美文网首页
实现一个乞丐版 slider

实现一个乞丐版 slider

作者: 月下吴刚_c8c7 | 来源:发表于2022-12-04 19:56 被阅读0次

    一、组件的使用

    使用 cra 搭建一个 新的 react 项目, 当前 react 版本是 18.2.0,在 app.js 中使用 slider

    function App() {
      const [inputValue, setInputValue] = useState(19);
    
      const onChange = (newValue) => {
        setInputValue(newValue);
      };
      return (
        <div className="App">
            <h2>我是slider</h2>
            <div style={{ width: "60%" }}>
              <div>value 值--- {inputValue}</div>
              <Slider
                min={1}
                max={100}
                defaultValue={20}
                onChange={onChange}
                value={typeof inputValue === "number" ? inputValue : 0}
              />
            </div>
          </header>
        </div>
      );
    }
    
    export default App;
    

    以上就可以直接使用单向数据流在 slider 上显示当前 value 值,也可以使用 slider 来改变 value 值;

    二、源码概览

    1、slider的属性, ref ,事件,方法

    属性

    // direction 直接使用 ltr
    // vertical  直接 fase
    // step 1
    // min 0
    // max 100
    // reverse 直接 false 
    
    // dragging  onStartDrag
    // draggingIndex
    // draggingValue
    // cacheValues
    
    // keyboardValue  setKeyboardValue
    
    // mergedValue  rawValues setValue
    
    // formatValue offsetValues
    // mergedMin mergedMax
    

    ref

    containerRef

    slider 组件的外层盒子,即最外层轨道;

    事件

    onSliderMouseDown

    当鼠标点击时,计算出发点的水平位置,此处只考虑 direaction 为左到右,getBoundingClientRect 用法可以参考 MDN 文档;此时只会改变 values 里的第一个值,其他的不考虑;此处点击得到值与 滑块的拖动无关,当修改 value 值后会触发第一个滑块的位置同步更新;

    const onSliderMouseDown = (e) => {
        e.preventDefault();
        const {
            width, // 包含,border 和 padding
            height,
            left,
            top,
            bottom,
            right,
        } = containerRef.current.getBoundingClientRect();
        const { clientX, clientY } = e;
        let percent = (clientX - left) / width; // 此处只考虑 direction='ltr'
        const nextValue = mergedMin + percent * (mergedMax - mergedMin);
        const newValue = formatValue(nextValue); // 按照 step 处理数值
        changeToCloseValue(newValue);
    };
    

    方法

    getTriggerValue

    源码如下,有 range 时就返回全部的 values , 没有 range 时,values 只有一个值,返回 values[0] 即可;triggerValues 既是 values 的浅复制, [...values] ;

    const getTriggerValue = (triggerValues) => range ? triggerValues : triggerValues[0];
    

    triggerChange

    拿到新值后干点什么,onChange 在此时执行;

    const triggerChange = (nextValues) => {
        const cloneNextValues = [...nextValues].sort((a, b) => a - b);
    
        if (onChange && !shallowEqual(cloneNextValues, rawValuesRef.current)) {
            onChange(getTriggerValue(cloneNextValues));
        }
        setValue(cloneNextValues);
    };
    

    changeToCloseValue

    可以在这里面执行 onBeforeChange 和 onAfterChange;

    const changeToCloseValue = (newValue) => {
        if (!disabled) {
            let valueIndex = 0;
            const cloneNextValues = [...rawValues];
            cloneNextValues[valueIndex] = newValue;
            //   onBeforeChange?.(getTriggerValue(cloneNextValues));
            triggerChange(cloneNextValues);
            //   onAfterChange?.(getTriggerValue(cloneNextValues));
        }
    };
    

    2、三个 hook

    1 useDrag

      const [draggingIndex, draggingValue, cacheValues, onStartDrag] = useDrag(
        containerRef,
        direction,
        rawValues,
        mergedMin,
        mergedMax,
        formatValue, // 真实 value 需要被格式化,根据 min, max, step
        triggerChange, // 函数,接收值的更新
        finishChange, // mousemove 之后需要执行的回调
        offsetValues // useOffset 返回的
      );
    

    2 useOffset

    formatValue 是一个函数,将每次得到额值格式化,这里是根据 mix, max ,step 三个值来进行计算。offsetValues 是一个函数,

      const [formatValue, offsetValues] = useOffset(
        mergedMin,
        mergedMax,
        mergedStep,
      );
    // 根据 range 和 step 来格式化
     const formatRangeValue = React.useCallback(
        (val) => {
          let formatNextValue = isFinite(val) ? val : min;
          formatNextValue = Math.min(max, val);
          formatNextValue = Math.max(min, formatNextValue);
          return formatNextValue;
        },
        [min, max]
      );
      const formatStepValue = React.useCallback(
        (val) => {
          if (step !== null) {
            const stepValue =
              min + Math.round((formatRangeValue(val) - min) / step) * step;
    
            const getDecimal = (num) => (String(num).split(".")[1] || "").length;
    
            const maxDecimal = Math.max(
              getDecimal(step),
              getDecimal(max),
              getDecimal(min)
            );
            const fixedValue = Number(stepValue.toFixed(maxDecimal));
            return min <= fixedValue && fixedValue <= max ? fixedValue : null;
          }
    
          return null;
        },
        [step, min, max, formatRangeValue]
      );
    // 根据 mix,max,step
      const formatValue = React.useCallback(
        (val) => {
          // ...
          return formatStepValue(val) // 简化后的计算
        },
        [min, max, step]
      );
    
      //  offset 是 valueIndex 所在滑块的 move 偏移量,offsetValues 方法只在 move 时调用,得到某个滑块的新值,以及当前所有滑块的经过 formatValue 处理后的新值;
      const offsetValues = (values, offset, valueIndex, mode = "unit") => {
        //...
        return {
          value: nextValues[valueIndex],
          values: nextValues,
        };
      }
    

    3 useMergedState

    主要将 value 值和 defaultValue 值进行合并计算, 优先级: value---> 0,最后 mergedValue 的初始值为 value 或 0, 此时 min =1,max=100

    import useMergedState from "rc-util/lib/hooks/useMergedState";
    const [mergedValue, setValue] = useMergedState(defaultValue, {
        value,
    });
    

    3、两个第三方库

    1 classNames

    合并 class

     <div
         ref={containerRef}
         className={classNames(prefixCls, className, {
            [`${prefixCls}-disabled`]: disabled,
            [`${prefixCls}-vertical`]: vertical,
            [`${prefixCls}-horizontal`]: !vertical,
         })}
         style={style}
         onMouseDown={onSliderMouseDown}
     >
    

    2 shallowEqual

    顾名思义,浅层判断两个值是否相等;

    shallowEqual(cloneNextValues, rawValuesRef.current);
    

    4、子组件

    一共有四个,Handels, Tracks,Steps,Marks,这里只考虑最常用最简单的场景,只需要 Handels

    1 Handles

    根据 value 的数量来生成 handel ;handel 就是一个绝对定位的空 div 滑块;

     {values.map((value, index) => (
         <Handle
             dragging={draggingIndex === index}
             prefixCls={prefixCls}
             style={getIndex(style, index)}
             key={index}
             value={value}
             valueIndex={index}
             onStartMove={onStartMove}
             render={handleRender}
             {...restProps}
             />
     ))}
    
    • 滑块组件,根据 传入的 value 值,mix, max,三个属性来计算绝对定位的值,从而决定滑块位置,当滑块被拖动时,会触发修改 vlaue ,从而更新就绝对定位位置;当value 是数组时,会生成多个滑块;

    • values 数组的 index 会从 map 函数传入 handel, 当 handel 被拖动时,index 会从 useDrag 提供的 onStartMove 函数 传入,修改 useDrag 内的 draggingIndex,draggingIndex 再出来进入 slider ,然后从handles 进入每个 handel,最后用来判断 每个 handel 的 dragging 属性是否为 true。也就是说,draggingIndex 的作用只有一个,就是和每个 handel 的 index 对比来判断各个 handel 的 dragging 属性为 true 还是 false。 这里兜转了一圈,相当辛苦,只因为单向数据流。

    • 其中发现了一个可以用键盘操作的库

      import KeyCode from 'rc-util/lib/KeyCode';
      

    三、实现雏形,点击得到值;

    修改 value 有两种方式,在轨道上点击 鼠标,拖动滑块;

    思路:当在轨道上点击鼠标时,获取mouseDown 事件的 clientX,clientY(值与页面滚动无关,只与浏览器有关),然后根据 轨道的 width,min,max,计算出 value 值,这就是点击轨道得到值。与后面的滑块拖拽无关,但是值改变了可以影响滑块的位置;

    注意:点击轨道只会修改 vlaues 中第一个的值,对其他的 value 无影响;

      const onSliderMouseDown = (e) => {
        e.preventDefault();
        const {
          width,
          height,
          left,
          top,
          bottom,
          right,
        } = containerRef.current.getBoundingClientRect();
        const { clientX, clientY } = e;
        let percent = (clientX - left) / width;
    
        const nextValue = mergedMin + percent * (mergedMax - mergedMin);
        const newValue = formatValue(nextValue); // 按照 step 格式化处理数值
        console.log("newValue-----", newValue);
        console.log("nextValue-----", nextValue);
        changeToCloseValue(newValue);
      };
    

    四、滑块的静态值和动态值;

    • 滑块其实就是一个 空的 div 盒子,当禁止时,使用绝对定位来巨顶其在 轨道槽上的位置,拖动时,会触发 onMove 来修改 value 值,然后单向数据 value 值 会影响其绝对定位得位置,实现 UI 与状态的 统一;

    • 滑块还有一个 active 和 hover 的 css 属性,改变鼠标的形式;

      .rc-slider-handle:hover {
        border-color: #57c5f7;
      }
      .rc-slider-handle:active {
        border-color: #57c5f7;
        box-shadow: 0 0 5px #57c5f7;
        cursor: -webkit-grabbing;
        cursor: grabbing;
      }
      .rc-slider-handle-dragging.rc-slider-handle-dragging.rc-slider-handle-dragging {
        border-color: #57c5f7;
        box-shadow: 0 0 0 5px #96dbfa;
      }
      

    五、滑块的拖动;

    useDrag 中返回一个 onStartDrag ,当滑块上触发 onMouseDown 时,就开始执行 onInternalStartMove ;valueIndex 是保证 values有多个时,多个滑块动谁就改变谁,不动的不改变;

    // handel.js
    // onStartMove 和 valueIndex 从 slider 中传入,其中 onStartMove 从useDrag中来
     const onInternalStartMove = e => {
         if (!disabled) {
             onStartMove(e, valueIndex);
         }
     };
    
    // useDrag.js 
    // 源码中考虑了移动端
    function getPosition(e) {
      const obj = "touches" in e ? e.touches[0] : e;
      return {
        pageX: obj.pageX,
        pageY: obj.pageY,
      };
    }
    
    const onStartMove = (e, valueIndex) => {
        e.stopPropagation();
        const originValue = rawValues[valueIndex];
        setDraggingIndex(valueIndex);
        setDraggingValue(originValue);
        setOriginValues(rawValues);
        // pageX 和 pageY 包含需要考虑滚动
        const { pageX: startX, pageY: startY } = getPosition(e);
        const onMouseMove = (event) => {
            event.preventDefault();
            const { pageX: moveX, pageY: moveY } = getPosition(event);
            const offsetX = moveX - startX;
            const offsetY = moveY - startY;
            const { width, height } = containerRef.current.getBoundingClientRect();
            let offSetPercent;
            if (direction === 'ltr') offSetPercent = offsetX / width
            updateCacheValueRef.current(valueIndex, offSetPercent);
        };
    
        const onMouseUp = (event) => {
            event.preventDefault();
            document.removeEventListener("mouseup", onMouseUp);
            document.removeEventListener("mousemove", onMouseMove);
            document.removeEventListener("touchend", onMouseUp);
            document.removeEventListener("touchmove", onMouseMove);
            mouseMoveEventRef.current = null;
            mouseUpEventRef.current = null;
            setDraggingIndex(-1);
            finishChange();
        };
    
        document.addEventListener("mouseup", onMouseUp);
        document.addEventListener("mousemove", onMouseMove);
        document.addEventListener("touchend", onMouseUp);
        document.addEventListener("touchmove", onMouseMove);
        mouseMoveEventRef.current = onMouseMove;
        mouseUpEventRef.current = onMouseUp;
    };
    

    六、总结

    官方组件好用的原因, 组件之间解耦清晰,各组件和 hook 职责分明,还有对象引用互不关联,经常有对象或数组的解构拷贝使用,最后就是各种 null 和 undefined 的判断,代码健壮性好。除去核心功能代码,健壮性兼容代码占比很重,保证了代码怎么玩都不会报错。

    相关文章

      网友评论

          本文标题:实现一个乞丐版 slider

          本文链接:https://www.haomeiwen.com/subject/hhhmfdtx.html