一、組件的使用
使用 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ò)。