实现一个乞丐版 slider

一、组件的使用

使用 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 的判断,代码健壮性好。除去核心功能代码,健壮性兼容代码占比很重,保证了代码怎么玩都不会报错。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,907评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,987评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,298评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,586评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,633评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,488评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,275评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,176评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,619评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,819评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,932评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,655评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,265评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,871评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,994评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,095评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,884评论 2 354

推荐阅读更多精彩内容