【解读 ahooks 源码系列】 Scene 篇(二)

前言

本文是 ahooks 源码(v3.7.4)系列的第十三篇——【解读 ahooks 源码系列】 Scene 篇(二)

往期文章:

本文主要解读 useTextSelectionuseCountdownuseDynamicListuseWebSocket 的源码实现

useTextSelection

实时获取用户当前选取的文本内容及位置。

基本用法

官方在线 Demo

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 属性并将其设置为 null
  • Selection.getRangeAt:返回一个包含当前选区内容的区域对象。

实现思路:

  1. 通过监听 mousedownmouseup 事件,获取选中文本内容使用 window.getSelection() 方法,而获取位置信息使用 getBoundingClientRect 方法
  2. mousedown 回调:清空之前的信息(state/range)、判断选中范围是否在目标区域
  3. 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。

基本用法

官方在线 Demo

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。

基本用法

官方在线 Demo

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>
    </>
  );
};

核心实现

实现思路:

  1. 对数组的常见 API 进行封装
  2. 维护一个 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。

基本用法

官方在线 Demo

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&notify_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):对要传输的数据进行排队

核心实现

  1. 如果没有传 manualtrue指定手动连接的话,进来会默认自动连接。
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 实例
};
  1. 重连与断开连接实现

重连:

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();
});
  1. 发送消息
const sendMessage: WebSocket['send'] = (message) => {
// 连接成功状态才可发送
if (readyState === ReadyState.Open) {
websocketRef.current?.send(message);
} else {
throw new Error('WebSocket disconnected');
}
};

完整源码

原文链接:https://juejin.cn/post/7244174211970285623 作者:JackySummer

(0)
上一篇 2023年6月14日 上午10:46
下一篇 2023年6月14日 上午10:56

相关推荐

发表回复

登录后才能评论