likes
comments
collection
share

React Hook 封装常见业务场景,有哪些你用得到?

作者站长头像
站长
· 阅读数 17

我正在参加「掘金·启航计划」

本文是全网最全 ahooks 源码分析篇之一,该系列已整理成文档-地址。觉得还不错,给个 star 支持一下哈,Thanks。

本系列主要是针对 ahooks 的 v3.3.13 版本进行源码解读。个人 folk 仓库可见 详情

本文来看下 ahooks 针对常见的业务场景做了哪些封装。通过对这些 hook 的分析和思考,希望能够给大家在日常工作中遇到的场景有所帮助,也欢迎给 ahooks 提 RFC。

React Hook 封装常见业务场景,有哪些你用得到?

useAntdTable

useAntdTable 基于 useRequest 实现,封装了常用的 Ant Design Form 与 Ant Design Table 联动逻辑,并且同时支持 antd v3 和 v4。

首先调用 usePagination 处理分页的逻辑。

const useAntdTable = <TData extends Data, TParams extends Params>(
  service: Service<TData, TParams>,
  options: AntdTableOptions<TData, TParams> = {},
) => {
  const {
    // form 实例
    form,
    // 默认表单选项
    defaultType = 'simple',
    // 默认参数,第一项为分页数据,第二项为表单数据。[pagination, formData]
    defaultParams,
    manual = false,
    // refreshDeps 变化,会重置 current 到第一页,并重新发起请求。
    refreshDeps = [],
    ready = true,
    ...rest
  } = options;

  // 对分页的逻辑进行处理
  // 分页也是对 useRequest 的再封装
  const result = usePagination<TData, TParams>(service, {
    manual: true,
    ...rest,
  });
  // ...
};

然后处理列表页筛选 Form 表单的逻辑,这里支持 Antd v3 和 Antd v4 版本。

// 判断是否为 Antd 的第四版本
const isAntdV4 = !!form?.getInternalHooks;

获取当前表单值,form.getFieldsValue 或者 form.getFieldInstance

// 获取当前的 from 值
const getActivetFieldValues = () => {
  if (!form) {
    return {};
  }
  // antd 4
  if (isAntdV4) {
    return form.getFieldsValue(null, () => true);
  }
  // antd 3
  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;
};

校验表单逻辑 form.validateFields:

// 校验逻辑
const validateFields = (): Promise<Record<string, any>> => {
  if (!form) {
    return Promise.resolve({});
  }
  const activeFieldsValue = getActivetFieldValues();
  const fields = Object.keys(activeFieldsValue);
  // antd 4
  // validateFields 直接调用
  if (isAntdV4) {
    return (form.validateFields as Antd4ValidateFields)(fields);
  }
  // antd 3
  return new Promise((resolve, reject) => {
    form.validateFields(fields, (errors, values) => {
      if (errors) {
        reject(errors);
      } else {
        resolve(values);
      }
    });
  });
};

重置表单 form.setFieldsValue

// 重置表单
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);
};

修改表单类型,支持 'simple''advance'。初始化的表单数据可以填写 simple 和 advance 全量的表单数据,开发者可以根据当前激活的类型来设置表单数据。修改 type 的时候会重置 form 表单数据。

const changeType = () => {
  // 获取当前表单值
  const activeFieldsValue = getActivetFieldValues();
  // 修改表单值
  allFormDataRef.current = {
    ...allFormDataRef.current,
    ...activeFieldsValue,
  };
  // 设置表单类型
  setType(t => (t === 'simple' ? 'advance' : 'simple'));
};

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

_submit 方法:对 form 表单校验后,根据当前 form 表单数据、分页等筛选条件进行对表格数据搜索。

const _submit = (initPagination?: TParams[0]) => {
  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);
  });
};

另外当表格触发 onChange 方法的时候,也会进行请求:

// Table 组件的 onChange 事件
const onTableChange = (pagination: any, filters: any, sorter: any) => {
  const [oldPaginationParams, ...restParams] = params || [];
  run(
    // @ts-ignore
    {
      ...oldPaginationParams,
      current: pagination.current,
      pageSize: pagination.pageSize,
      filters,
      sorter,
    },
    ...restParams,
  );
};

初始化的时候,会根据当前是否有缓存的数据,有则根据缓存的数据执行请求逻辑。否则,通过 manualready 判断是否需要进行重置表单后执行请求逻辑。

// 初始化逻辑
// init
useEffect(() => {
  // if has cache, use cached params. ignore manual and ready.
  // params.length > 0,则说明有缓存
  if (params.length > 0) {
    // 使用缓存的数据
    allFormDataRef.current = cacheFormTableData?.allFormData || {};
    // 重置表单后执行请求
    restoreForm();
    // @ts-ignore
    run(...params);
    return;
  }
  // 非手动并且已经 ready,则执行 _submit
  if (!manual && ready) {
    allFormDataRef.current = defaultParams?.[1] || {};
    restoreForm();
    _submit(defaultParams?.[0]);
  }
}, []);

最后,将请求返回的数据通过 dataSource、 pagination、loading 透传回给到 Table 组件,实现 Table 的数据以及状态的展示。以及将对 Form 表单的一些操作方法暴露给开发者。

return {
  ...result,
  // Table 组件需要的数据,直接透传给 Table 组件即可
  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),
    // 当前表单类型, simple | advance
    type,
    // 切换表单类型
    changeType: useMemoizedFn(changeType),
    // 重置当前表单
    reset: useMemoizedFn(reset),
  },
} as AntdTableResult<TData, TParams>;

usePagination

usePagination 基于 useRequest 实现,封装了常见的分页逻辑。

首先通过 useRequest 处理请求,service 约定返回的数据结构为 { total: number, list: Item[] }

其中 useRequest 的 defaultParams 参数第一个参数为 { current: number, pageSize: number }。并根据请求的参数以及返回的 total 值,得出总的页数。

还有 refreshDeps 变化,会重置 current 到第一页「changeCurrent(1)」,并重新发起请求,一般你可以把 pagination 依赖的条件放这里。

const usePagination = <TData extends Data, TParams extends Params>(
  service: Service<TData, TParams>,
  options: PaginationOptions<TData, TParams> = {},
) => {
  const { defaultPageSize = 10, ...rest } = options;

  // service 返回的数据结构为 { total: number, list: Item[] }
  const result = useRequest(service, {
    // service 的第一个参数为 { current: number, pageSize: number }
    defaultParams: [{ current: 1, pageSize: defaultPageSize }],
    // refreshDeps 变化,会重置 current 到第一页,并重新发起请求,一般你可以把 pagination 依赖的条件放这里
    refreshDepsAction: () => {
      // eslint-disable-next-line @typescript-eslint/no-use-before-define
      changeCurrent(1);
    },
    ...rest,
  });
  // 取到相关的请求参数
  const { current = 1, pageSize = defaultPageSize } = result.params[0] || {};
  // 获取请求结果,total 代表数据总条数
  const total = result.data?.total || 0;
  // 获取到总的页数
  const totalPage = useMemo(() => Math.ceil(total / pageSize), [
    pageSize,
    total,
  ]);
};

重点看下 onChange 方法:

  • 入参分别为当前页数以及当前每一页的最大数量。
  • 根据 total 算出总页数。
  • 获取到所有的参数,执行请求逻辑。
  • 当修改当前页或者当前每一页的最大数量的时候,直接调用 onChange 方法。
// c,代表 current page
// p,代表 page size
const onChange = (c: number, p: number) => {
  let toCurrent = c <= 0 ? 1 : c;
  const toPageSize = p <= 0 ? 1 : p;
  // 根据 total 算出总页数
  const tempTotalPage = Math.ceil(total / toPageSize);
  // 假如此时总页面小于当前页面,需要将当前页面赋值为总页数
  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 字段,包含所有分页信息,及操作分页的函数。
  pagination: {
    current,
    pageSize,
    total,
    totalPage,
    onChange: useMemoizedFn(onChange),
    changeCurrent: useMemoizedFn(changeCurrent),
    changePageSize: useMemoizedFn(changePageSize),
  },
} as PaginationResult<TData, TParams>;

小结:usePagination 默认用法与 useRequest 一致,但内部封装了分页请求相关的逻辑。返回的结果多返回一个 pagination 参数,包含所有分页信息,及操作分页的函数。

缺点就是对 API 请求参数有所限制,比如入参结构必须为 { current: number, pageSize: number },返回结果为 { total: number, list: Item[] }

useCountDown

一个用于管理倒计时的 Hook。

其实现原理就是通过定时器 setInterval 进行设置倒计时,为负值时,停止倒计时。

初始化 state:

// 初始化 state
const [timeLeft, setTimeLeft] = useState(() => calcLeft(targetDate));

其中 calcLeft 方法是计算目标时间和当前时间之间还有多少毫秒,入参是目标时间:

// 计算目标时间和当前时间之间还有多少毫秒
const calcLeft = (t?: TDate) => {
  if (!t) {
    return 0;
  }
  // https://stackoverflow.com/questions/4310953/invalid-date-in-safari
  // 计算剩余时间,目标时间 - 当前时间 > 0
  const left = dayjs(t).valueOf() - new Date().getTime();
  // 小于 0 的时候,返回 0,代表结束
  if (left < 0) {
    return 0;
  }
  return left;
};

通过定时器进行倒计时:

// 保证取到最新的值
const onEndRef = useLatest(onEnd);

useEffect(() => {
  if (!targetDate) {
    // for stop
    setTimeLeft(0);
    return;
  }

  // 立即执行一次
  setTimeLeft(calcLeft(targetDate));

  // 定时器
  const timer = setInterval(() => {
    const targetLeft = calcLeft(targetDate);
    setTimeLeft(targetLeft);
    // 剩余为 0,则取消定时器,并执行 onEnd 回调
    if (targetLeft === 0) {
      clearInterval(timer);
      onEndRef.current?.();
    }
  }, interval);

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

最后针对返回的结果进行格式化:

// 格式化后的倒计时
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,
  };
};

// 对结果进行 format
const formattedRes = useMemo(() => {
  return parseMs(timeLeft);
}, [timeLeft]);

// 将结果返回给开发者
return [timeLeft, formattedRes] as const;

useCounter

管理计数器的 Hook。

其实现原理很简单,就是暴露相应方法对数值进行管理。比如增加、减少、重置等方法。

初始化数据:

const { min, max } = options;

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

getTargetValue 获取目标数值,必须大于等于 min,小于等于 max。否则直接取 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;
}

setValue 设置值,可以留意其入参类型设置。

export type ValueParam = number | ((c: number) => number);
// 设置值。value 值支持 number 和 function
const setValue = (value: ValueParam) => {
  setCurrent(c => {
    const target = isNumber(value) ? value : value(c);
    return getTargetValue(target, {
      max,
      min,
    });
  });
};

inc/dec/set/reset 方法都是调用的 setValue 方法。

// 增加。增加维度默认为 1
const inc = (delta: number = 1) => {
  setValue(c => c + delta);
};

// 减少。减少区间默认为 1
const dec = (delta: number = 1) => {
  setValue(c => c - delta);
};

const set = (value: ValueParam) => {
  setValue(value);
};

// 重设会初始值
const reset = () => {
  setValue(initialValue);
};

useDynamicList

一个帮助你管理动态列表状态,并能生成唯一 key 的 Hook。

其原理上就是对数组常见操作进行了封装。有一些比较基础,直接看代码注释即可。

  • 生成唯一的 key

通过 useRef 返回同一个 ref 对象,每次设置的时候都自增 1,从而保证生成唯一的 key。keyList 中某个 item 的索引跟其对应源数据 item 索引保持一致。

// 当前的指向
const counterRef = useRef(-1);
// key List
const keyList = useRef<number[]>([]);
// 设置唯一的 key,通过 ref 保证 key 的唯一性
const setKey = useCallback((index: number) => {
  // 每次都加1
  counterRef.current += 1;
  // 将 key 值放入到列表中
  keyList.current.splice(index, 0, counterRef.current);
}, []);
  • 初始数据的处理

通过 useState 设置初始化数据。

// 列表设置
const [list, setList] = useState(() => {
  initialList.forEach((_, index) => {
    setKey(index);
  });
  return initialList;
});
  • resetList - 重设 list
// 重置 list,重新设置 list 的值
const resetList = useCallback((newList: T[]) => {
  // 先重置 key
  keyList.current = [];
  setList(() => {
    // 设置 key
    newList.forEach((_, index) => {
      setKey(index);
    });
    return newList;
  });
}, []);
  • insert - 在指定位置插入元素

通过 splice 方法进行处理。

// 在指定位置插入元素
const insert = useCallback((index: number, item: T) => {
  setList(l => {
    const temp = [...l];
    temp.splice(index, 0, item);
    setKey(index);
    return temp;
  });
}, []);
  • getKey - 获取 key 值
// 获取某个元素的 key 值
const getKey = useCallback((index: number) => keyList.current[index], []);
  • getIndex - 获取某个值下标
// 获取某个值的下标
const getIndex = useCallback(
  (key: number) => keyList.current.findIndex(ele => ele === key),
  [],
);
  • merge - 合并列表
// 将两个列表合并
const merge = useCallback((index: number, items: T[]) => {
  setList(l => {
    // 维护一个临时列表
    const temp = [...l];
    // 设置 key
    items.forEach((_, i) => {
      setKey(index + i);
    });
    // 合并
    temp.splice(index, 0, ...items);
    return temp;
  });
}, []);
  • replace - 替换某个元素
// 替换
const replace = useCallback((index: number, item: T) => {
  setList(l => {
    const temp = [...l];
    temp[index] = item;
    return temp;
  });
}, []);
  • remove - 移除某项

这里除了移除数组中的某项,还需要移除 keyList 中的值,只是这里还做了错误捕获,这个没想到会是什么场景。

// 移除
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;
  });
}, []);
  • move - 移动元素
// 移动元素
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 {
      // 维护 keyList
      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;
  });
}, []);
  • push - 在列表末尾添加元素
// 在列表末尾添加元素
const push = useCallback((item: T) => {
  setList(l => {
    setKey(l.length);
    return l.concat([item]);
  });
}, []);
  • pop - 移除末尾项
// 移除末尾项
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));
}, []);
  • unshift - 在起始位置添加元素
// 在列表起始位置添加元素
const unshift = useCallback((item: T) => {
  setList(l => {
    setKey(0);
    return [item].concat(l);
  });
}, []);
  • shift - 移除起始位置元素
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));
}, []);
  • sortList - 校准排序

为什么要校对?

// 校准排序
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
  [],
);

useHistoryTravel

管理状态历史变化记录,方便在历史记录中前进与后退。

实现原理,其在内部维护了以下的数据结构。通过队列的方式维护过去和未来的列表。

// 定义数据类型。其中 past 和 future 维护一个队列。
interface IData<T> {
  present?: T;
  past: T[];
  future: T[];
}
  • reset - 重置

直接将 present 值设置为初始值或者入参中的第一个值。并重置 future 和 past。

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

  setHistory({
    present: _initial,
    future: [],
    past: [],
  });
};
  • setValue - 设置 value

其对应的方法是 updateValue。直接看代码:

// 更新,都是往过去的list中添加
const updateValue = (val: T) => {
  setHistory({
    present: val,
    // future 直接置空
    future: [],
    // 之前的 past 和 present 都将称为 past
    past: [...past, present],
  });
};
  • _forward & _backward - 前进和后退

不管前进还是后退,都是调用 split 函数。不同的是前进则第二个参数传递的是 feature,后退则第二个参数传递的是 past。

// 前进,默认前进一步
const _forward = (step: number = 1) => {
  if (future.length === 0) {
    return;
  }
  // 前进则第二个参数传递的是 feature
  const { _before, _current, _after } = split(step, future);
  setHistory({
    // 旧状态,加上现在以及刚过去的
    past: [...past, present, ..._before],
    // 当前
    present: _current,
    future: _after,
  });
};

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

  // 后端则第二个参数传递的是 past
  const { _before, _current, _after } = split(step, past);
  setHistory({
    past: _before,
    present: _current,
    future: [..._after, present, ...future],
  });
};

split 函数主要的作用将传入 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: [] }

// 获取 current 值的下标
const dumpIndex = <T>(step: number, arr: T[]) => {
  let index =
    // 当值大于 0 的时候,前进
    step > 0
      ? step - 1 // move forward
      : arr.length + step; // move backward
  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[]) => {
  // 获取 current 值的下标
  const index = dumpIndex(step, targetArr);
  return {
    _current: targetArr[index],
    _before: targetArr.slice(0, index),
    _after: targetArr.slice(index + 1),
  };
};
  • go - 跳到具体某一步

最终调用 _forward 和 _backward

// 跳到第几步,最终调用 _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);
};

useInfiniteScroll

简介

useInfiniteScroll 封装了常见的无限滚动逻辑。

详细可看官网

注意:这里的无限滚动指的是常见的点击加载更多或者说下拉加载更加功能,而不是虚拟滚动,虚拟滚动后面会讲到。

实现原理

实现原理:使用了 useRequest hook 负责进行请求后台数据。其中 reloadAsync 对应 useRequest 的 runAsync,reload 对应 useRequest 的 run。前者返回 Promise,需要自行处理异常。后者内部已经做了异常处理。

另外假如传入 target 和 isNoMore 参数,通过监听 scroll 事件,判断是否滚动到指定的位置(支持设置 threshold 值-距离底部距离阈值),进行自动发起加载更多请求,从而实现滚动自动加载效果。

大概说完原理,来看代码。

具体实现

入参以及状态定义,可以直接看注释:

const useInfiniteScroll = <TData extends Data>(
  // 请求服务
  service: Service<TData>,
  options: InfiniteScrollOptions<TData> = {},
) => {
  const {
    // 父级容器,如果存在,则在滚动到底部时,自动触发 loadMore。需要配合 isNoMore 使用,以便知道什么时候到最后一页了。
    target,
    // 是否有最后一页的判断逻辑,入参为当前聚合后的 data
    isNoMore,
    // 下拉自动加载,距离底部距离阈值
    threshold = 100,
    // 变化后,会自动触发 reload
    reloadDeps = [],
    // 默认 false。 即在初始化时自动执行 service。
    // 如果设置为 true,则需要手动调用 reload 或 reloadAsync 触发执行。
    manual,
    // service 执行前触发
    onBefore,
    // 执行后
    onSuccess,
    // service reject 时触发
    onError,
    // service 执行完成时触发
    onFinally,
  } = options;

  // 最终的数据
  const [finalData, setFinalData] = useState<TData>();
  // 是否loading more
  const [loadingMore, setLoadingMore] = useState(false);
  // 省略代码...
};

判断是否有数据:isNoMore 的入参是当前聚合后的 data。

// 判断是否还有数据
const noMore = useMemo(() => {
  if (!isNoMore) return false;
  return isNoMore(finalData);
}, [finalData]);

通过 useRequest 处理请求,可以看到 onBefore、onSuccess、onError、onFinally、manual 等参数都是直接传到了 useRequest 中。

// 通过 useRequest 处理请求
const { loading, run, runAsync, cancel } = useRequest(
  // 入参,将上次请求返回的数据整合到新的参数中
  async (lastData?: TData) => {
    const currentData = await service(lastData);
    // 首次请求,则直接设置
    if (!lastData) {
      setFinalData(currentData);
    } else {
      setFinalData({
        ...currentData,
        // service 返回的数据必须包含 list 数组,类型为 { list: any[], ...rest }
        // @ts-ignore
        list: [...lastData.list, ...currentData.list],
      });
    }
    return currentData;
  },
  {
    // 是否手动控制
    manual,
    // 请求结束
    onFinally: (_, d, e) => {
      // 设置 loading 为 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),
  },
);

loadMore/loadMoreAsync 和 reload/reloadAsync 分别对应调用的是 useRequest 的 run 和 runAsync 函数。

// 同步加载更多
const loadMore = () => {
  // 假如没有更多,直接返回
  if (noMore) return;
  setLoadingMore(true);
  // 执行 useRequest
  run(finalData);
};

// 异步加载更多,返回的值是 Promise,需要自行处理异常
const loadMoreAsync = () => {
  if (noMore) return Promise.reject();
  setLoadingMore(true);
  return runAsync(finalData);
};

const reload = () => run();
const reloadAsync = () => runAsync();

并且当 reloadDeps 依赖发生变化的时候,会触发 reload,进行重置:

useUpdateEffect(() => {
  run();
}, [...reloadDeps]);

最后就是滚动自动加载的逻辑,通过 scrollHeight - scrollTop <= clientHeight + threshold 结果判断是否触底。

// 滚动方法
const scrollMethod = () => {
  const el = getTargetElement(target);
  if (!el) {
    return;
  }
  // Element.scrollTop 属性可以获取或设置一个元素的内容垂直滚动的像素数。
  const scrollTop = getScrollTop(el);
  // Element.scrollHeight 这个只读属性是一个元素内容高度的度量,包括由于溢出导致的视图中不可见内容。
  const scrollHeight = getScrollHeight(el);
  // 这个属性是只读属性,对于没有定义CSS或者内联布局盒子的元素为0,否则,它是元素内部的高度(单位像素),包含内边距,但不包括水平滚动条、边框和外边距。
  const clientHeight = getClientHeight(el);

  // 根据上面三个值以及 threshold 判断是否进行加载更多
  if (scrollHeight - scrollTop <= clientHeight + threshold) {
    loadMore();
  }
};

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

上面提到的三个重要的值 scrollTop,scrollHeight,clientHeight 可以看 utils 中的 rect

对应的值分别为以下结果:

scrollTop

Element.scrollTop 属性可以获取或设置一个元素的内容垂直滚动的像素数。一个元素的 scrollTop 值是这个元素的内容顶部(卷起来的)到它的视口可见内容(的顶部)的距离的度量。当一个元素的内容没有产生垂直方向的滚动条,那么它的 scrollTop 值为 0。

scrollHeight

Element.scrollHeight 这个只读属性是一个元素内容高度的度量,包括由于溢出导致的视图中不可见内容。

React Hook 封装常见业务场景,有哪些你用得到?

clientHeight

这个属性是只读属性,对于没有定义 CSS 或者内联布局盒子的元素为 0,否则,它是元素内部的高度 (单位像素),包含内边距,但不包括水平滚动条、边框和外边距。clientHeight 可以通过 CSS height + CSS padding - 水平滚动条高度 (如果存在) 来计算。

useNetwork

管理网络连接状态的 Hook。

实现原理是通过监听网络 online、offline、change 事件,并通过 navigator 的 connection 获取到网络的情况。

获取网络状态

主要是通过以下 getConnectionProperty 方法获取,可以直接看注释。

// 获取网络状态
function getConnection() {
  const nav = navigator as any;
  if (!isObject(nav)) return null;
  // https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/type
  return nav.connection || nav.mozConnection || nav.webkitConnection;
}

function getConnectionProperty(): NetworkState {
  const c = getConnection();
  if (!c) return {};
  return {
    // NetworkInformation.rtt 是一个只读属性,返回了当前连接下评估的往返时延(RTT, round-trip time ),并保留该值为 25 千分秒的最接近的整数倍。
    rtt: c.rtt,
    // 返回设备正在与网络进行通信的连接类型。比如 wifi/bluetooth 等
    type: c.type,
    // 如果用户设备上设置了减少数据使用的选项时返回 true。
    saveData: c.saveData,
    // 有效带宽估算(单位:兆比特/秒)
    downlink: c.downlink,
    // 最大下行速度(单位:兆比特/秒)
    downlinkMax: c.downlinkMax,
    // 网络连接的类型,值有 'slow-2g', '2g', '3g', or '4g'.
    effectiveType: c.effectiveType,
  };
}

监听事件,更新网络情况

监听 online 事件,触发设置 online 为 true。

监听 offline 事件,触发设置 online 为 false。

监听网络波动,则对网络情况进行更新。

since 为最后一次更新的时间。

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(),
    }));
  };

  // 监听网络变化
  window.addEventListener(NetworkEventType.ONLINE, onOnline);
  window.addEventListener(NetworkEventType.OFFLINE, onOffline);

  const connection = getConnection();
  // 获取到 connection 对象,监听该对象的 change 事件
  connection?.addEventListener(NetworkEventType.CHANGE, onConnectionChange);

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

useSelections

常见联动 Checkbox 逻辑封装,支持多选,单选,全选逻辑,还提供了是否选择,是否全选,是否半选的状态。

实现原理,维护所有项的值 items 数组以及设置选择的元素 setSelected(Set 数据结构)。

基本都是数组和 Set 的一些基础操作。可以直接看代码:

  • 判断是否选中
// 判断是否选中
const isSelected = (item: T) => selectedSet.has(item);
  • select - 选中
// 添加到选中的
const select = (item: T) => {
  selectedSet.add(item);
  // Array.from 将 Set 转换成数组
  return setSelected(Array.from(selectedSet));
};
  • unSelect - 移除
// 从选中列表中山茶油
const unSelect = (item: T) => {
  selectedSet.delete(item);
  return setSelected(Array.from(selectedSet));
};
  • toggle - 切换选中态
// 切换选中态
const toggle = (item: T) => {
  if (isSelected(item)) {
    unSelect(item);
  } else {
    select(item);
  }
};
  • selectAll - 选中所有
// 选中所有
const selectAll = () => {
  items.forEach(o => {
    selectedSet.add(o);
  });
  setSelected(Array.from(selectedSet));
};
  • unSelectAll - 去除所有选中
const unSelectAll = () => {
  items.forEach(o => {
    selectedSet.delete(o);
  });
  setSelected(Array.from(selectedSet));
};
  • noneSelected - 判断是否一个都没有选中
// 判断是否一个都没有选中
const noneSelected = useMemo(() => items.every(o => !selectedSet.has(o)), [
  items,
  selectedSet,
]);
  • allSelected - 是否所有的都选中
// 是否所有的都选中
const allSelected = useMemo(
  () => items.every(o => selectedSet.has(o)) && !noneSelected,
  [items, selectedSet, noneSelected],
);
  • partiallySelected - 是否部分选中
// 是否部分选中
const partiallySelected = useMemo(() => !noneSelected && !allSelected, [
  noneSelected,
  allSelected,
]);
  • toggleAll - 反转所有
// 反转所有
const toggleAll = () => (allSelected ? unSelectAll() : selectAll());

useTextSelection

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

其实现原理主要是监听 mouseup 和 mousedown 事件。调用 window.getSelection() 方法 获取到 Selection 对象,并通过 getBoundingClientRect 方法获取到其位置信息。

window.getSelection。返回一个 Selection 对象,表示用户选择的文本范围或光标的当前位置。

const selection = window.getSelection();

selection 是一个 Selection 对象。 如果想要将 selection 转换为字符串,可通过连接一个空字符串("")或使用 String.toString() 方法。

监听和取消事件,在 mouseup 事件中,获取到选取的文本以及位置信息。在 mousedown 中清除之前的信息。

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();
      // 通过 toString 方法转换成字符串
      text = selObj ? selObj.toString() : '';
      if (text) {
        // 获取到该对象为止
        rect = getRectFromSelection(selObj);
        setState({ ...state, text, ...rect });
      }
    };

    // 任意点击都需要清空之前的 range
    const mousedownHandler = () => {
      if (!window.getSelection) return;
      if (stateRef.current.text) {
        setState({ ...initState });
      }
      // 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() 方法会从当前 selection 对象中移除所有的 range 对象,取消所有的选择只留下 anchorNode 和focusNode 属性并将其设置为 null。
      selObj.removeAllRanges();
    };

    // 监听 mouseup 和 mousedown
    el.addEventListener('mouseup', mouseupHandler);

    document.addEventListener('mousedown', mousedownHandler);

    return () => {
      el.removeEventListener('mouseup', mouseupHandler);
      document.removeEventListener('mousedown', mousedownHandler);
    };
  },
  [],
  // 目标元素
  target,
);

获取文本位置信息:

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

  if (selection.rangeCount < 1) {
    return initRect;
  }
  // https://developer.mozilla.org/zh-CN/docs/Web/API/Selection/getRangeAt
  // 返回一个包含当前选区内容的区域对象。
  const range = selection.getRangeAt(0);
  // 获取它的位置
  const {
    height,
    width,
    top,
    left,
    right,
    bottom,
  } = range.getBoundingClientRect();
  return {
    height,
    width,
    top,
    left,
    right,
    bottom,
  };
}

useVirtualList

简介

提供虚拟化列表能力的 Hook,用于解决展示海量数据渲染时首屏渲染缓慢和滚动卡顿问题。

详情可见官网,文章源代码可以点击这里

实现原理

其实现原理监听外部容器的 scroll 事件以及其 size 发生变化的时候,触发计算逻辑算出内部容器的高度和 marginTop 值。

具体实现

其监听滚动逻辑如下:

// 当外部容器的 size 发生变化的时候,触发计算逻辑
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,
  },
);

其中 calculateRange 非常重要,它基本实现了虚拟滚动的主流程逻辑,其主要做了以下的事情:

  • 获取到整个内部容器的高度 totalHeight。
  • 根据外部容器的 scrollTop 算出已经“滚过”多少项,值为 offset。
  • 根据外部容器高度以及当前的开始索引,获取到外部容器能承载的个数 visibleCount。
  • 并根据 overscan(视区上、下额外展示的 DOM 节点数量)计算出开始索引(start)和(end)。
  • 根据开始索引获取到其距离最开始的距离(offsetTop)。
  • 最后根据 offsetTop 和 totalHeight 设置内部容器的高度和 marginTop 值。

变量很多,可以结合下图,会比较清晰理解:

React Hook 封装常见业务场景,有哪些你用得到?

代码如下:

// 计算范围,由哪个开始,哪个结束
const calculateRange = () => {
  // 获取外部和内部容器
  // 外部容器
  const container = getTargetElement(containerTarget);
  // 内部容器
  const wrapper = getTargetElement(wrapperTarget);

  if (container && wrapper) {
    const {
      // 滚动距离顶部的距离。设置或获取位于对象最顶端和窗口中可见内容的最顶端之间的距离
      scrollTop,
      // 内容可视区域的高度
      clientHeight,
    } = container;

    // 根据外部容器的 scrollTop 算出已经“滚过”多少项
    const offset = getOffset(scrollTop);
    // 可视区域的 DOM 个数
    const visibleCount = getVisibleCount(clientHeight, offset);

    // 开始的下标
    const start = Math.max(0, offset - overscan);
    // 结束的下标
    const end = Math.min(list.length, offset + visibleCount + overscan);

    // 获取上方高度
    const offsetTop = getDistanceTop(start);
    // 设置内部容器的高度,总的高度 - 上方高度
    // @ts-ignore
    wrapper.style.height = totalHeight - offsetTop + 'px';
    // margin top 为上方高度
    // @ts-ignore
    wrapper.style.marginTop = offsetTop + 'px';
    // 设置最后显示的 List
    setTargetList(
      list.slice(start, end).map((ele, index) => ({
        data: ele,
        index: index + start,
      })),
    );
  }
};

其它就是这个函数的辅助函数了,包括:

  • 根据外部容器以及内部每一项的高度,计算出可视区域内的数量:
// 根据外部容器以及内部每一项的高度,计算出可视区域内的数量
const getVisibleCount = (containerHeight: number, fromIndex: number) => {
  // 知道每一行的高度 - 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++) {
    // 计算每一个 Item 的高度
    const height = itemHeightRef.current(i, list[i]);
    sum += height;
    endIndex = i;
    // 大于容器宽度的时候,停止
    if (sum >= containerHeight) {
      break;
    }
  }
  // 最后一个的下标减去开始一个的下标
  return endIndex - fromIndex;
};
  • 根据 scrollTop 计算上面有多少个 DOM 节点:
// 根据 scrollTop 计算上面有多少个 DOM 节点
const getOffset = (scrollTop: number) => {
  // 每一项固定高度
  if (isNumber(itemHeightRef.current)) {
    return Math.floor(scrollTop / itemHeightRef.current) + 1;
  }
  // 动态指定每个元素的高度情况
  let sum = 0;
  let offset = 0;
  // 从 0 开始
  for (let i = 0; i < list.length; i++) {
    const height = itemHeightRef.current(i, list[i]);
    sum += height;
    if (sum >= scrollTop) {
      offset = i;
      break;
    }
  }
  // 满足要求的最后一个 + 1
  return offset + 1;
};
  • 获取上部高度:
// 获取上部高度
const getDistanceTop = (index: number) => {
  // 每一项高度相同
  if (isNumber(itemHeightRef.current)) {
    const height = index * itemHeightRef.current;
    return height;
  }
  // 动态指定每个元素的高度情况,则 itemHeightRef.current 为函数
  const height = list
    .slice(0, index)
    // reduce 计算总和
    // @ts-ignore
    .reduce((sum, _, i) => sum + itemHeightRef.current(i, list[index]), 0);
  return height;
};
  • 计算总的高度:
// 计算总的高度
const totalHeight = useMemo(() => {
  // 每一项高度相同
  if (isNumber(itemHeightRef.current)) {
    return list.length * itemHeightRef.current;
  }
  // 动态指定每个元素的高度情况
  // @ts-ignore
  return list.reduce(
    (sum, _, index) => sum + itemHeightRef.current(index, list[index]),
    0,
  );
}, [list]);

最后暴露一个滚动到指定的 index 的函数,其主要是计算出该 index 距离顶部的高度 scrollTop,设置给外部容器。并触发 calculateRange 函数。

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

思考与总结

对于高度相对比较确定的情况,我们做虚拟滚动还是相对简单的,但假如高度不确定呢?

或者换另外一个角度,当我们的滚动不是纵向的时候,而是横向,该如何处理呢?

useWebSocket

用于处理 WebSocket 的 Hook。

主要原理是基于 WebSocket,进行一些逻辑封装,比如错误重试逻辑,组件清除后自动 disconnect 等。

可以先看下类型定义,我们可以直观知道 WebSocket 的状态有四种,见 ReadyState:

  • 0 (WebSocket.CONNECTING)。正在链接中。
  • 1 (WebSocket.OPEN)。已经链接并且可以通讯。
  • 2 (WebSocket.CLOSING)。连接正在关闭。
  • 3 (WebSocket.CLOSED)。连接已关闭或者没有链接成功。

另外可以看到 typescript 内部有对 WebSocket 回调事件(WebSocketEventMap)以及其实例(WebSocket)的定义。

export enum ReadyState {
  Connecting = 0,
  Open = 1,
  Closing = 2,
  Closed = 3,
}

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[];
}

export interface Result {
  // 事件
  latestMessage?: WebSocketEventMap['message'];
  sendMessage?: WebSocket['send'];
  disconnect?: () => void;
  connect?: () => void;
  readyState: ReadyState;
  // 实例
  webSocketIns?: WebSocket;
}

看入参、状态声明以及返回:

export default function useWebSocket(
  // socketUrl。必填,webSocket 地址
  socketUrl: string,
  // 连接配置项
  options: Options = {},
): Result {
  const {
    reconnectLimit = 3,
    // 重试时间间隔(ms)
    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 unmountedRef = useRef(false);

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

  // 省略中间...

  return {
    latestMessage,
    sendMessage: useMemoizedFn(sendMessage),
    connect: useMemoizedFn(connect),
    disconnect: useMemoizedFn(disconnect),
    // 当前 webSocket 连接状态
    readyState,
    // webSocket 实例
    webSocketIns: websocketRef.current,
  };
}

再来看 webSocket 的连接,其中会对相关的回调事件进行处理,比如错误、成功回调。

// 创建链接
const connectWs = () => {
  // 假如存在重试的逻辑,则清除掉其定时器
  if (reconnectTimerRef.current) {
    clearTimeout(reconnectTimerRef.current);
  }
  // 先关闭之前的
  if (websocketRef.current) {
    websocketRef.current.close();
  }

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

  // webSocket 错误回调
  ws.onerror = event => {
    if (unmountedRef.current) {
      return;
    }
    // 进行重试
    reconnect();
    // 错误的回调
    onErrorRef.current?.(event, ws);
    setReadyState(ws.readyState || ReadyState.Closed);
  };
  // webSocket 连接成功回调
  ws.onopen = event => {
    if (unmountedRef.current) {
      return;
    }
    // webSocket 连接成功回调
    onOpenRef.current?.(event, ws);
    reconnectTimesRef.current = 0;
    setReadyState(ws.readyState || ReadyState.Open);
  };
  // webSocket 收到消息回调
  ws.onmessage = (message: WebSocketEventMap['message']) => {
    if (unmountedRef.current) {
      return;
    }
    // webSocket 收到消息回调
    onMessageRef.current?.(message, ws);
    setLatestMessage(message);
  };
  // webSocket 关闭回调
  ws.onclose = event => {
    if (unmountedRef.current) {
      return;
    }
    reconnect();
    // webSocket 关闭回调
    onCloseRef.current?.(event, ws);
    setReadyState(ws.readyState || ReadyState.Closed);
  };
  // 保存 websocket  实例
  websocketRef.current = ws;
};

上面当 WebSocket 错误的时候,也就是 onerror 回调中,我们会调用 reconnect 进行重试。重试支持重试次数的定义,我们来看下 reconnect 函数:

// 重试。重新连接
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);
  }
};

封装暴露常见操作,比如发送信息,断开,连接等方法:

// 发送消息函数
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();
};

socketUrl 更新的时候,manual (手动连接)为 false,则自动连接:

useEffect(() => {
  // 如果手动,则不会触发连接
  if (!manual) {
    connect();
  }
}, [socketUrl, manual]);

组件销毁的时候,则断开:

useUnmount(() => {
  unmountedRef.current = true;
  disconnect();
});