實(shí)現(xiàn)一個(gè)乞丐版 slider

一、組件的使用

使用 cra 搭建一個(gè) 新的 react 項(xiàng)目, 當(dāng)前 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;

以上就可以直接使用單向數(shù)據(jù)流在 slider 上顯示當(dāng)前 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

當(dāng)鼠標(biāo)點(diǎn)擊時(shí),計(jì)算出發(fā)點(diǎn)的水平位置,此處只考慮 direaction 為左到右,getBoundingClientRect 用法可以參考 MDN 文檔;此時(shí)只會(huì)改變 values 里的第一個(gè)值,其他的不考慮;此處點(diǎn)擊得到值與 滑塊的拖動(dòng)無關(guān),當(dāng)修改 value 值后會(huì)觸發(fā)第一個(gè)滑塊的位置同步更新;

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 處理數(shù)值
    changeToCloseValue(newValue);
};

方法

getTriggerValue

源碼如下,有 range 時(shí)就返回全部的 values , 沒有 range 時(shí),values 只有一個(gè)值,返回 values[0] 即可;triggerValues 既是 values 的淺復(fù)制, [...values] ;

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

triggerChange

拿到新值后干點(diǎn)什么,onChange 在此時(shí)執(zhí)行;

const triggerChange = (nextValues) => {
    const cloneNextValues = [...nextValues].sort((a, b) => a - b);

    if (onChange && !shallowEqual(cloneNextValues, rawValuesRef.current)) {
        onChange(getTriggerValue(cloneNextValues));
    }
    setValue(cloneNextValues);
};

changeToCloseValue

可以在這里面執(zhí)行 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、三個(gè) hook

1 useDrag

  const [draggingIndex, draggingValue, cacheValues, onStartDrag] = useDrag(
    containerRef,
    direction,
    rawValues,
    mergedMin,
    mergedMax,
    formatValue, // 真實(shí) value 需要被格式化,根據(jù) min, max, step
    triggerChange, // 函數(shù),接收值的更新
    finishChange, // mousemove 之后需要執(zhí)行的回調(diào)
    offsetValues // useOffset 返回的
  );

2 useOffset

formatValue 是一個(gè)函數(shù),將每次得到額值格式化,這里是根據(jù) mix, max ,step 三個(gè)值來進(jìn)行計(jì)算。offsetValues 是一個(gè)函數(shù),

  const [formatValue, offsetValues] = useOffset(
    mergedMin,
    mergedMax,
    mergedStep,
  );
// 根據(jù) 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]
  );
// 根據(jù) mix,max,step
  const formatValue = React.useCallback(
    (val) => {
      // ...
      return formatStepValue(val) // 簡化后的計(jì)算
    },
    [min, max, step]
  );

  //  offset 是 valueIndex 所在滑塊的 move 偏移量,offsetValues 方法只在 move 時(shí)調(diào)用,得到某個(gè)滑塊的新值,以及當(dāng)前所有滑塊的經(jīng)過 formatValue 處理后的新值;
  const offsetValues = (values, offset, valueIndex, mode = "unit") => {
    //...
    return {
      value: nextValues[valueIndex],
      values: nextValues,
    };
  }

3 useMergedState

主要將 value 值和 defaultValue 值進(jìn)行合并計(jì)算, 優(yōu)先級: value---> 0,最后 mergedValue 的初始值為 value 或 0, 此時(shí) min =1,max=100

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

3、兩個(gè)第三方庫

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

顧名思義,淺層判斷兩個(gè)值是否相等;

shallowEqual(cloneNextValues, rawValuesRef.current);

4、子組件

一共有四個(gè),Handels, Tracks,Steps,Marks,這里只考慮最常用最簡單的場景,只需要 Handels

1 Handles

根據(jù) value 的數(shù)量來生成 handel ;handel 就是一個(gè)絕對定位的空 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}
         />
 ))}
  • 滑塊組件,根據(jù) 傳入的 value 值,mix, max,三個(gè)屬性來計(jì)算絕對定位的值,從而決定滑塊位置,當(dāng)滑塊被拖動(dòng)時(shí),會(huì)觸發(fā)修改 vlaue ,從而更新就絕對定位位置;當(dāng)value 是數(shù)組時(shí),會(huì)生成多個(gè)滑塊;

  • values 數(shù)組的 index 會(huì)從 map 函數(shù)傳入 handel, 當(dāng) handel 被拖動(dòng)時(shí),index 會(huì)從 useDrag 提供的 onStartMove 函數(shù) 傳入,修改 useDrag 內(nèi)的 draggingIndex,draggingIndex 再出來進(jìn)入 slider ,然后從handles 進(jìn)入每個(gè) handel,最后用來判斷 每個(gè) handel 的 dragging 屬性是否為 true。也就是說,draggingIndex 的作用只有一個(gè),就是和每個(gè) handel 的 index 對比來判斷各個(gè) handel 的 dragging 屬性為 true 還是 false。 這里兜轉(zhuǎn)了一圈,相當(dāng)辛苦,只因?yàn)閱蜗驍?shù)據(jù)流。

  • 其中發(fā)現(xiàn)了一個(gè)可以用鍵盤操作的庫

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

三、實(shí)現(xiàn)雛形,點(diǎn)擊得到值;

修改 value 有兩種方式,在軌道上點(diǎn)擊 鼠標(biāo),拖動(dòng)滑塊;

思路:當(dāng)在軌道上點(diǎn)擊鼠標(biāo)時(shí),獲取mouseDown 事件的 clientX,clientY(值與頁面滾動(dòng)無關(guān),只與瀏覽器有關(guān)),然后根據(jù) 軌道的 width,min,max,計(jì)算出 value 值,這就是點(diǎn)擊軌道得到值。與后面的滑塊拖拽無關(guān),但是值改變了可以影響滑塊的位置;

注意:點(diǎn)擊軌道只會(huì)修改 vlaues 中第一個(gè)的值,對其他的 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 格式化處理數(shù)值
    console.log("newValue-----", newValue);
    console.log("nextValue-----", nextValue);
    changeToCloseValue(newValue);
  };

四、滑塊的靜態(tài)值和動(dòng)態(tài)值;

  • 滑塊其實(shí)就是一個(gè) 空的 div 盒子,當(dāng)禁止時(shí),使用絕對定位來巨頂其在 軌道槽上的位置,拖動(dòng)時(shí),會(huì)觸發(fā) onMove 來修改 value 值,然后單向數(shù)據(jù) value 值 會(huì)影響其絕對定位得位置,實(shí)現(xiàn) UI 與狀態(tài)的 統(tǒng)一;

  • 滑塊還有一個(gè) active 和 hover 的 css 屬性,改變鼠標(biāo)的形式;

    .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;
    }
    

五、滑塊的拖動(dòng);

useDrag 中返回一個(gè) onStartDrag ,當(dāng)滑塊上觸發(fā) onMouseDown 時(shí),就開始執(zhí)行 onInternalStartMove ;valueIndex 是保證 values有多個(gè)時(shí),多個(gè)滑塊動(dòng)誰就改變誰,不動(dòng)的不改變;

// handel.js
// onStartMove 和 valueIndex 從 slider 中傳入,其中 onStartMove 從useDrag中來
 const onInternalStartMove = e => {
     if (!disabled) {
         onStartMove(e, valueIndex);
     }
 };

// useDrag.js 
// 源碼中考慮了移動(dòng)端
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 包含需要考慮滾動(dòng)
    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;
};

六、總結(jié)

官方組件好用的原因, 組件之間解耦清晰,各組件和 hook 職責(zé)分明,還有對象引用互不關(guān)聯(lián),經(jīng)常有對象或數(shù)組的解構(gòu)拷貝使用,最后就是各種 null 和 undefined 的判斷,代碼健壯性好。除去核心功能代碼,健壯性兼容代碼占比很重,保證了代碼怎么玩都不會(huì)報(bào)錯(cuò)。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容