目录

📝 解读 ahooks 源码系列 - Scene

本篇文章是解读 ahooks@3.8.0 源码系列的第二篇 - Scene,欢迎您的指正和点赞。

本文主要解读 useAntdTable、useFusionTable、useInfiniteScroll、usePagination、useDynamicList、useVirtualList、useHistoryTravel、useNetwork、useSelections、useCountDown、useCounter、useTextSelection、useWebSocket 的源码实现。

useAntdTable

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import type {
  PaginationOptions,
  PaginationResult,
} from "../usePagination/types";

export type Data = { total: number; list: any[] };

export type Params = [
  {
    current: number;
    pageSize: number;
    sorter?: any;
    filter?: any;
    extra?: any;
    [key: string]: any;
  },
  ...any[]
];

export type Service<TData extends Data, TParams extends Params> = (
  ...args: TParams
) => Promise<TData>;

export type Antd3ValidateFields = (
  fieldNames: string[],
  callback: (errors, values: Record<string, any>) => void
) => void;

export type Antd4ValidateFields = (
  fieldNames?: string[]
) => Promise<Record<string, any>>;

export interface AntdFormUtils {
  getFieldInstance?: (name: string) => Record<string, any>;
  setFieldsValue: (value: Record<string, any>) => void;
  getFieldsValue: (...args: any) => Record<string, any>;
  resetFields: (...args: any) => void;
  validateFields: Antd3ValidateFields | Antd4ValidateFields;
  getInternalHooks?: any;
  [key: string]: any;
}

export interface AntdTableResult<TData extends Data, TParams extends Params>
  extends PaginationResult<TData, TParams> {
  tableProps: {
    dataSource: TData["list"];
    loading: boolean;
    onChange: (pagination: any, filters?: any, sorter?: any) => void;
    pagination: any;
    [key: string]: any;
  };
  search: {
    type: "simple" | "advance";
    changeType: () => void;
    submit: () => void;
    reset: () => void;
  };
}

export interface AntdTableOptions<TData extends Data, TParams extends Params>
  extends PaginationOptions<TData, TParams> {
  form?: AntdFormUtils;
  defaultType?: "simple" | "advance";
}
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
import { useEffect, useRef, useState } from "react";
import useMemoizedFn from "../useMemoizedFn";
import usePagination from "../usePagination";
import useUpdateEffect from "../useUpdateEffect";

import type {
  Antd4ValidateFields,
  AntdTableOptions,
  AntdTableResult,
  Data,
  Params,
  Service,
} from "./types";

const useAntdTable = <TData extends Data, TParams extends Params>(
  service: Service<TData, TParams>,
  options: AntdTableOptions<TData, TParams> = {}
) => {
  const {
    // form 实例
    form,
    // 默认表单类型
    defaultType = "simple",
    // 默认参数,第一项为分页数据,第二项为表单数据
    defaultParams,
    manual = false,
    refreshDeps = [],
    ready = true,
    ...rest
  } = options;

  // 分页
  const result = usePagination<TData, TParams>(service, {
    manual: true,
    ...rest,
    onSuccess(...args) {
      // eslint-disable-next-line @typescript-eslint/no-use-before-define
      runSuccessRef.current = true;
      rest.onSuccess?.(...args);
    },
  });

  const { params = [], run } = result;

  const cacheFormTableData = params[2] || ({} as any);

  const [type, setType] = useState(cacheFormTableData?.type || defaultType);

  const allFormDataRef = useRef<Record<string, any>>({});
  const defaultDataSourceRef = useRef([]);
  const runSuccessRef = useRef(false);

  // 判断是否为 antd v4
  const isAntdV4 = !!form?.getInternalHooks;

  // get current active field values
  // 获取表单值
  const getActiveFieldValues = () => {
    if (!form) {
      return {};
    }

    // antd v4
    if (isAntdV4) {
      return form.getFieldsValue(null, () => true);
    }

    // antd v3
    const allFieldsValue = form.getFieldsValue();
    const activeFieldsValue = {};
    Object.keys(allFieldsValue).forEach((key: string) => {
      if (form.getFieldInstance ? form.getFieldInstance(key) : true) {
        activeFieldsValue[key] = allFieldsValue[key];
      }
    });
    return activeFieldsValue;
  };

  // 校验表单
  const validateFields = (): Promise<Record<string, any>> => {
    if (!form) {
      return Promise.resolve({});
    }

    const activeFieldsValue = getActiveFieldValues();
    const fields = Object.keys(activeFieldsValue);

    // antd v4
    if (isAntdV4) {
      return (form.validateFields as Antd4ValidateFields)(fields);
    }

    // antd v3
    return new Promise((resolve, reject) => {
      form.validateFields(fields, (errors, values) => {
        if (errors) {
          reject(errors);
        } else {
          resolve(values);
        }
      });
    });
  };

  // 重置表单
  const restoreForm = () => {
    if (!form) {
      return;
    }

    // antd v4
    if (isAntdV4) {
      return form.setFieldsValue(allFormDataRef.current);
    }

    // antd v3
    const activeFieldsValue = {};
    Object.keys(allFormDataRef.current).forEach((key) => {
      if (form.getFieldInstance ? form.getFieldInstance(key) : true) {
        activeFieldsValue[key] = allFormDataRef.current[key];
      }
    });
    form.setFieldsValue(activeFieldsValue);
  };

  // 修改表单类型
  const changeType = () => {
    // 获取表单值
    const activeFieldsValue = getActiveFieldValues();
    // 修改表单值
    allFormDataRef.current = {
      ...allFormDataRef.current,
      ...activeFieldsValue,
    };
    setType((t) => (t === "simple" ? "advance" : "simple"));
  };

  // change search type, restore form data
  // 修改 type,重置 form 表单数据
  useUpdateEffect(() => {
    if (!ready) {
      return;
    }
    restoreForm();
  }, [type]);

  const _submit = (initPagination?: TParams[0]) => {
    if (!ready) {
      return;
    }

    setTimeout(() => {
      // 表单校验
      validateFields()
        .then((values = {}) => {
          // 分页逻辑
          const pagination = initPagination || {
            pageSize: options.defaultPageSize || 10,
            ...(params?.[0] || {}),
            current: 1,
          };
          // 如果没有 form,直接根据分页逻辑进行请求
          if (!form) {
            // @ts-ignore
            run(pagination);
            return;
          }

          // 获取到当前所有的 form Data
          // record all form data
          allFormDataRef.current = {
            ...allFormDataRef.current,
            ...values,
          };

          // @ts-ignore
          run(pagination, values, {
            allFormData: allFormDataRef.current,
            type,
          });
        })
        .catch((err) => err);
    });
  };

  // 重置表单
  const reset = () => {
    if (form) {
      form.resetFields();
    }

    _submit({
      ...(defaultParams?.[0] || {}),
      pageSize:
        options.defaultPageSize || options.defaultParams?.[0]?.pageSize || 10,
      current: 1,
    });
  };

  // 提交表单
  const submit = (e?: any) => {
    e?.preventDefault?.();
    _submit(
      runSuccessRef.current
        ? undefined
        : {
            pageSize:
              options.defaultPageSize ||
              options.defaultParams?.[0]?.pageSize ||
              10,
            current: 1,
            ...(defaultParams?.[0] || {}),
          }
    );
  };

  // 分页、排序、筛选变化时触发
  const onTableChange = (
    pagination: any,
    filters: any,
    sorter: any,
    extra: any
  ) => {
    const [oldPaginationParams, ...restParams] = params || [];
    run(
      // @ts-ignore
      {
        ...oldPaginationParams,
        current: pagination.current,
        pageSize: pagination.pageSize,
        filters,
        sorter,
        extra,
      },
      ...restParams
    );
  };

  // init
  useEffect(() => {
    // if has cache, use cached params. ignore manual and ready.
    if (params.length > 0) {
      allFormDataRef.current = cacheFormTableData?.allFormData || {};
      restoreForm();
      // @ts-ignore
      run(...params);
      return;
    }

    if (!manual && ready) {
      allFormDataRef.current = defaultParams?.[1] || {};
      restoreForm();
      _submit(defaultParams?.[0]);
    }
  }, []);

  // refresh & ready change on the same time
  const hasAutoRun = useRef(false);
  hasAutoRun.current = false;

  // ready 状态变化时的副作用
  useUpdateEffect(() => {
    if (!manual && ready) {
      hasAutoRun.current = true;
      if (form) {
        form.resetFields();
      }
      allFormDataRef.current = defaultParams?.[1] || {};
      restoreForm();
      _submit(defaultParams?.[0]);
    }
  }, [ready]);

  // 依赖项变化时的副作用
  useUpdateEffect(() => {
    if (hasAutoRun.current) {
      return;
    }

    if (!ready) {
      return;
    }

    if (!manual) {
      hasAutoRun.current = true;
      result.pagination.changeCurrent(1);
    }
  }, [...refreshDeps]);

  return {
    ...result,
    tableProps: {
      dataSource: result.data?.list || defaultDataSourceRef.current,
      loading: result.loading,
      onChange: useMemoizedFn(onTableChange),
      pagination: {
        current: result.pagination.current,
        pageSize: result.pagination.pageSize,
        total: result.pagination.total,
      },
    },
    search: {
      submit: useMemoizedFn(submit),
      type,
      changeType: useMemoizedFn(changeType),
      reset: useMemoizedFn(reset),
    },
  } as AntdTableResult<TData, TParams>;
};

export default useAntdTable;

useFusionTable

文档地址:https://ahooks.js.org/zh-CN/hooks/use-fusion-table

详细代码:https://github.com/alibaba/hooks/tree/master/packages/hooks/src/useFusionTable

useInfiniteScroll

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import type { DependencyList } from "react";
import type { BasicTarget } from "utils/domTarget";

export type Data = { list: any[]; [key: string]: any };

export type Service<TData extends Data> = (
  currentData?: TData
) => Promise<TData>;

export interface InfiniteScrollResult<TData extends Data> {
  Data: TData;
  loading: boolean;
  loadingMore: boolean;
  error?: Error;
  noMore: boolean;

  loadMore: () => void;
  loadMoreAsync: () => Promise<TData>;
  reload: () => void;
  reloadAsync: () => Promise<TData>;
  cancel: () => void;
  mutate: (data?: TData) => void;
}

export interface InfiniteScrollOptions<TData extends Data> {
  target?: BasicTarget<Element | Document>;
  isNoMore?: (data?: TData) => boolean;
  threshold?: number;

  manual?: boolean;
  reloadDeps?: DependencyList;

  onBefore?: () => void;
  onSuccess?: (data: TData) => void;
  onError?: (e: Error) => void;
  onFinally?: (data?: TData, e?: Error) => void;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
 * scrollTop: 表示一个元素的垂直滚动条滚动的距离
 * scrollHeight: 表示一个元素的内容的总高度,包括不可见部分
 * clientHeight: 表示一个元素在视窗中可见部分的高度
 */

const getScrollTop = (el: Document | Element) => {
  if (
    el === document ||
    el === document.documentElement ||
    el === document.body
  ) {
    return Math.max(
      window.pageYOffset,
      document.documentElement.scrollTop,
      document.body.scrollTop
    );
  }
  return (el as Element).scrollTop;
};

const getScrollHeight = (el: Document | Element) => {
  return (
    (el as Element).scrollHeight ||
    Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)
  );
};

const getClientHeight = (el: Document | Element) => {
  return (
    (el as Element).clientHeight ||
    Math.max(document.documentElement.clientHeight, document.body.clientHeight)
  );
};

export { getClientHeight, getScrollHeight, getScrollTop };
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
import { useMemo, useState } from "react";
import type { Data, InfiniteScrollOptions, Service } from "./types";
import useRequest from "../useRequest";
import useMemoizedFn from "../useMemoizedFn";
import useUpdateEffect from "../useUpdateEffect";
import useEventListener from "../useEventListener";
import { getTargetElement } from "utils/domTarget";
import { getClientHeight, getScrollHeight, getScrollTop } from "utils/rect";

const useInfiniteScroll = <TData extends Data>(
  service: Service<TData>,
  options: InfiniteScrollOptions<TData> = {}
) => {
  const {
    // 父级容器
    target,
    // 是否有最后一页的判断逻辑
    isNoMore,
    // 下拉自动加载,距离底部距离阈值
    threshold = 100,
    // 变化后,会自动触发 reload
    reloadDeps = [],
    manual,
    onBefore,
    onSuccess,
    onError,
    onFinally,
  } = options;

  // 聚合后的数据
  const [finalData, setFinalData] = useState<TData>();
  // 加载更多 loading
  const [loadingMore, setLoadingMore] = useState(false);

  const { loading, error, run, runAsync, cancel } = useRequest(
    // 入参,将上次请求返回的数据整合到新的参数中
    async (lastData?: TData) => {
      const currentData = await service(lastData);
      // 首次请求,直接设置
      if (!lastData) {
        setFinalData({
          ...currentData,
          list: [...(currentData.list ?? [])],
        });
      } else {
        setFinalData({
          ...currentData,
          list: [...(lastData.list ?? []), ...currentData.list],
        });
      }
      return currentData;
    },
    {
      manual,
      onFinally: (_, d, e) => {
        // 设置 loadingMore 为 false
        setLoadingMore(false);
        onFinally?.(d, e);
      },
      onBefore: () => onBefore?.(),
      onSuccess: (d) => {
        setTimeout(() => {
          // eslint-disable-next-line @typescript-eslint/no-use-before-define
          scrollMethod();
        });
        onSuccess?.(d);
      },
      onError: (e) => onError?.(e),
    }
  );

  // 是否没有更多数据了,配置 options.isNoMore 后生效
  const noMore = useMemo(() => {
    if (!isNoMore) return false;
    return isNoMore(finalData);
  }, [finalData]);

  // 同步加载更多
  const loadMore = useMemoizedFn(() => {
    if (noMore) return;
    setLoadingMore(true);
    run(finalData);
  });

  // 异步加载更多
  const loadMoreAsync = useMemoizedFn(() => {
    if (noMore) return Promise.reject();
    setLoadingMore(true);
    return runAsync(finalData);
  });

  // 同步加载第一页数据
  const reload = () => {
    setLoadingMore(false);
    return run();
  };

  // 异步加载第一页数据
  const reloadAsync = () => {
    setLoadingMore(false);
    return runAsync();
  };

  // 监听 reloadDeps,变化后,自动触发 reload
  useUpdateEffect(() => {
    run();
  }, [...reloadDeps]);

  // 滚动
  const scrollMethod = () => {
    let el = getTargetElement(target);
    if (!el) {
      return;
    }

    el = el === document ? document.documentElement : el;

    const scrollTop = getScrollTop(el);
    const scrollHeight = getScrollHeight(el);
    const clientHeight = getClientHeight(el);

    // 判断滚动条是否到达底部或即将到达底部
    if (scrollHeight - scrollTop <= clientHeight + threshold) {
      // 加载更多
      loadMore();
    }
  };

  // 监听滚动事件
  useEventListener(
    "scroll",
    () => {
      if (loading || loadingMore) {
        return;
      }
      scrollMethod();
    },
    { target }
  );

  return {
    data: finalData,
    loaading: !loadMore && loading,
    loadingMore,
    error,
    noMore,

    loadMore,
    loadMoreAsync,
    reload: useMemoizedFn(reload),
    reloadAsync: useMemoizedFn(reloadAsync),
    mutate: setFinalData,
    cancel,
  };
};

export default useInfiniteScroll;

usePagination

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import type { Result, Options } from "../useRequest/src/types";

export type Data = { total: number; list: any[] };

export type Params = [
  { current: number; pageSize: number; [key: string]: any },
  ...any[]
];

export type Service<TData extends Data, TParams extends Params> = (
  ...args: TParams
) => Promise<TData>;

export interface PaginationResult<TData extends Data, TParams extends Params>
  extends Result<TData, TParams> {
  pagination: {
    current: number;
    pageSize: number;
    total: number;
    totalPage: number;
    onChange: (current: number, pageSize: number) => void;
    changeCurrent: (current: number) => void;
    changePageSize: (pageSize: number) => void;
  };
}

export interface PaginationOptions<TData extends Data, TParams extends Params>
  extends Options<TData, TParams> {
  defaultPageSize?: number;
  defaultCurrent?: number;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import { useMemo } from "react";
import useMemoizedFn from "@/hooks/useMemoizedFn";
import useRequest from "@/hooks/useRequest";

import type {
  Data,
  PaginationOptions,
  PaginationResult,
  Params,
  Service,
} from "./types";

/**
 * 基于 useRequest,封装了常见的分页逻辑
 * */
const usePagination = <TData extends Data, TParams extends Params>(
  service: Service<TData, TParams>,
  options: PaginationOptions<TData, TParams> = {}
) => {
  const { defaultPageSize = 10, defaultCurrent = 1, ...rest } = options;

  // // service 约定返回的数据结构为 { total: number, list: Item[] }
  const result = useRequest(service, {
    // service 的默认参数为 { current: number, pageSize: number }
    defaultParams: [{ current: defaultCurrent, pageSize: defaultPageSize }],
    // refreshDeps 变化,会重置 current 到第一页,并重新发起请求
    refreshDepsAction: () => {
      // eslint-disable-next-line @typescript-eslint/no-use-before-define
      changeCurrent(1);
    },
    ...rest,
  });

  const { current = 1, pageSize = defaultPageSize } = result.params[0] || {};

  // 计算总条数
  const total = result.data?.total || 0;
  // 计算总页数
  const totalPage = useMemo(
    () => Math.ceil(total / pageSize),
    [pageSize, total]
  );

  /**
   * c: current
   * p: pageSize
   * */
  const onChange = (c: number, p: number) => {
    let toCurrent = c < 0 ? 1 : c;
    const toPageSize = p <= 0 ? 1 : p;
    // 根据 total 和传入的 pageSize 计算当前总页数
    const tempTotalPage = Math.ceil(total / toPageSize);
    // 如果当前总页数小于当前要跳转的 current,需要将 current 赋值为当前总页数
    if (toCurrent > tempTotalPage) {
      toCurrent = Math.max(1, tempTotalPage);
    }

    const [oldPaginationParams = {}, ...restParams] = result.params || [];

    // 重新执行请求
    result.run(
      {
        ...oldPaginationParams,
        current: toCurrent,
        pageSize: toPageSize,
      },
      ...restParams
    );
  };

  const changeCurrent = (c: number) => {
    onChange(c, pageSize);
  };

  const changePageSize = (p: number) => {
    onChange(current, p);
  };

  // 额外返回 pagination 字段,包含所有分页信息,及操作分页的函数。
  return {
    ...result,
    pagination: {
      current,
      pageSize,
      total,
      totalPage,
      onChange: useMemoizedFn(onChange),
      changeCurrent: useMemoizedFn(changeCurrent),
      changePageSize: useMemoizedFn(changePageSize),
    },
  } as PaginationResult<TData, TParams>;
};

export default usePagination;

useDynamicList

文档地址

详细代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
/**
 * Array.prototype.splice()
 * https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/splice
 * const months = ['Jan', 'March', 'April', 'June'];
 * months.splice(1, 0, 'Feb'); // 从索引 1 位置开始,不删除元素,插入 ’Feb‘
 * // Inserts at index 1
 * console.log(months);
 * // Expected output: Array ["Jan", "Feb", "March", "April", "June"]

 * months.splice(4, 1, 'May'); // 从索引 4 位置开始,删除一个元素,并插入 'May'
 * // Replaces 1 element at index 4
 * console.log(months);
 * // Expected output: Array ["Jan", "Feb", "March", "April", "May"]
 */

import { useCallback, useRef, useState } from "react";

const useDynamicList = <T>(initialList: T[] = []) => {
  // 计数
  const counterRef = useRef(-1);

  // uuid list
  const keyList = useRef<number[]>([]);

  // 根据 index 为某个元素设置 uuid
  const setKey = useCallback((index: number) => {
    counterRef.current += 1;
    keyList.current.splice(index, 0, counterRef.current);
  }, []);

  // 当前列表
  const [list, setList] = useState(() => {
    initialList.forEach((_, index) => {
      setKey(index);
    });
    return initialList;
  });

  // 重新设置 list 的值
  const resetList = useCallback((newList: T[]) => {
    keyList.current = [];
    setList(() => {
      newList.forEach((_, index) => {
        setKey(index);
      });
      return newList;
    });
  }, []);

  // 在指定位置插入元素
  const insert = useCallback((index: number, item: T) => {
    setList((l) => {
      const temp = [...l];
      temp.splice(index, 0, item);
      setKey(index);
      return temp;
    });
  }, []);

  // 获得某个元素的 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];
      // 根据 oldIndex 把元素过滤掉
      const temp = newList.filter((_, index: number) => index !== oldIndex);
      // 根据 newIndex 把过滤掉的元素插入进去
      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
    []
  );

  return {
    list,
    insert,
    merge,
    replace,
    remove,
    getKey,
    getIndex,
    move,
    push,
    pop,
    unshift,
    shift,
    sortList,
    resetList,
  };
};

export default useDynamicList;

useVirtualList

文档地址

详细代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
import { type BasicTarget, getTargetElement } from "@/utils/domTarget";
import useMemoizedFn from "../useMemoizedFn";
import useUpdateEffect from "../useUpdateEffect";
import useEventListener from "../useEventListener";
import {
  type CSSProperties,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { isNumber } from "@/utils";
import useLatest from "../useLatest";
import useSize from "../useSize";

type ItemHeight<T> = (index: number, data: T) => number;

export interface Options<T> {
  containerTarget: BasicTarget;
  wrapperTarget: BasicTarget;
  itemHeight: number | ItemHeight<T>;
  overscan?: number;
}

const useVirtualList = <T = any,>(list: T[], options: Options<T>) => {
  const { containerTarget, wrapperTarget, itemHeight, overscan = 5 } = options;

  const itemHeightRef = useLatest(itemHeight);

  // 外部容器尺寸
  const size = useSize(containerTarget);

  // 标记滚动是否由滚动函数触发
  const scrollTriggerByScrollToFunc = useRef(false);

  // 当前需要展示的列表内容
  const [targetList, setTargetList] = useState<{ index: number; data: T }[]>(
    []
  );

  // 内部容器样式
  const [wrapperStyle, setWrapperStyle] = useState<CSSProperties>({});

  // 根据滚动位置计算偏移量
  const getOffset = (scrollTop: number) => {
    if (isNumber(itemHeightRef.current)) {
      return Math.floor(scrollTop / itemHeightRef.current) + 1;
    }
    let sum = 0;
    let offset = 0;
    for (let i = 0; i < list.length; i++) {
      const height = itemHeightRef.current(i, list[i]);
      sum += height;
      if (sum >= scrollTop) {
        offset = i;
        break;
      }
    }
    return offset + 1;
  };

  // 根据容器高度和起始索引计算可见的列表项数量
  const getVisibleCount = (containerHeight: number, fromIndex: number) => {
    if (isNumber(itemHeightRef.current)) {
      return Math.ceil(containerHeight / itemHeightRef.current);
    }
    let sum = 0;
    let endIndex = 0;
    for (let i = fromIndex; i < list.length; i++) {
      const height = itemHeightRef.current(i, list[i]);
      sum += height;
      endIndex = i;
      if (sum >= containerHeight) {
        break;
      }
    }
    return endIndex - fromIndex;
  };

  // 根据索引计算顶部的高度,前面所有列表项的高度总和
  const getDistanceTop = (index: number) => {
    if (isNumber(itemHeightRef.current)) {
      const height = index * itemHeightRef.current;
      return height;
    }
    const height = list
      .slice(0, index)
      .reduce(
        (sum, _, i) =>
          sum + (itemHeightRef.current as ItemHeight<T>)(i, list[i]),
        0
      );
    return height;
  };

  // 内部容器的高度
  const totalHeight = useMemo(() => {
    if (isNumber(itemHeightRef.current)) {
      return list.length * itemHeightRef.current;
    }
    return list.reduce(
      (sum, _, index) =>
        sum + (itemHeightRef.current as ItemHeight<T>)(index, list[index]),
      0
    );
  }, [list]);

  // 计算可见范围内的列表项,并设置内部容器的高度和样式
  const calculateRange = () => {
    const container = getTargetElement(containerTarget);

    if (container) {
      const { scrollTop, clientHeight } = container;

      // 根据 scrollTop 计算已经 "滚过" 多少项
      const offset = getOffset(scrollTop);
      // 根据外部容器可视高度和当前的开始索引,计算外部容器能承载的项数
      const visibleCount = getVisibleCount(clientHeight, offset);

      // 根据 overscan (视区上、下额外展示的 DOM 节点数量) 计算开始索引和结束索引
      const start = Math.max(0, offset - overscan);
      const end = Math.min(list.length, offset + visibleCount + overscan);

      // 根据开始索引计算其距离最开始的距离
      const offfsetTop = getDistanceTop(start);

      // 设置内部容器的 height 和 marginTop
      setWrapperStyle({
        height: totalHeight - offfsetTop + "px",
        marginTop: offfsetTop + "px",
      });

      setTargetList(
        list.slice(start, end).map((ele, index) => ({
          data: ele,
          index: index + start,
        }))
      );
    }
  };

  // 监听容器尺寸、原列表项变化,变化时重新计算
  useEffect(() => {
    if (!size?.width || !size?.height) {
      return;
    }
    calculateRange();
  }, [size?.width, size?.height, list]);

  // 监听外部容器 scroll 事件
  useEventListener(
    "scroll",
    (e) => {
      // 如果滚动是由滚动函数触发,则不需要重新计算
      if (scrollTriggerByScrollToFunc.current) {
        scrollTriggerByScrollToFunc.current = false;
        return;
      }
      e.preventDefault();
      calculateRange();
    },
    {
      target: containerTarget,
    }
  );

  // 将 wrapperStyle 应用到内部容器
  useUpdateEffect(() => {
    const wrapper = getTargetElement(wrapperTarget) as HTMLElement;
    if (wrapper) {
      Object.keys(wrapperStyle).forEach(
        (key) => (wrapper.style[key] = wrapperStyle[key])
      );
    }
  }, [wrapperStyle]);

  // 快速滚动到指定 index
  const scrollTo = (index: number) => {
    const container = getTargetElement(containerTarget);
    if (container) {
      scrollTriggerByScrollToFunc.current = true;
      container.scrollTop = getDistanceTop(index);
      calculateRange();
    }
  };

  return [targetList, useMemoizedFn(scrollTo)] as const;
};

export default useVirtualList;

useHistoryTravel

文档地址

详细代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
import { isNumber } from "@/utils";
import useMemoizedFn from "../useMemoizedFn";
import { useRef, useState } from "react";

/**
 * past - 过去的状态队列
 * future - 未来的状态队列
 * present - 当前状态
 * */
interface IData<T> {
  present?: T;
  past: T[];
  future: T[];
}

// 获取 current 值的下标
const dumpIndex = <T,>(step: number, arr: T[]) => {
  // step 大于 0 表示前进,小于 0 表示后退
  let index = step > 0 ? step - 1 : arr.length + step;
  if (index >= arr.length - 1) {
    index = arr.length - 1;
  }
  if (index < 0) {
    index = 0;
  }
  return index;
};

/**
 * 将传入的 targetArr,根据 step,分成当前状态、过去的状态队列、未来的状态队列
 * 比如前进,出参为 2 和 [1,2,3,4],得到的结果是 { _current: 2, _before: [1], _after: [3,4] }
 * 比如后退,出参为 -1,[1,2,3,4],得到的结果是 { _current: 4, _before: [1, 2, 3], _after: [] }
 * */
const split = <T,>(step: number, targetArr: T[]) => {
  const index = dumpIndex(step, targetArr);
  return {
    _current: targetArr[index],
    _before: targetArr.slice(0, index),
    _after: targetArr.slice(index + 1),
  };
};

const useHistoryTravel = <T,>(initialValue?: T, maxLength: number = 0) => {
  const [history, setHistory] = useState<IData<T | undefined>>({
    present: initialValue,
    past: [],
    future: [],
  });

  const { present, past, future } = history;

  const initialValueRef = useRef(initialValue);

  /**
   * 重置
   * 重置到初始值,或提供一个新的初始值
   * */
  const reset = (...params: any[]) => {
    const _initial = params.length > 0 ? params[0] : initialValueRef.current;
    initialValueRef.current = _initial;

    setHistory({
      present: _initial,
      past: [],
      future: [],
    });
  };

  /**
   * 更新
   * */
  const updateValue = (val: T) => {
    // 以前的旧状态队列和 present 合并到新的旧状态队列
    const _past = [...past, present];
    // 判断 maxLength,超过最大长度后将删除旧状态队列中的第一个记录
    const maxLengthNum = isNumber(maxLength) ? maxLength : Number(maxLength);
    if (maxLengthNum > 0 && _past.length > maxLengthNum) {
      _past.splice(0, 1);
    }
    setHistory({
      present: val,
      past: _past,
      future: [], // 新状态队列置为空
    });
  };

  /**
   * 前进,默认前进一步
   * */
  const _forward = (step: number = 1) => {
    if (future.length === 0) {
      return;
    }

    const { _before, _current, _after } = split(step, future);
    setHistory({
      // 以前的旧状态队列、present、_before 合并到新的旧状态队列
      past: [...past, present, ..._before],
      present: _current,
      future: _after,
    });
  };

  /**
   * 后退,默认后退一步
   * */
  const _backward = (step: number = -1) => {
    if (past.length === 0) {
      return;
    }

    const { _before, _current, _after } = split(step, past);
    setHistory({
      past: _before,
      present: _current,
      // 以前的新状态队列、present、_after 合并到新的新状态队列
      future: [..._after, present, ...future],
    });
  };

  // 跳到第几步,最终调用 _forward 和 _backward
  const go = (step: number) => {
    const stepNum = isNumber(step) ? step : Number(step);
    if (stepNum === 0) {
      return;
    }
    if (stepNum > 0) {
      return _forward(stepNum);
    }
    _backward(stepNum);
  };

  return {
    value: present,
    backLength: past.length,
    forwardLength: future.length,
    setValue: useMemoizedFn(updateValue),
    go: useMemoizedFn(go),
    back: useMemoizedFn(() => {
      go(-1);
    }),
    forward: useMemoizedFn(() => {
      go(1);
    }),
    reset: useMemoizedFn(reset),
  };
};

export default useHistoryTravel;

useNetwork

文档地址

详细代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
import { isObject } from "@/utils";
import { useEffect, useState } from "react";

/**
 * since: online 最后改变时间
 * online: 网络是否为在线
 * rtt: 当前连接下评估的往返时延
 * type: 设备使用与所述网络进行通信的连接的类型 bluetooth | cellular | ethernet | none | wifi | wimax | other | unknown
 * downlink: 有效带宽估算(单位:兆比特/秒)
 * downlinkMax: 最大下行速度(单位:兆比特/秒)
 * saveData: 用户代理是否设置了减少数据使用的选项
 * effectiveType: 网络连接的类型 slow-2g | 2g | 3g | 4g
 * */
export interface NetworkState {
  since?: Date;
  online?: boolean;
  rtt?: number;
  type?: string;
  downlink?: number;
  saveData?: boolean;
  downlinkMax?: number;
  effectiveType?: string;
}

enum NetworkEventType {
  ONLINE = "online",
  OFFLINE = "offline",
  CHANGE = "change",
}

function getConnection() {
  const nav = navigator as any;
  if (!isObject(nav)) return null;
  return nav.connection || nav.mozConnection || nav.webkitConnection;
}

function getConnectionProperty(): NetworkState {
  const c = getConnection();
  if (!c) return {};
  return {
    rtt: c.rtt,
    type: c.type,
    saveData: c.saveData,
    downlink: c.downlink,
    downlinkMax: c.downlinkMax,
    effectiveType: c.effectiveType,
  };
}

const useNetwork = (): NetworkState => {
  const [state, setState] = useState(() => {
    return {
      since: undefined,
      online: navigator?.onLine,
      ...getConnectionProperty(),
    };
  });

  useEffect(() => {
    const onOnline = () => {
      setState((prevState) => ({
        ...prevState,
        online: true,
        since: new Date(),
      }));
    };

    const onOffline = () => {
      setState((prevState) => ({
        ...prevState,
        online: false,
        since: new Date(),
      }));
    };

    const onConnectionChange = () => {
      setState((prevState) => ({
        ...prevState,
        ...getConnectionProperty(),
      }));
    };

    // 监听网络 online 事件
    window.addEventListener(NetworkEventType.ONLINE, onOnline);
    // 监听网络 offline 事件
    window.addEventListener(NetworkEventType.OFFLINE, onOffline);

    // 监听 nav.connection || nav.mozConnection || nav.webkitConnection 的 change 事件
    const connection = getConnection();
    connection?.addEventListener(NetworkEventType.CHANGE, onConnectionChange);

    return () => {
      window.removeEventListener(NetworkEventType.ONLINE, onOnline);
      window.removeEventListener(NetworkEventType.OFFLINE, onOffline);
      connection?.removeEventListener(
        NetworkEventType.CHANGE,
        onConnectionChange
      );
    };
  }, []);

  return state;
};

export default useNetwork;

useSelections

文档地址

详细代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
import { useMemo, useState } from "react";
import type { Key } from "react";
import useMemoizedFn from "../useMemoizedFn";
import { isPlainObject } from "lodash";
import { isFunction, isString } from "@/utils";

export interface Options<T> {
  defaultSelected?: T[];
  itemKey?: string | ((item: T) => Key);
}

const useSelections = <T,>(items: T[], options?: T[] | Options<T>) => {
  let defaultSelected: T[] = [];
  let itemKey: Options<T>["itemKey"];

  if (Array.isArray(options)) {
    defaultSelected = options;
  } else if (isPlainObject(options)) {
    defaultSelected = options?.defaultSelected ?? defaultSelected;
    itemKey = options?.itemKey ?? itemKey;
  }

  const getKey = (item: T): Key => {
    if (isFunction(itemKey)) {
      return itemKey(item);
    }
    if (isString(itemKey) && isPlainObject(item)) {
      return item[itemKey];
    }

    return item as Key;
  };

  const [selected, setSelected] = useState<T[]>(defaultSelected);

  const selectedMap = useMemo(() => {
    const keyToItemMap = new Map();

    if (!Array.isArray(selected)) {
      return keyToItemMap;
    }

    selected.forEach((item) => {
      keyToItemMap.set(getKey(item), item);
    });

    return keyToItemMap;
  }, [selected]);

  // 是否被选择
  const isSelected = (item: T) => selectedMap.has(getKey(item));

  // 选择单个元素
  const select = (item: T) => {
    selectedMap.set(getKey(item), item);
    setSelected(Array.from(selectedMap.values()));
  };

  // 取消选择单个元素
  const unSelect = (item: T) => {
    selectedMap.delete(getKey(item));
    setSelected(Array.from(selectedMap.values()));
  };

  // 反选单个元素
  const toggle = (item: T) => {
    if (isSelected(item)) {
      unSelect(item);
    } else {
      select(item);
    }
  };

  // 选择全部元素
  const selectAll = () => {
    items.forEach((item) => {
      selectedMap.set(getKey(item), item);
    });
    setSelected(Array.from(selectedMap.values()));
  };

  // 取消选择全部元素
  const unSelectAll = () => {
    items.forEach((item) => {
      selectedMap.delete(getKey(item));
    });
    setSelected(Array.from(selectedMap.values()));
  };

  // 是否一个都没有选择
  const noneSelected = useMemo(
    () => items.every((item) => !selectedMap.has(getKey(item))),
    [items, selectedMap]
  );

  // 是否全选
  const allSelected = useMemo(
    () => items.every((item) => selectedMap.has(getKey(item))) && !noneSelected,
    [items, selectedMap, noneSelected]
  );

  // 是否半选
  const partiallySelected = useMemo(
    () => !noneSelected && !allSelected,
    [noneSelected, allSelected]
  );

  // 反选全部元素
  const toggleAll = () => (allSelected ? unSelectAll() : selectAll());

  // 清除所有选中元素
  const clearAll = () => {
    selectedMap.clear();
    setSelected([]);
  };

  return {
    selected,
    noneSelected,
    allSelected,
    partiallySelected,
    setSelected,
    isSelected,
    select: useMemoizedFn(select),
    unSelect: useMemoizedFn(unSelect),
    toggle: useMemoizedFn(toggle),
    selectAll: useMemoizedFn(selectAll),
    unSelectAll: useMemoizedFn(unSelectAll),
    clearAll: useMemoizedFn(clearAll),
    toggleAll: useMemoizedFn(toggleAll),
  } as const;
};

export default useSelections;

useCountDown

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
import dayjs from "dayjs";
import { useEffect, useMemo, useState } from "react";
import { isNumber } from "../../../utils";
import useLatest from "@/hooks/useLatest";

export type TDate = dayjs.ConfigType;

export interface Options {
  leftTime?: number;
  targetDate?: TDate;
  interval?: number;
  onEnd?: () => void;
}

export interface FormattedRes {
  days: number;
  hours: number;
  minutes: number;
  seconds: number;
  milliseconds: number;
}

// 计算目标时间和当前时间还差多少毫秒
const calcLeft = (target?: TDate) => {
  if (!target) {
    return 0;
  }
  // https://stackoverflow.com/questions/4310953/invalid-date-in-safari
  // 计算剩余时间,目标时间 - 当前时间
  const left = dayjs(target).valueOf() - Date.now();
  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,
  };
};

const useCountDown = (options: Options = {}) => {
  const { leftTime, targetDate, interval = 1000, onEnd } = options || {};

  const target = useMemo<TDate>(() => {
    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));

    // 通过定时器 setInterval 设置倒计时
    const timer = setInterval(() => {
      const targetLeft = calcLeft(target);
      setTimeLeft(targetLeft);
      // 剩余时间为 0,取消定时器,并执行 onEnd 回调
      if (targetLeft === 0) {
        clearInterval(timer);
        onEndRef.current?.();
      }
    }, interval);

    return () => clearInterval(timer);
  }, [target, interval]);

  const formattedRes = useMemo(() => parseMs(timeLeft), [timeLeft]);

  return [timeLeft, formattedRes] as const;
};

export default useCountDown;

useCounter

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
import { isNumber } from "@/utils";
import useMemoizedFn from "../useMemoizedFn";
import { useState } from "react";

export interface Options {
  min?: number;
  max?: number;
}

export interface Actions {
  inc: (delta?: number) => void;
  dec: (delta?: number) => void;
  set: (value: number | ((c: number) => number)) => void;
  reset: () => void;
}

export type ValueParam = number | ((c: number) => number);

/**
 * 获取目标数值
 * 必须大于等于 min
 * 小于等于 max
 * */
function getTargetValue(val: number, options: Options = {}) {
  const { min, max } = options;
  let target = val;
  if (isNumber(max)) {
    target = Math.min(max, target);
  }
  if (isNumber(min)) {
    target = Math.max(min, target);
  }
  return target;
}

const useCounter = (initialValue: number = 0, options: Options = {}) => {
  const { min, max } = options;

  const [current, setCurrent] = useState(() => {
    return getTargetValue(initialValue, {
      min,
      max,
    });
  });

  // 设置值,参数可以为 number 或 函数
  const setValue = (value: ValueParam) => {
    setCurrent((c) => {
      const target = isNumber(value) ? value : value(c);
      return getTargetValue(target, {
        max,
        min,
      });
    });
  };

  // 加,默认加 1
  const inc = (delta: number = 1) => {
    setValue((c) => c + delta);
  };

  // 减,默认减 1
  const dec = (delta: number = 1) => {
    setValue((c) => c - delta);
  };

  // 设置 current
  const set = (value: ValueParam) => {
    setValue(value);
  };

  // 重置为默认值
  const reset = () => {
    setValue(initialValue);
  };

  return [
    current,
    {
      inc: useMemoizedFn(inc),
      dec: useMemoizedFn(dec),
      set: useMemoizedFn(set),
      reset: useMemoizedFn(reset),
    },
  ] as const;
};

export default useCounter;

useTextSelection

文档地址

详细代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
import { getTargetElement, type BasicTarget } from "@/utils/domTarget";
import useEffectWithTarget from "@/utils/useEffectWithTarget";
import { useRef, useState } from "react";

interface Rect {
  top: number;
  left: number;
  bottom: number;
  right: number;
  height: number;
  width: number;
}

export interface State extends Rect {
  text: string;
}

const initRect: Rect = {
  top: NaN,
  left: NaN,
  bottom: NaN,
  right: NaN,
  height: NaN,
  width: NaN,
};

const initState: State = {
  text: "",
  ...initRect,
};

function getRectFromSelection(selection: Selection | null): Rect {
  if (!selection) {
    return initRect;
  }

  // rangeCount readonly 返回该选区所包含的连续范围的数量
  if (selection.rangeCount < 1) {
    return initRect;
  }

  // getRangeAt() 方法返回一个包含当前选区内容的区域对象
  // index 指定需要被处理的子级编号(从 0 开始),如果该数值被错误的赋予了大于或等于 rangeCount 结果的数字,将会产生错误
  const range = selection.getRangeAt(0);

  // range.getBoundingClientRect() 返回一个 DOMRect 对象,其绑定了 Range 的整个内容
  const { height, width, top, left, right, bottom } =
    range.getBoundingClientRect();
  return {
    height,
    width,
    top,
    left,
    right,
    bottom,
  };
}

const 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;
        selObj = window.getSelection();
        // toString() 方法返回当前选区的纯文本内容
        text = selObj ? selObj.toString() : "";

        if (text && isInRangeRef.current) {
          rect = getRectFromSelection(selObj);
          setState({ ...state, text, ...rect });
        }
      };

      // 鼠标按下时触发回调,重置状态、清除选区
      const mousedownHandler = (e) => {
        // 如果按下的是右键,则立即返回,这样选中的数据就不会被清空
        if (e.button === 2) return;

        if (!window.getSelection) return;

        // 重置状态
        if (stateRef.current.text) {
          setState({ ...initState });
        }
        isInRangeRef.current = false;

        // 清除选区
        // https://developer.mozilla.org/zh-CN/docs/Web/API/Window/getSelection
        // 返回一个 Selection 对象,表示用户选择的文本范围或光标的当前位置
        const selObj = window.getSelection();
        if (!selObj) return;
        // https://developer.mozilla.org/zh-CN/docs/Web/API/Selection/removeAllRanges
        // Selection.removeAllRanges() 方法会将所有的区域都从选取中移除,只留下 anchorNode 和focusNode 属性并将其设置为 null
        // anchorNode readonly 返回该选区起点所在的节点
        // focusNode readonly 返回该选区终点所在的节点
        selObj.removeAllRanges();

        // 检查元素 el 是否包含鼠标事件的目标元素
        isInRangeRef.current = el.contains(e.target);
      };

      // 监听 mouseup 和 mousedown
      el.addEventListener("mouseup", mouseupHandler);
      document.addEventListener("mousedown", mousedownHandler);

      return () => {
        el.removeEventListener("mouseup", mouseupHandler);
        document.removeEventListener("mousedown", mousedownHandler);
      };
    },
    [],
    target
  );

  return state;
};

export default useTextSelection;

useWebSocket

文档地址

详细代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
iimport { useEffect, useRef, useState } from "react";
import useLatest from "../useLatest";
import useUnmount from "../useUnmount";
import useMemoizedFn from "../useMemoizedFn";

/**
 * Connecting: 正在连接中
 * Open: 已经连接并可以通讯
 * Closing: 连接正在关闭
 * Closed: 连接已关闭或没有连接成功
 * */
export enum ReadyState {
  Connecting = 0,
  Open = 1,
  Closing = 2,
  Closed = 3,
}

/**
 * reconnectLimit: 重试次数
 * reconnectInterval: 重试时间间隔(ms)
 * manual: 手动启动连接
 * onOpen: 连接成功回调
 * onClose: 关闭回调
 * onMessage: 收到消息回调
 * onError: 错误回调
 * protocols: 子协议
 * */
export interface Options {
  reconnectLimit?: number;
  reconnectInterval?: number;
  manual?: boolean;
  onOpen?: (event: WebSocketEventMap["open"], instance: WebSocket) => void;
  onClose?: (event: WebSocketEventMap["close"], instance: WebSocket) => void;
  onMessage?: (
    message: WebSocketEventMap["message"],
    instance: WebSocket
  ) => void;
  onError?: (event: WebSocketEventMap["error"], instance: WebSocket) => void;
  protocols?: string | string[];
}

/**
 * latestMessage: 最新消息
 * sendMessage: 发送消息函数
 * disconnect: 手动断开 webSocket 连接
 * connect: 手动连接 webSocket,如果当前已有连接,则关闭后重新连接
 * readyState: 当前 webSocket 连接状态
 * webSocketIns: webSocket 实例
 * */
export interface Result {
  latestMessage?: WebSocketEventMap["message"];
  sendMessage: WebSocket["send"];
  disconnect: () => void;
  connect: () => void;
  readyState: ReadyState;
  webSocketIns?: WebSocket;
}

const useWebSocket = (socketUrl: string, options: Options = {}): Result => {
  const {
    reconnectLimit = 3,
    reconnectInterval = 3 * 1000,
    manual = false,
    onOpen,
    onClose,
    onMessage,
    onError,
    protocols,
  } = options;

  const onOpenRef = useLatest(onOpen);
  const onCloseRef = useLatest(onClose);
  const onMessageRef = useLatest(onMessage);
  const onErrorRef = useLatest(onError);

  const reconnectTimesRef = useRef(0);
  const reconnectTimerRef = useRef<ReturnType<typeof setTimeout>>();
  const websocketRef = useRef<WebSocket>();

  const [latestMessage, setLatestMessage] =
    useState<WebSocketEventMap["message"]>();
  const [readyState, setReadyState] = useState<ReadyState>(ReadyState.Closed);

  // 重试
  const reconnect = () => {
    // 没有超过重试次数并且当前 webSocket 实例状态不是 Open
    if (
      reconnectTimesRef.current < reconnectLimit &&
      websocketRef.current?.readyState !== ReadyState.Open
    ) {
      // 如果已经存在重试逻辑,则清除掉计定时器
      if (reconnectTimerRef.current) {
        clearTimeout(reconnectTimerRef.current);
      }

      // 重连
      reconnectTimerRef.current = setTimeout(() => {
        connectWs();
        reconnectTimesRef.current++;
      }, reconnectInterval);
    }
  };

  // 创建连接
  const connectWs = () => {
    // 如果已经存在重试逻辑,则清除掉计定时器
    if (reconnectTimerRef.current) {
      clearTimeout(reconnectTimerRef.current);
    }

    // 如果当前已有连接,则关闭掉
    if (websocketRef.current) {
      websocketRef.current?.close();
    }

    // 创建 webSocket
    const ws = new WebSocket(socketUrl, protocols);
    setReadyState(ReadyState.Connecting);

    // webSocket 错误回调
    ws.onerror = (event) => {
      if (websocketRef.current !== ws) {
        return;
      }
      // 重试
      reconnect();
      // 执行错误回调
      onErrorRef.current?.(event, ws);
      // 修改连接状态
      setReadyState(ws.readyState || ReadyState.Closed);
    };

    // webSocket 连接成功回调
    ws.onopen = (event) => {
      if (websocketRef.current !== ws) {
        return;
      }
      // 执行连接成功回调
      onOpenRef.current?.(event, ws);
      // 重置重试次数
      reconnectTimesRef.current = 0;
      // 修改连接状态
      setReadyState(ws.readyState || ReadyState.Open);
    };

    // webSocket 收到消息回调
    ws.onmessage = (message: WebSocketEventMap["message"]) => {
      if (websocketRef.current !== ws) {
        return;
      }
      // 执行收到消息回调
      onMessageRef.current?.(message, ws);
      // 更新最新消息状态
      setLatestMessage(message);
    };

    // webSocket 连接关闭回调
    ws.onclose = (event) => {
      onCloseRef.current?.(event, ws);
      // closed by server
      if (websocketRef.current === ws) {
        reconnect();
      }
      // closed by disconnect or closed by server
      if (!websocketRef.current || websocketRef.current === ws) {
        setReadyState(ws.readyState || ReadyState.Closed);
      }
    };

    // 保存 webSocket 实例
    websocketRef.current = ws;
  };

  // 发送消息函数
  const sendMessage: WebSocket["send"] = (message) => {
    if (readyState === ReadyState.Open) {
      websocketRef.current?.send(message);
    } else {
      throw new Error("Websocket disconnected");
    }
  };

  // 连接 webSocket,如果当前已有连接,则关闭后重新连接
  const connect = () => {
    reconnectTimesRef.current = 0;
    connectWs();
  };

  // 手动断开 webSocket 连接
  const disconnect = () => {
    if (reconnectTimerRef.current) {
      clearTimeout(reconnectTimerRef.current);
    }

    reconnectTimesRef.current = reconnectLimit;
    websocketRef.current?.close();
    websocketRef.current = undefined;
  };

  useEffect(() => {
    // 是否手动启动连接
    if (!manual && socketUrl) {
      connect();
    }
  }, [socketUrl, manual]);

  // 组件销毁
  useUnmount(() => {
    disconnect();
  });

  return {
    latestMessage,
    sendMessage: useMemoizedFn(sendMessage),
    connect: useMemoizedFn(connect),
    disconnect: useMemoizedFn(disconnect),
    readyState,
    webSocketIns: websocketRef.current,
  };
};

export default useWebSocket;