【解读 ahooks 源码系列】 Scene 篇(二)
前言
本文是 ahooks 源码(v3.7.4)系列的第十三篇——【解读 ahooks 源码系列】 Scene 篇(二)
往期文章:
- 【解读 ahooks 源码系列】(开篇)如何获取和监听 DOM 元素:useEffectWithTarget
- 【解读 ahooks 源码系列】DOM 篇(一):useEventListener、useClickAway、useDocumentVisibility、useDrop、useDrag
- 【解读 ahooks 源码系列】DOM 篇(二):useEventTarget、useExternal、useTitle、useFavicon、useFullscreen、useHover
- 【解读 ahooks 源码系列】DOM 篇(三):useMutationObserver、useInViewport、useKeyPress、useLongPress
- 【解读 ahooks 源码系列】DOM 篇(四):useMouse、useResponsive、useScroll、useSize、useFocusWithin
- 【解读 ahooks 源码系列】Dev 篇——useTrackedEffect 和 useWhyDidYouUpdate
- 【解读 ahooks 源码系列】Advanced 篇:useControllableValue、useCreation、useIsomorphicLayoutEffect、useEventEmitter、useLatest、useMemoizedFn、useReactive
- 【解读 ahooks 源码系列】State 篇(一):useSetState、useToggle、useBoolean、useCookieState、useLocalStorageState、useSessionStorageState、useDebounce、useThrottle
- 【解读 ahooks 源码系列】State 篇(二):useMap、useSet、usePrevious、useRafState、useSafeState、useGetState、useResetState
- 【解读 ahooks 源码系列】Effect 篇(一):useUpdateEffect、useUpdateLayoutEffect、useAsyncEffect、useDebounceFn、useDebounceEffect、useThrottleFn、useThrottleEffect
- 【解读 ahooks 源码系列】Effect 篇(二):useDeepCompareEffect、useDeepCompareLayoutEffect、useInterval、useTimeout、useRafInterval、useRafTimeout、useLockFn、useUpdate、useThrottleEffect
- 【解读 ahooks 源码系列】LifeCycle 篇 与 Scene 篇(一):useMount、useUnmount、useUnmountedRef、useCounter、useNetwork、useSelections、useHistoryTravel
本文主要解读 useTextSelection
、useCountdown
、useDynamicList
、useWebSocket
的源码实现
useTextSelection
实时获取用户当前选取的文本内容及位置。
基本用法
import React from 'react';
import { useTextSelection } from 'ahooks';
export default () => {
const { text } = useTextSelection();
return (
<div>
<p>You can select text all page.</p>
<p>Result:{text}</p>
</div>
);
};
核心实现
Element.getBoundingClientRect()
:返回一个 DOMRect 对象,其提供了元素的大小及其相对于视口的位置。Window.getSelection
:返回一个 Selection 对象,表示用户选择的文本范围或光标的当前位置。Selection.removeAllRanges()
:从当前 selection 对象中移除所有的 range 对象,取消所有的选择只留下 anchorNode 和 focusNode 属性并将其设置为 nullSelection.getRangeAt
:返回一个包含当前选区内容的区域对象。
实现思路:
- 通过监听
mousedown
和mouseup
事件,获取选中文本内容使用window.getSelection()
方法,而获取位置信息使用getBoundingClientRect
方法 mousedown
回调:清空之前的信息(state/range)、判断选中范围是否在目标区域mouseup
回调:获取选中区域文本与位置信息,更新到 state
const initRect: Rect = {
top: NaN,
left: NaN,
bottom: NaN,
right: NaN,
height: NaN,
width: NaN,
};
const initState: State = {
text: '',
...initRect,
};
function useTextSelection(target?: BasicTarget<Document | Element>): State {
const [state, setState] = useState(initState);
const stateRef = useRef(state);
const isInRangeRef = useRef(false);
stateRef.current = state;
useEffectWithTarget(
() => {
// 获取目标元素
const el = getTargetElement(target, document);
if (!el) {
return;
}
const mouseupHandler = () => {
let selObj: Selection | null = null;
let text = '';
let rect = initRect;
if (!window.getSelection) return;
// 返回一个 Selection 对象,表示用户选择的文本范围或光标的当前位置
selObj = window.getSelection();
// 转为字符串
text = selObj ? selObj.toString() : '';
if (text && isInRangeRef.current) {
// 获取文本位置信息并设置
rect = getRectFromSelection(selObj);
setState({ ...state, text, ...rect });
}
};
// 任意点击都需要清空之前的 range
const mousedownHandler = (e) => {
if (!window.getSelection) return;
if (stateRef.current.text) {
setState({ ...initState });
}
isInRangeRef.current = false;
// 返回一个 Selection 对象,表示用户选择的文本范围或光标的当前位置
const selObj = window.getSelection();
if (!selObj) return;
selObj.removeAllRanges();
isInRangeRef.current = el.contains(e.target);
};
el.addEventListener('mouseup', mouseupHandler);
document.addEventListener('mousedown', mousedownHandler);
return () => {
el.removeEventListener('mouseup', mouseupHandler);
document.removeEventListener('mousedown', mousedownHandler);
};
},
[],
target,
);
return state;
}
获取文本位置信息函数:
function getRectFromSelection(selection: Selection | null): Rect {
if (!selection) {
return initRect;
}
// rangeCount:返回选区 (selection) 中 range 对象数量的只读属性
if (selection.rangeCount < 1) {
return initRect;
}
// 返回一个包含当前选区内容的区域对象
const range = selection.getRangeAt(0);
const { height, width, top, left, right, bottom } = range.getBoundingClientRect();
return {
height,
width,
top,
left,
right,
bottom,
};
}
useCountdown
一个用于管理倒计时的 Hook。
基本用法
import React from 'react';
import { useCountDown } from 'ahooks';
export default () => {
const [countdown, formattedRes] = useCountDown({
targetDate: '2022-12-31 24:00:00',
});
const { days, hours, minutes, seconds, milliseconds } = formattedRes;
return (
<>
<p>
There are {days} days {hours} hours {minutes} minutes {seconds} seconds {milliseconds}{' '}
milliseconds until 2022-12-31 24:00:00
</p>
</>
);
};
核心实现
实现思路:通过定时器 setInterval 进行设置倒计时;当剩余时间为负值时,停止倒计时,执行结束回调。
const useCountdown = (options: Options = {}) => {
const { leftTime, targetDate, interval = 1000, onEnd } = options || {};
const target = useMemo<TDate>(() => {
// 如果传了 leftTime,则采用 leftTime,忽略 targetDate
if ('leftTime' in options) {
return isNumber(leftTime) && leftTime > 0 ? Date.now() + leftTime : undefined;
} else {
return targetDate;
}
}, [leftTime, targetDate]);
const [timeLeft, setTimeLeft] = useState(() => calcLeft(target));
// 最新引用的倒计时结束回调
const onEndRef = useLatest(onEnd);
useEffect(() => {
if (!target) {
// for stop
setTimeLeft(0);
return;
}
// 立即执行一次
setTimeLeft(calcLeft(target));
const timer = setInterval(() => {
const targetLeft = calcLeft(target);
setTimeLeft(targetLeft);
// 为0代表倒计时结束
if (targetLeft === 0) {
clearInterval(timer); // 清除定时器
onEndRef.current?.(); // 执行回调
}
}, interval);
return () => clearInterval(timer);
}, [target, interval]);
// 返回格式化后的倒计时
const formattedRes = useMemo(() => parseMs(timeLeft), [timeLeft]);
// [倒计时时间戳(毫秒), 格式化后的倒计时]
return [timeLeft, formattedRes] as const;
};
来看下 calcLeft 和 parseMs 函数:
// 计算目标时间和当前时间相差的毫秒数
const calcLeft = (target?: TDate) => {
if (!target) {
return 0;
}
// https://stackoverflow.com/questions/4310953/invalid-date-in-safari
// 剩余时间 = 目标时间 - 当前时间
const left = dayjs(target).valueOf() - Date.now();
// 剩余时间小于0,则返回0表示结束
return left < 0 ? 0 : left;
};
// 格式化倒计时
const parseMs = (milliseconds: number): FormattedRes => {
return {
days: Math.floor(milliseconds / 86400000),
hours: Math.floor(milliseconds / 3600000) % 24,
minutes: Math.floor(milliseconds / 60000) % 60,
seconds: Math.floor(milliseconds / 1000) % 60,
milliseconds: Math.floor(milliseconds) % 1000,
};
};
useDynamicList
一个帮助你管理动态列表状态,并能生成唯一 key 的 Hook。
基本用法
import { MinusCircleOutlined, PlusCircleOutlined } from '@ant-design/icons';
import { useDynamicList } from 'ahooks';
import { Input } from 'antd';
import React from 'react';
export default () => {
const { list, remove, getKey, insert, replace } = useDynamicList(['David', 'Jack']);
const Row = (index: number, item: any) => (
<div key={getKey(index)} style={{ marginBottom: 16 }}>
<Input
style={{ width: 300 }}
placeholder="Please enter name"
onChange={(e) => replace(index, e.target.value)}
value={item}
/>
{list.length > 1 && (
<MinusCircleOutlined
style={{ marginLeft: 8 }}
onClick={() => {
remove(index);
}}
/>
)}
<PlusCircleOutlined
style={{ marginLeft: 8 }}
onClick={() => {
insert(index + 1, '');
}}
/>
</div>
);
return (
<>
{list.map((ele, index) => Row(index, ele))}
<div>{JSON.stringify([list])}</div>
</>
);
};
核心实现
实现思路:
- 对数组的常见 API 进行封装
- 维护一个 list 列表数组和 keyList,每次操作元素都要设置 list 和 keyList
维护的 list 列表:
// 当前的列表
const [list, setList] = useState(() => {
initialList.forEach((_, index) => {
setKey(index);
});
return initialList;
});
比如我们要进行插入操作时,使用 js 的 splice
方法进行插入,赋值新的 list 值,同时调用 setKey 方法
// 在指定位置插入元素
const insert = useCallback((index: number, item: T) => {
setList((l) => {
const temp = [...l];
temp.splice(index, 0, item);
setKey(index);
return temp;
});
}, []);
setKey 方法里面使用 counterRef 维持自增 key(唯一标识符),并把该标识符插入到 keyList,确保每个列表项都有一个唯一的标识符,进行增删等等元素操作就不会出现问题。
const counterRef = useRef(-1); // 存储最后一个key
// 包含了列表中每个项的唯一标识符
const keyList = useRef<number[]>([]);
// 用于更新 keyList,确保 keyList 始终包含最新的唯一标识符列表
const setKey = useCallback((index: number) => {
counterRef.current += 1; // 每次设置都保持自增 +1
keyList.current.splice(index, 0, counterRef.current);
}, []);
其它的封装实现都大同小异:
// 重新设置 list 的值
const resetList = useCallback((newList: T[]) => {
keyList.current = [];
setList(() => {
newList.forEach((_, index) => {
setKey(index);
});
return newList;
});
}, []);
// 获得某个元素的 uuid
const getKey = useCallback((index: number) => keyList.current[index], []);
// 获得某个 key 的 index
const getIndex = useCallback(
(key: number) => keyList.current.findIndex((ele) => ele === key),
[],
);
// 在指定位置插入多个元素
const merge = useCallback((index: number, items: T[]) => {
setList((l) => {
const temp = [...l];
items.forEach((_, i) => {
setKey(index + i);
});
temp.splice(index, 0, ...items);
return temp;
});
}, []);
// 替换指定元素
const replace = useCallback((index: number, item: T) => {
setList((l) => {
const temp = [...l];
temp[index] = item;
return temp;
});
}, []);
// 删除指定元素
const remove = useCallback((index: number) => {
setList((l) => {
const temp = [...l];
temp.splice(index, 1);
// remove keys if necessary
try {
keyList.current.splice(index, 1);
} catch (e) {
console.error(e);
}
return temp;
});
}, []);
// 移动元素
const move = useCallback((oldIndex: number, newIndex: number) => {
if (oldIndex === newIndex) {
return;
}
setList((l) => {
const newList = [...l];
const temp = newList.filter((_, index: number) => index !== oldIndex);
temp.splice(newIndex, 0, newList[oldIndex]);
// move keys if necessary
try {
const keyTemp = keyList.current.filter((_, index: number) => index !== oldIndex);
keyTemp.splice(newIndex, 0, keyList.current[oldIndex]);
keyList.current = keyTemp;
} catch (e) {
console.error(e);
}
return temp;
});
}, []);
// 在列表末尾添加元素
const push = useCallback((item: T) => {
setList((l) => {
setKey(l.length);
return l.concat([item]);
});
}, []);
// 移除末尾元素
const pop = useCallback(() => {
// remove keys if necessary
try {
keyList.current = keyList.current.slice(0, keyList.current.length - 1);
} catch (e) {
console.error(e);
}
setList((l) => l.slice(0, l.length - 1));
}, []);
// 在列表起始位置添加元素
const unshift = useCallback((item: T) => {
setList((l) => {
setKey(0);
return [item].concat(l);
});
}, []);
// 移除起始位置元素
const shift = useCallback(() => {
// remove keys if necessary
try {
keyList.current = keyList.current.slice(1, keyList.current.length);
} catch (e) {
console.error(e);
}
setList((l) => l.slice(1, l.length));
}, []);
// 校准排序
const sortList = useCallback(
(result: T[]) =>
result
.map((item, index) => ({ key: index, item })) // add index into obj
.sort((a, b) => getIndex(a.key) - getIndex(b.key)) // sort based on the index of table
.filter((item) => !!item.item) // remove undefined(s)
.map((item) => item.item), // retrive the data
[],
);
useWebSocket
用于处理 WebSocket 的 Hook。
基本用法
import React, { useRef, useMemo } from 'react';
import { useWebSocket } from 'ahooks';
enum ReadyState {
Connecting = 0,
Open = 1,
Closing = 2,
Closed = 3,
}
export default () => {
const messageHistory = useRef<any[]>([]);
const { readyState, sendMessage, latestMessage, disconnect, connect } = useWebSocket(
'wss://demo.piesocket.com/v3/channel_1?api_key=VCXCEuvhGcBDP7XhiJJUDvR1e1D3eiVjgZ9VRiaV¬ify_self',
);
messageHistory.current = useMemo(
() => messageHistory.current.concat(latestMessage),
[latestMessage],
);
return (
<div>
{/* send message */}
<button
onClick={() => sendMessage && sendMessage(`${Date.now()}`)}
disabled={readyState !== ReadyState.Open}
style={{ marginRight: 8 }}
>
✉️ send
</button>
{/* disconnect */}
<button
onClick={() => disconnect && disconnect()}
disabled={readyState !== ReadyState.Open}
style={{ marginRight: 8 }}
>
❌ disconnect
</button>
{/* connect */}
<button onClick={() => connect && connect()} disabled={readyState === ReadyState.Open}>
{readyState === ReadyState.Connecting ? 'connecting' : '📞 connect'}
</button>
<div style={{ marginTop: 8 }}>readyState: {readyState}</div>
<div style={{ marginTop: 8 }}>
<p>received message: </p>
{messageHistory.current.map((message, index) => (
<p key={index} style={{ wordWrap: 'break-word' }}>
{message?.data}
</p>
))}
</div>
</div>
);
};
WebSocket 基础知识
WebSocket 对象提供了用于创建和管理 WebSocket 连接,以及可以通过该连接发送和接收数据的 API。
WebSocket() 构造函数
使用 WebSocket() 构造函数来构造一个 WebSocket。
var aWebSocket = new WebSocket(url [, protocols]);
- url:要连接的 URL,即 WebSocket 服务器将响应的 URL。
- protocols:一个协议字符串或者一个包含协议字符串的数组。这些字符串用于指定子协议,这样单个服务器可以实现多个 WebSocket 子协议
readyState 常量
- WebSocket.CONNECTING(正在连接中):0
- WebSocket.OPEN(已经连接并且可以通讯):1
- WebSocket.CLOSING(连接正在关闭):2
- WebSocket.CLOSED(连接已关闭或者没有连接成功):3
属性
- WebSocket.readyState:当前的连接状态
- WebSocket.onopen:用于指定连接成功后的回调函数
- WebSocket.onclose:用于指定连接关闭后的回调函数
- WebSocket.onerror:用于指定连接失败后的回调函数
- WebSocket.onmessage:用于指定当从服务器接受到信息时的回调函数
方法
- WebSocket.close():关闭当前链接
- WebSocket.send(data):对要传输的数据进行排队
核心实现
- 如果没有传
manual
为true
指定手动连接的话,进来会默认自动连接。
const reconnectTimesRef = useRef(0);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout>>();
const websocketRef = useRef<WebSocket>(); // webSocket 实例
// 当前 webSocket 连接状态
const [readyState, setReadyState] = useState<ReadyState>(ReadyState.Closed);
useEffect(() => {
if (!manual) {
connect();
}
}, [socketUrl, manual]);
// 手动连接 webSocket,如果当前已有连接,则关闭后重新连接
const connect = () => {
reconnectTimesRef.current = 0; // 重置 websocket 重连次数
connectWs();
};
const connectWs = () => {
// 如当前处于重连逻辑处理,则清除重连定时器
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
// 关闭之前的 websocket 连接
if (websocketRef.current) {
websocketRef.current.close();
}
const ws = new WebSocket(socketUrl, protocols);
setReadyState(ReadyState.Connecting);
// 监听连接失败后的回调函数
ws.onerror = (event) => {
if (unmountedRef.current) {
return;
}
reconnect(); // 错误则进行重连 websocket
onErrorRef.current?.(event, ws); // 执行错误回调
setReadyState(ws.readyState || ReadyState.Closed);
};
// 监听连接成功后的回调函数
ws.onopen = (event) => {
if (unmountedRef.current) {
return;
}
onOpenRef.current?.(event, ws); // 执行连接成功回调
reconnectTimesRef.current = 0; // 连接成功后重置重连次数
setReadyState(ws.readyState || ReadyState.Open);
};
// 监听从服务器接受到信息时的回调函数
ws.onmessage = (message: WebSocketEventMap['message']) => {
if (unmountedRef.current) {
return;
}
onMessageRef.current?.(message, ws); // 执行收到消息回调
setLatestMessage(message); // 设置最新的 message
};
// 监听连接关闭后的回调函数
ws.onclose = (event) => {
if (unmountedRef.current) {
return;
}
reconnect(); // 重连
onCloseRef.current?.(event, ws);
setReadyState(ws.readyState || ReadyState.Closed);
};
websocketRef.current = ws; // 保存 websocket 实例
};
- 重连与断开连接实现
重连:
const reconnect = () => {
// 没有超过重试次数 && 没有连接成功
if (
reconnectTimesRef.current < reconnectLimit &&
websocketRef.current?.readyState !== ReadyState.Open
) {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
// 指定重试时间间隔后重连
reconnectTimerRef.current = setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
connectWs();
reconnectTimesRef.current++;
}, reconnectInterval);
}
};
断开连接:
// 手动断开 webSocket 连接
const disconnect = () => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
reconnectTimesRef.current = reconnectLimit;
websocketRef.current?.close();
};
// 组件销毁时,则断开
useUnmount(() => {
unmountedRef.current = true; // 标识设置为已卸载
disconnect();
});
- 发送消息
const sendMessage: WebSocket['send'] = (message) => {
// 连接成功状态才可发送
if (readyState === ReadyState.Open) {
websocketRef.current?.send(message);
} else {
throw new Error('WebSocket disconnected');
}
};
转载自:https://juejin.cn/post/7244174211970285623