hooks 源码浅析 — Scene

开发者认知操作系统:可复用的原子点滴记录模板 在软件工程中,‘开发经验’本质上是可复现的决策链路,而‘开发点滴’则是带上下文的最小知识单元。其底层原理在于将人类短期记忆(7±2信息块)无法承载的复杂调试上下文,通过结构化存档转化为可检索、可验证、可传承的技术资产。这种实践超越传统笔记或博客,具备明确的技术价值:防止知识随人员流动蒸发、加速新人上手、支撑线上问题秒级定位。典型应用场景包括分布式事务排查、浏览器兼容性修复、API错误处理演进等高频开发痛点。本文聚焦于一种轻量、嵌入式、Git原生的开发者认知操作系统设计,核心围绕原子点滴、经验脉络与知识图谱三 阅读详情

在这里插入图片描述

如上图所示,描述了useFusionTable、useAntdTable、usePagination、useRequest四个hook的调用依赖关系。

usePagination

基于useRequest封装了常用的分页逻辑,针对入参和出参做了处理。

  • 入参:defaultParams,设置了current和pageSize,会传递给service接口使用。
  • 出参:扩展了pagination,额外返回分页信息以及分页操作函数以便给组件层使用。
const usePagination = (service, options) => {
    const { defaultPageSize = 10, ...rest } = options;
    
    const result = useRequest(service, {
      defaultParams: [{ current: 1, pageSize: defaultPageSize }],
      refreshDepsAction: () => {
        // eslint-disable-next-line @typescript-eslint/no-use-before-define
        changeCurrent(1);
      },
      ...rest,
    });
  
    const onChange = (c: number, p: number) => {
      let toCurrent = c <= 0 ? 1 : c;
      const toPageSize = p <= 0 ? 1 : p;
      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,
      );
    };
    // changeCurrent、changePageSize逻辑
    return {
      ...result,
      pagination: {
        current,
        pageSize,
        total,
          totalPage,
          onChange: useMemoizedFn(onChange), // 页码或 pageSize 改变的回调
          changeCurrent: useMemoizedFn(changeCurrent),
          changePageSize: useMemoizedFn(changePageSize),
        },
      } as PaginationResult<TData, TParams>;
  };

useAntdTable

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

  • 出参:扩展了tableProps 和 search 字段,管理表格和表单。提供submit,reset,onTableChange提供给组件层
  • Form与Table联动,需要传递form实例,以便内部处理表单相关行为。例如提交、重置、校验,进一步触发请求逻辑。
  • useUpdateEffect可以忽略首次渲染执行,只在依赖更新的
const useAntdTable = (service, options) => {
  const {
    form, //接收form实例,实现Form与Table联动
    defaultType = 'simple',
    defaultParams,
    manual = false,
    refreshDeps = [],
    ready = true,
    ...rest
  } = options;
  const result = usePagination<TData, TParams>(service, {
    manual: true,
    ...rest,
  });
  
  const { params = [], run } = result; //params记录缓存的数据
  
  //省略 reset、 submit、 onTableChange 等函数

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

在这里插入图片描述


useFusionTable

上面说到在调用hook依赖中用到了useAntdTable,这里比较巧妙的是用到了适配器模式,针对入参和出参进行了适配转换。

const useFusionTable = (service, options): FusionTableResult<TData, TParams> => {
  const ret = useAntdTable<TData, TParams>(service, {
    ...options,
    form: options.field ? fieldAdapter(options.field) : undefined,
  });

  return resultAdapter(ret);
};

// 表单实例适配器,操作表单相关函数做映射
export const fieldAdapter = (field: Field) =>
  ({
    getFieldInstance: (name: string) => field.getNames().includes(name),
    setFieldsValue: field.setValues,
    getFieldsValue: field.getValues,
    resetFields: field.resetToDefault,
    validateFields: (fields, callback) => {
      field.validate(fields, callback);
    },
  } as AntdFormUtils);
// 结果适配器,磨平Fusion和Antd差异
export const resultAdapter = (result: any) => {
  const tableProps = {
    dataSource: result.tableProps.dataSource,
    loading: result.tableProps.loading,
    onSort: (dataIndex: string, order: string) => {
      result.tableProps.onChange(
        { current: result.pagination.current, pageSize: result.pagination.pageSize },
        result.params[0]?.filters,
        {
          field: dataIndex,
          order,
        },
      );
    },
    onFilter: (filterParams: Object) => {
      result.tableProps.onChange(
        { current: result.pagination.current, pageSize: result.pagination.pageSize },
        filterParams,
        result.params[0]?.sorter,
      );
    },
  };

  const paginationProps = {
    onChange: result.pagination.changeCurrent,
    onPageSizeChange: result.pagination.changePageSize,
    current: result.pagination.current,
    pageSize: result.pagination.pageSize,
    total: result.pagination.total,
  };

  return {
    ...result,
    tableProps,
    paginationProps,
  };
};

useInfiniteScroll

封装了常见的无限滚动逻辑,会自动帮忙整合多次请求的数据结果。

  • 依赖的hook有useRequest、useUpdateEffect、useMemoizedFn、useEventListener。
  const scrollMethod = () => {
    const el = getTargetElement(target);
    if (!el) {
      return;
    }
    
    // 元素的内容顶部(卷起来的)到它的视口可见内容(的顶部)的距离的度量。
    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 },
  );

在这里插入图片描述


useDynamicList

管理动态列表状态,并能生成唯一 key 。

  • 内部维护了一个计数器counterRef,和 唯一key的列表。
  • 计数器是不断累加的,移除操作不会改变计数器的当前记录值。只是删除对应的key值。再次添加,会在计数器记录值上继续累加。
  • 增删逻辑转换,以便返回新数组进行赋值。
  • push(item: T): number ===> concat([item]): T[]
  • pop(): T | undefined ===> slice(start?: number, end?: number): T[];
  • unshift(item: T): number ===> concat([item]): T[]
  • shift(): T | undefined ===> slice(start?: number, end?: number): T[];
const useDynamicList = <T>(initialList: T[] = []) => {
  // 计数器
  const counterRef = useRef(-1);
  // 唯一key列表
  const keyList = useRef<number[]>([]);
  
  const setKey = useCallback((index: number) => {
    counterRef.current += 1;
    keyList.current.splice(index, 0, counterRef.current);
  }, []);
  
  // 基于入参List,记录数量和keyList。
  const [list, setList] = useState(() => {
    initialList.forEach((_, index) => {
      setKey(index);
    });
    return initialList;
  });
  
  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));
  }, []);

}

useVirtualList

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

  • 首先需要清楚几个概览:可视区域、可滚动区域、滚动元素。(如下图所示)
  • 计算当前可见区域起始数据的 startIndex(包含上面区域额外的节点数overscan)
  • 计算当前可见区域结束数据的 endIndex(包含下面区域额外的节点数overscan)
  • 计算内容高度,以及marginTop高度,来抵消上部移除的节点所占据高度。
  • 计算当前可见区域的数据,并渲染到页面中
    在这里插入图片描述
    在这里插入图片描述
const useVirtualList = <T = any>(list: T[], options: Options<T>) => { const { const useVirtualList = <T = any>(list: T[], options: Options<T>) => {
  const { containerTarget, wrapperTarget, itemHeight, overscan = 5 } = options;

  const totalHeight = useMemo(() => {
    if (isNumber(itemHeightRef.current)) {
      return list.length * itemHeightRef.current;
    }
    // @ts-ignore
    // Item 高度不一致时,计算逻辑
    return list.reduce((sum, _, index) => sum + itemHeightRef.current(index, list[index]), 0);
  }, [list]);
  
  // 计算需要渲染Item的索引范围
  const calculateRange = () => {
    const container = getTargetElement(containerTarget); // 可视区域
    const wrapper = getTargetElement(wrapperTarget);     // 可滚动区域

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

      const offset = getOffset(scrollTop); // 顶部偏移的Item数
      const visibleCount = getVisibleCount(clientHeight, offset); //可见Item数

      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';
      // @ts-ignore
      wrapper.style.marginTop = offsetTop + '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]);

  useEventListener(
    'scroll',
    (e) => {
      if (scrollTriggerByScrollToFunc.current) {
        scrollTriggerByScrollToFunc.current = false;
        return;
      }
      e.preventDefault();
      calculateRange();
    },
    {
      target: containerTarget,
    },
  );

  const scrollTo = (index: number) => {
    const container = getTargetElement(containerTarget);
    if (container) {

      scrollTriggerByScrollToFunc.current = true; // 标记位,避免二次触发scroll事件
      container.scrollTop = getDistanceTop(index);
      calculateRange();
    }
  };

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

export default useVirtualList;


useHistoryTravel

管理状态历史变化记录,方便在历史记录中前进与后退。例如撤销,恢复的场景。

  • 维护了三个变量present、past、future分别记录了当前值,过去及未来的值列表。
  • 更新值时:记录当前值,把原值追加到past中;
  • 回退:从past中进行拆分,同时把原值插入到future中;
  • 前进:从future中进行拆分,同时把原值插入到future中;

如下表格展示了Input输入后状态的变化,以及前进,回退操作后状态变化。

input初始值present: undefined, past: [], future: []
输入1present: “1”, past: [undefined], future: []
追加输入2present: “12”, past: [undefined, “1”], future: []
追加输入3present: “123”, past: [undefined, “1”, “12”], future: []
回退present: “12”, past: [undefined, “1”], future: [“123”]
回退present: “1”, past: [undefined], future: [“12”, “123”]
前进present: “12”, past: [undefined, “1”], future: [“123”]
export default function useHistoryTravel<T>(initialValue?: T) {
  const [history, setHistory] = useState<IData<T | undefined>>({
    present: initialValue, // 记录当前值
    past: [], // 记录过去的值
    future: [], // 记录未来的值
  });
  
  const updateValue = (val: T) => {
    setHistory({
      present: val,
      future: [],
      past: [...past, present], // 更新当前值,把上一次值追加到past数组中
    });
  };
  
   const _backward = (step: number = -1) => {
    if (past.length === 0) {
      return;
    }

    const { _before, _current, _after } = split(step, past);
    console.log(_before, _current, _after);
    setHistory({
      past: _before,
      present: _current,
      future: [..._after, present, ...future],
    });
  };
}

useNetwork

管理网络连接状态。监听online 、 offline、 change事件。初始网络状态通过navigator.onLine获取

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

    window.addEventListener(NetworkEventType.ONLINE, onOnline); // 监听在线状态
    window.addEventListener(NetworkEventType.OFFLINE, onOffline); // 监听离线状态

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

useSelections

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

  • 将原始数组转为Set集合,非常方便进行增删查,避免指定唯一ID。思路巧妙
  • Set 对象允许你存储任何类型的唯一值,无论是原始值或者是对象引用。
  • Set对象是值的集合,你可以按照插入的顺序迭代它的元素。 Set中的元素只会出现一次,即 Set 中的元素是唯一的。
export default function useSelections<T>(items: T[], defaultSelected: T[] = []) {
  const [selected, setSelected] = useState<T[]>(defaultSelected);
  // 这里将原始数据转为Set数组,采用has方法方便判断唯一性。例如是对象数组,同样支持
  const selectedSet = useMemo(() => new Set(selected), [selected]);
  // 判断是否选中
  const isSelected = (item: T) => selectedSet.has(item);
  // 选择
  const select = (item: T) => {
    selectedSet.add(item);
    return setSelected(Array.from(selectedSet));
  };
  // 取消选择
  const unSelect = (item: T) => {
    selectedSet.delete(item);
    return setSelected(Array.from(selectedSet));
  };

  const toggle = (item: T) => {
    if (isSelected(item)) {
      unSelect(item);
    } else {
      select(item);
    }
  };

  const selectAll = () => {
    items.forEach((o) => {
      selectedSet.add(o);
    });
    setSelected(Array.from(selectedSet));
  };

  const unSelectAll = () => {
    items.forEach((o) => {
      selectedSet.delete(o);
    });
    setSelected(Array.from(selectedSet));
  }
  
  // 省略n行代码
  return {
    selected,
    noneSelected,
    allSelected,
    partiallySelected,
    setSelected,
    isSelected,
    select: useMemoizedFn(select),
    unSelect: useMemoizedFn(unSelect),
    toggle: useMemoizedFn(toggle),
    selectAll: useMemoizedFn(selectAll),
    unSelectAll: useMemoizedFn(unSelectAll),
    toggleAll: useMemoizedFn(toggleAll),
  } as const;
}

范例:

const result: Result= useSelections<T>(items: T[], defaultSelected?: T[]);

useSelections([1, 2, 3, 4, 5, 6, 7, 8]);

useSelections([{ value: 1, label: "苹果" }, { value: 2, label: "梨" }]);

useCountDown

用于管理倒计时的 Hook。

  • 动态变更配置项, 适用于验证码或类似场景,时间结束后会触发 onEnd 回调。
  • 借助于setInterval实现,由于间隔不一定精准,以及程序执行需要时间,会存在一定误差。
  • 停止计时并没有提供类似stop函数,而是采用setTargetDate(undefined)更新targetDate
const calcLeft = (t?: TDate) => {
  if (!t) {
    return 0;
  }
  // https://stackoverflow.com/questions/4310953/invalid-date-in-safari
  const left = dayjs(t).valueOf() - new Date().getTime();
  if (left < 0) {
    return 0;
  }
  return 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) => {
  // targetDate 截止时间
  const { targetDate, interval = 1000, onEnd } = options || {};
  // 截止时间和当前时间 的差值
  const [timeLeft, setTimeLeft] = useState(() => calcLeft(targetDate));

  const onEndRef = useLatest(onEnd);

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

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

    const timer = setInterval(() => {
      const targetLeft = calcLeft(targetDate);
      setTimeLeft(targetLeft);
      if (targetLeft === 0) {
        clearInterval(timer);
        onEndRef.current?.();
      }
    }, interval);

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

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

  return [timeLeft, formattedRes] as const;
};

useCounter

管理计数器的 Hook。

  • 对于临界值的处理。如果指定target不在[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;
}

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

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

  const setValue = (value: ValueParam) => {
    setCurrent((c) => {
      const target = isNumber(value) ? value : value(c);
      return getTargetValue(target, {
        max,
        min,
      });
    });
  };

  const inc = (delta: number = 1) => {
    setValue((c) => c + delta);
  };

  const dec = (delta: number = 1) => {
    setValue((c) => c - delta);
  };

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

useTextSelection

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

  • 监听目前元素的down和up事件。鼠标按下,清空原始数据;鼠标抬起重新获取新的选中的值
  • Window.getSelection 返回一个 Selection 对象,表示用户选择的文本范围或光标的当前位置。如果想要将 selection 转换为字符串,可通过连接一个空字符串(“”)或使用 String.toString() 方法
  • Selection.getRangeAt()返回一个包含当前选区内容的区域对象 Range。
  • Range.getBoundingClientRect()返回一个DOMRect对象,{ height, width, top, left, right, bottom }
  • 高阶函数createEffectWithTarget,包装了useEffect,返回一个新函数useEffectWithTarget,扩展了对target的依赖对比。
export default function depsAreSame(oldDeps: DependencyList, deps: DependencyList): boolean {
  if (oldDeps === deps) return true;
  for (let i = 0; i < oldDeps.length; i++) {
    if (!Object.is(oldDeps[i], deps[i])) return false;
  }
  return true;
}

const createEffectWithTarget = (useEffectType: typeof useEffect | typeof useLayoutEffect) => {
  /**
   *
   * @param effect
   * @param deps
   * @param target target should compare ref.current vs ref.current, dom vs dom, ()=>dom vs ()=>dom
   */
  const useEffectWithTarget = (
    effect: EffectCallback,
    deps: DependencyList,
    target: BasicTarget<any> | BasicTarget<any>[],
  ) => {
    const hasInitRef = useRef(false);

    const lastElementRef = useRef<(Element | null)[]>([]);
    const lastDepsRef = useRef<DependencyList>([]);

    const unLoadRef = useRef<any>();

    useEffectType(() => {

      const targets = isArray(target) ? target : [target];
      const els = targets.map((item) => getTargetElement(item));

      // init run
      if (!hasInitRef.current) {
        hasInitRef.current = true;
        lastElementRef.current = els;
        lastDepsRef.current = deps;

        unLoadRef.current = effect();
        return;
      }

      if (
        els.length !== lastElementRef.current.length ||
        !depsAreSame(els, lastElementRef.current) ||
        !depsAreSame(deps, lastDepsRef.current)
      ) {
        unLoadRef.current?.();

        lastElementRef.current = els;
        lastDepsRef.current = deps;
        unLoadRef.current = effect();
      }
    });

    useUnmount(() => {
      unLoadRef.current?.();
      // for react-refresh
      hasInitRef.current = false;
    });
  };

  return useEffectWithTarget;
};

useWebSocket

用于处理 WebSocket 的 Hook

  • 使用useLatest,返回当前最新值的 Hook,避免闭包问题。
export default function useWebSocket(socketUrl: string, options: Options = {}): Result {
  const {
    reconnectLimit = 3,
    reconnectInterval = 3 * 1000,
    manual = false,
    onOpen,
    onClose,
    onMessage,
    onError,
    protocols,
  } = options;
  // 返回当前最新值的 Hook,可以避免闭包问题。
  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);

  // error, close 情况下进行重新连接
  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();
        console.log(2);

        reconnectTimesRef.current++;
      }, reconnectInterval);
    }
  };

  const connectWs = () => {
    if (reconnectTimerRef.current) {
      clearTimeout(reconnectTimerRef.current);
    }

    if (websocketRef.current) {
      websocketRef.current.close();
    }

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

    ws.onerror = (event) => {
      if (unmountedRef.current) {
        return;
      }
      console.log(event);

      reconnect();
      onErrorRef.current?.(event, ws);
      setReadyState(ws.readyState || ReadyState.Closed);
    };
    ws.onopen = (event) => {
      if (unmountedRef.current) {
        return;
      }
      onOpenRef.current?.(event, ws);
      reconnectTimesRef.current = 0;
      setReadyState(ws.readyState || ReadyState.Open);
    };
    ws.onmessage = (message: WebSocketEventMap['message']) => {
      if (unmountedRef.current) {
        return;
      }
      onMessageRef.current?.(message, ws);
      setLatestMessage(message);
      console.log(message);

    };
    ws.onclose = (event) => {
      console.log(event);
      if (unmountedRef.current) {
        return;
      }
      reconnect();
      onCloseRef.current?.(event, ws);
      setReadyState(ws.readyState || ReadyState.Closed);
    };

    websocketRef.current = ws;
  };

  const sendMessage: WebSocket['send'] = (message) => {
    if (readyState === ReadyState.Open) {
      websocketRef.current?.send(message);
    } else {
      throw new Error('WebSocket disconnected');
    }
  };

  const connect = () => {
    reconnectTimesRef.current = 0;
    connectWs();
  };

  const disconnect = () => {
    if (reconnectTimerRef.current) {
      clearTimeout(reconnectTimerRef.current);
    }

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

  useEffect(() => {
    if (!manual) {
      connect();
    }
  }, [socketUrl, manual]);

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

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

参考资料

HTML5游戏源码变现:低门槛开发与商业应用全解析 HTML5技术凭借其跨平台、即开即玩的特性,已成为轻量级互动内容开发的主流选择。其核心原理基于Canvas、WebGL等Web API,结合JavaScript与游戏框架(如Phaser),能够高效构建2D及轻量3D游戏。这项技术的价值在于大幅降低了互动内容的开发与部署门槛,使得非专业开发者也能快速创建可运行的游戏应用。在实际应用场景中,HTML5游戏不仅用于娱乐,更广泛服务于教育互动、营销活动、网站用户留存提升等领域。本文聚焦于一个具体实践方向:如何通过封装和售卖高质量的HTML5游戏源码包,实现游戏资产 阅读详情

相关推荐

Unity Inspector中文乱码终极解决方案:UTF-8编码统一与工程实践

字符编码是计算机存储和处理文本信息的基础规则,它定义了字符与二进制数据之间的映射关系。在跨平台、多语言的软件开发中,编码不一致是导致文本乱码的常见根源。其核心原理在于,当读取文件的程序使用了与文件保存时不同的编码规则进行解码,就会产生无法识别的字符,通常表现为问号或方块。对于使用Unity引擎进行游戏开发的团队而言,正确处理编码问题具有重要的技术价值,它能确保项目资产(如脚本、预制体、场景文件)中的元数据、注释和序列化字符串在不同开发环境和版本控制系统中保持一致性,避免因乱码导致的协作障碍和调试时间浪费。特

weixin_30698297的博客 349

阿里 ahooks介绍(三): useAntdTable和useFusionTable及useTable

一、useAntdTable(与ant design 中的antdTable控件配合) 二、useFusionTable:(与普通的table控件配合) 三、useTable(带有插件功能,满足九个场景应用)

热水钟博客 5904

Power Bi数据分析案例:在线零售平台销售数据分析

该数据集为,涵盖2023年1月至2024年3月,包含用户群体特征、购买行为、产品偏好以及参与度指标。用户维度:Customer_ID,Age,Gender,City产品维度:Product_Category,Unit_Price事实表:Order_ID,Quantity,Total_Amount,Discount_Amount,Rating,Delivery_Time在Power Query当中处理销售日期列,并新增年龄段、浏览页面分段列。

2301_79586168的博客 1278

React:table组件/Pagination分页组件/Dialog组件 - 点击单元格内容显示弹窗,弹窗的内容由表格内容控制(1)

一、首先渲染一个表格,表格内容:一列数据,一列操作列 <Pagination/>是用来分页的分页组件 export default function TableList(){ return ( <> <Card free> <Card.Content> <div className={styles.Main}> ...

thingjustthing的博客 959

【Excel VBA 编程】批量核对两张表格数据,行和列显示顺序均被打乱

利用数据字典在 Excel 中进行表格数据的核对,该表格含有多行多列,并且行和列顺序是打乱的,批量核对数据后将差异输出

wei1019的专栏 1293

ADS入门学习笔记——射频振荡器的设计

振荡器是射频系统中的基本部件之一,可以将直流功率转换为射频功率,在特定的频率点建立起稳定的正弦振荡,成为所需的射频信号源。

weixin_42126696的博客 8948

useDynamicList 实现动态列表

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

rcx0113的博客 499

comfyui从入门到精通二:提示词规则详解

《AI绘画提示词使用全指南》摘要: 本文系统解析AI绘画提示词的核心规则与实用技巧。关键点包括:1) 空格与下划线的本质差异,webui会自动转换但comfyui保留原格式;2) 数字与单词表达(如1girl/one girl)会导致风格变化;3) 三种权重控制方式(数值标注、括号嵌套、位置效应),其中数值标注最直观有效;4) 位置衰减公式揭示提示词排序对效果的影响权重。同时指出当前AI绘画仍存在角色一致性等更复杂的技术瓶颈。全文通过多组对比实验,为创作者提供从基础语法到高阶控制的完整解决方案。

ScottePerk的博客 1925

ahooks.js:一款强大的React Hooks库及其API使用教程(一)

ahooks是一款由阿里巴巴开发团队设计的React Hooks库,提供了一系列实用的React Hooks,以便开发者更好地使用React的功能。ahooks的设计原则是“最小API,最大自由”,旨在提供最小的、最易于理解和使用的API,同时保留最大的使用自由度。useRequest是ahooks中最常用的一个API,它用于处理异步请求和其相关状态。在上述代码中,useRequest接收一个函数作为参数,该函数返回一个Promise。

weixin_42662753的博客 9327

从零开始利用MATLAB进行FPGA设计(六)用ADC采集信号教程1

用AN9238采集信号的方法(1),包含PLL、FIFO、ILA的基础教程。

WZT725的博客 3094

ThinkPad T14 Gen 2与T14P系列对比

表格您的需求推荐机型日常办公、出差、文档处理、视频会议✅(够用、轻便、便宜)视频剪辑、3D建模、编程编译、AI训练、高色准设计✅(性能强、屏幕好、扩展佳)想要未来 5 年不换机✅T14p预算低于 5000 元✅T14 Gen 2(或考虑 T14 Gen 3/4)T14p 2025 款 ≠ 小改款,而是架构级升级它从“高性能笔记本”进化为“AI 移动工作站”,核心差异在于NPU + Ultra 平台 + 全面屏显 + 超长续航。如果您从事AI 开发、视频创作、工程仿真、多任务编程。

tecoes的博客 2738

130+Hooks,解锁react hooks使用 的正确姿势

为什么会有 Hook 介绍Hooks之前,首先要给大家说一下React的组件创建方式,一种是类组件,一种是纯函数组件,并且React团队希望,组件不要变成复杂的容器,最好只是数据流的管道。开发者根据需要,组合管道即可。也就是说组件的最佳写法应该是函数,而不是类。。 但是我们知道,在以往开发中类组件和纯函数组件的区别是很大的,纯函数组件有着类组件不具备的多种特点,简单列举几条 纯函数组件没有状态 纯函数组件没有生命周期 纯函数组件没有this 只能是纯函数 这就注定,我们所推崇的函数组件,只能做UI展

qq_39398332的博客 4894

【100%通过率】华为od机试C卷【部门人力分配】Python 实现

当前部门需要完成 N 个需求,需求用 requirements[i] 表示,requirements[i] 表示第 i 个需求的工作量大小,单位:人月。2:明显这个人力需求值有上下限的情况下【下限为requirements中的最大值,上限为requirements中的最大两个值相加(因为最多有两个需求同一个月开发)】,可以考虑使用二分法。当选择人力为6时,2个需求量为3的工作可以在1个月里完成,其他2个工作各需要1个月完成。当选择人力为5时,4个工作各需要1个月完成,一共需要4个月才能完成所有需求。

MISAYAONE的博客 2020

终极指南:如何在React、Vue和Svelte中无缝集成Scene.js动画框架

Scene.js是一款功能强大的JavaScript & CSS时间线动画库,它允许开发者轻松创建流畅、高性能的动画效果。本指南将详细介绍如何在当今最流行的三大前端框架(React、Vue和Svelte)中快速集成Scene.js,让你的应用界面瞬间焕发活力。 ## 为什么选择Scene.js? Scene.js作为一款专注于时间线动画的库,具有以下核心优势: - **轻量级设计**:核心库

gitblog_00293的博客 396

基于lstm+gru+transformer的电池寿命预测健康状态预测-完整数据代码

基于lstm+gru+transformer的电池寿命预测健康状态预测-完整数据代码

881

ssm框架搭建

这个是一个在idea上成功跑起来的ssm框架项目,保存在这里方便以后使用直接下载

SceneJS着色器编程入门:自定义Shader让3D场景焕然一新

SceneJS是一个基于WebGL的可扩展3D引擎,通过场景图(Scene Graph)组织节点,让开发者用JSON就能搭建三维场景。而SceneJS着色器编程更是它的精华所在——只需一个 `shader` 节点,就能编写自定义Shader,把普通的几何体变成流动的水面、半透明的X光效果甚至扭曲的动画。本文带你零基础入门SceneJS自定义Shader,让3D场景焕然一新。 ## SceneJS

gitblog_00815的博客 258

深度学习工具包 DeepLearningToolbox

深度学习工具包Deprecation notice.-----This toolbox is outdated and no longer maintained.There are much better tools available for deep learning than this toolbox, e.g. [Theano](http://deeplearning.net/software/theano/), [torch](http://torch.ch/) or [tensorflow](http://www.tensorflow.org/)I would suggest you use one of the tools mentioned above rather than use this toolbox.Best, Rasmus.DeepLearnToolbox================A Matlab toolbox for Deep Learning.Deep Learning is a new subfield of machine learning that focuses on learning deep hierarchical models of data.It is inspired by the human brain's apparent deep (layered, hierarchical) architecture.A good overview of the theory of Deep Learning theory is[Learning Deep Architectures for AI](http://www.iro.umontreal.ca/~bengioy/papers/ftml_book.pdf)For a more informal introduction, see the following videos by Geoffrey Hinton and Andrew Ng.* [The Next Generation of Neural Networks](http://www.youtube.com/watch?v=AyzOUbkUf3M) (Hinton, 2007)* [Recent Developments in Deep Learning](http://www.youtube.com/watch?v=VdIURAu1-aU) (Hinton, 2010)* [Unsupervised Feature Learning and Deep Learning](http://www.youtube.com/watch?v=ZmNOAtZIgIk) (Ng, 2011)If you use this toolbox in your research please cite [Prediction as a candidate for learning deep hierarchical models of data](http://www2.imm.dtu.dk/pubdb/views/publication_details.php?id=6284)```@MASTERSTHESIS\{IMM2012-06284, author = "R. B. Palm", title = "Prediction as a candidate for learning deep hierarchical models of data", year = "2012",}```Contact: rasmusbergpalm at gmail dot comDirectories included in the toolbox-----------------------------------`NN/` - A library for Feedforward Backpropagation Neural Networks`CNN/` - A library for Convolutional Neural Networks`DBN/` - A library for Deep Belief Networks`SAE/` - A library for Stacked Auto-Encoders`CAE/` - A library for Convolutional Auto-Encoders`util/` - Utility functions used by the libraries`data/` - Data used by the examples`tests/` - unit tests to verify toolbox is workingFor references on each library check REFS.mdSetup-----1. Download.2. addpath(genpath('DeepLearnToolbox'));Example: Deep Belief Network---------------------```matlabfunction test_example_DBNload mnist_uint8;train_x = double(train_x) / 255;test_x = double(test_x) / 255;train_y = double(train_y);test_y = double(test_y);%% ex1 train a 100 hidden unit RBM and visualize its weightsrand('state',0)dbn.sizes = [100];opts.numepochs = 1;opts.batchsize = 100;opts.momentum = 0;opts.alpha = 1;dbn = dbnsetup(dbn, train_x, opts);dbn = dbntrain(dbn, train_x, opts);figure; visualize(dbn.rbm{1}.W'); % Visualize the RBM weights%% ex2 train a 100-100 hidden unit DBN and use its weights to initialize a NNrand('state',0)%train dbndbn.sizes = [100 100];opts.numepochs = 1;opts.batchsize = 100;opts.momentum = 0;opts.alpha = 1;dbn = dbnsetup(dbn, train_x, opts);dbn = dbntrain(dbn, train_x, opts);%unfold dbn to nnnn = dbnunfoldtonn(dbn, 10);nn.activation_function = 'sigm';%train nnopts.numepochs = 1;opts.batchsize = 100;nn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.10, 'Too big error');```Example: Stacked Auto-Encoders---------------------```matlabfunction test_example_SAEload mnist_uint8;train_x = double(train_x)/255;test_x = double(test_x)/255;train_y = double(train_y);test_y = double(test_y);%% ex1 train a 100 hidden unit SDAE and use it to initialize a FFNN% Setup and train a stacked denoising autoencoder (SDAE)rand('state',0)sae = saesetup([784 100]);sae.ae{1}.activation_function = 'sigm';sae.ae{1}.learningRate = 1;sae.ae{1}.inputZeroMaskedFraction = 0.5;opts.numepochs = 1;opts.batchsize = 100;sae = saetrain(sae, train_x, opts);visualize(sae.ae{1}.W{1}(:,2:end)')% Use the SDAE to initialize a FFNNnn = nnsetup([784 100 10]);nn.activation_function = 'sigm';nn.learningRate = 1;nn.W{1} = sae.ae{1}.W{1};% Train the FFNNopts.numepochs = 1;opts.batchsize = 100;nn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.16, 'Too big error');```Example: Convolutional Neural Nets---------------------```matlabfunction test_example_CNNload mnist_uint8;train_x = double(reshape(train_x',28,28,60000))/255;test_x = double(reshape(test_x',28,28,10000))/255;train_y = double(train_y');test_y = double(test_y');%% ex1 Train a 6c-2s-12c-2s Convolutional neural network %will run 1 epoch in about 200 second and get around 11% error. %With 100 epochs you'll get around 1.2% errorrand('state',0)cnn.layers = { struct('type', 'i') %input layer struct('type', 'c', 'outputmaps', 6, 'kernelsize', 5) %convolution layer struct('type', 's', 'scale', 2) %sub sampling layer struct('type', 'c', 'outputmaps', 12, 'kernelsize', 5) %convolution layer struct('type', 's', 'scale', 2) %subsampling layer};cnn = cnnsetup(cnn, train_x, train_y);opts.alpha = 1;opts.batchsize = 50;opts.numepochs = 1;cnn = cnntrain(cnn, train_x, train_y, opts);[er, bad] = cnntest(cnn, test_x, test_y);%plot mean squared errorfigure; plot(cnn.rL);assert(er<0.12, 'Too big error');```Example: Neural Networks---------------------```matlabfunction test_example_NNload mnist_uint8;train_x = double(train_x) / 255;test_x = double(test_x) / 255;train_y = double(train_y);test_y = double(test_y);% normalize[train_x, mu, sigma] = zscore(train_x);test_x = normalize(test_x, mu, sigma);%% ex1 vanilla neural netrand('state',0)nn = nnsetup([784 100 10]);opts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samples[nn, L] = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.08, 'Too big error');%% ex2 neural net with L2 weight decayrand('state',0)nn = nnsetup([784 100 10]);nn.weightPenaltyL2 = 1e-4; % L2 weight decayopts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samplesnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex3 neural net with dropoutrand('state',0)nn = nnsetup([784 100 10]);nn.dropoutFraction = 0.5; % Dropout fraction opts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samplesnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex4 neural net with sigmoid activation functionrand('state',0)nn = nnsetup([784 100 10]);nn.activation_function = 'sigm'; % Sigmoid activation functionnn.learningRate = 1; % Sigm require a lower learning rateopts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samplesnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex5 plotting functionalityrand('state',0)nn = nnsetup([784 20 10]);opts.numepochs = 5; % Number of full sweeps through datann.output = 'softmax'; % use softmax outputopts.batchsize = 1000; % Take a mean gradient step over this many samplesopts.plot = 1; % enable plottingnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex6 neural net with sigmoid activation and plotting of validation and training error% split training data into training and validation datavx = train_x(1:10000,:);tx = train_x(10001:end,:);vy = train_y(1:10000,:);ty = train_y(10001:end,:);rand('state',0)nn = nnsetup([784 20 10]); nn.output = 'softmax'; % use softmax outputopts.numepochs = 5; % Number of full sweeps through dataopts.batchsize = 1000; % Take a mean gradient step over this many samplesopts.plot = 1; % enable plottingnn = nntrain(nn, tx, ty, opts, vx, vy); % nntrain takes validation set as last two arguments (optionally)[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');```[![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/rasmusbergpalm/deeplearntoolbox/trend.png)](https://bitdeli.com/free "Bitdeli Badge")

Vue源码 Vuex(二)Vuex 初始化

Vue源码 Vuex(二)Vuex 初始化Vuex 初始化安装Store 实例化初始化模块安装模块初始化 `store._vm`总结Vue源码学习目录 学习内容和文章内容来自 黄轶老师 《Vue.js2.0 源码揭秘》、 《Vue.js 3.0 核心源码解析》 这里分析的源码是Runtime + Compiler 的 Vue.js 调试代码在:node_modules\vue\dist\vue.esm.js 里添加 vue版本:Vue.js 2.5.17-beta 你越是认真生活,你的生活就会越美好—

陈坚泓的博客 1878

IDEA的使用指南

IDEA基于java的使用手册,可以最快速的上手,在使用中不断的丰富自己的使用技巧.

复分析 阿尔福斯(Ahlfors) 第三版答案 英文

答案分为十章,有部分习题没有答案,全英文复分析阿尔福斯(Ahlfors) 第三版答案

【限时开放】Seedance 2.0飞书集成企业版SDK源码解析(含3个未公开Hook接口调用示例)

快速掌握Seedance 2.0飞书机器人集成开发教程,赋能企业级应用场景。解析企业版SDK源码,覆盖审批流、多租户消息路由、自定义事件Hook等3个未公开接口调用示例,支持高并发与安全审计。值得收藏。

LogicWander的博客 324

手术导航系统C++渲染崩溃率下降92.7%的终极实践:基于ASan+UBSan+医疗专用Fuzzing框架的72小时稳定性攻坚纪实(含源码级hook注入模板)

手术导航系统C++渲染崩溃率直降92.7%,聚焦医疗C++渲染优化实战。基于ASan+UBSan内存检测与医疗专用Fuzzing框架,在72小时内完成源码级hook注入与稳定性攻坚,显著提升术中实时渲染可靠性。值得收藏

IterLoom的博客 334

MoveIt!入门教程:Ubuntu 14.04+ROS Indigo环境安装避坑指南

MoveIt是ROS中用于机械臂运动规划与抓取的核心中间件框架,其本质是一套基于插件架构的运行时系统,依赖OMPL规划器、octomap空间建模、geometric_shapes几何描述等关键组件。理解其模块化设计(如moveit_core、moveit_ros_planning、moveit_ros_visualization)和ABI兼容性约束,是解决‘Failed to load plugin’、rviz插件缺失、控制器识别失败等高频问题的技术前提。在工业机器人仿真与实机部署场景中,稳定可靠的环境配置

weixin_34293141的博客 686

V8 内部句柄机制完全解析:IndirectHandle、DirectHandle 与 HandleScope 实战指南

本指南深入剖析 V8 内部(`v8::internal`)用于在 C++ 代码中安全引用堆对象的**句柄(Handle)体系**——从经典的间接句柄(`IndirectHandle`/`Handle<T>`)到面向性能优化的直接句柄(`DirectHandle<T>`),再到管理句柄生命周期的 `HandleScope`、面向长期持有的全局句柄(`GlobalHandles`)与永久句柄(`Ete

gitblog_00136的博客 878

TencentDB Agent Memory开发贡献指南:如何参与开源项目共建?

TencentDB Agent Memory是一个团队级AI智能体记忆中枢,能够将对话、文档和代码转化为四种可重用的记忆资产(聊天记忆、技能、LLM知识库、代码图谱),这些资产可在智能体和框架之间进行管理、共享和配置。本文将为你提供完整的开发贡献指南,帮助你快速参与到这个开源项目的共建中。 ## 为什么选择贡献TencentDB Agent Memory? 作为一款创新的AI智能体记忆管理工具

gitblog_00873的博客 292

Godot场景转换开源项目实战:异步加载、内存管理与数据传递解决方案

在游戏开发中,场景转换是管理游戏流程和资源的核心机制。其原理涉及资源加载、节点生命周期管理和状态传递等关键技术点。良好的场景转换系统能提升游戏流畅度、优化内存使用并简化开发流程,对于复杂UI流程、关卡切换和资源密集型应用尤为重要。本文聚焦于Godot引擎中场景转换的开源实现方案,深入探讨异步加载与进度反馈、场景栈管理、数据传递标准化等关键问题。通过分析内存泄漏排查、过渡动画协调等常见痛点,为开发者提供工程实践层面的解决方案,帮助构建稳健高效的游戏架构。

weixin_30621711的博客 327

高效构建3D交互体验:React Three Fiber核心概念与实战指南

React Three Fiber作为Three.js的React渲染器,为前端开发者提供了声明式构建3D场景的革命性方式。这个强大的工具将Three.js的专业3D渲染能力与React的组件化开发模型完美结合,让创建复杂的3D交互应用变得更加高效和直观。无论是构建沉浸式数据可视化、交互式产品展示还是游戏原型,React Three Fiber都能显著提升开发效率和代码可维护性。 ## 🚀 从

gitblog_00075的博客 540

Godot自定义导入逻辑:从Aseprite-Wizard插件到自动化资产管道

在游戏开发中,资源导入系统是连接美术工具与游戏引擎的关键桥梁,其核心原理是通过插件化架构将外部文件格式转换为引擎可识别的内部资源。Godot的EditorImportPlugin提供了标准化的扩展接口,允许开发者针对特定文件类型定制导入行为,这一技术价值在于将重复的手动操作自动化,显著提升团队协作效率与资产一致性。Aseprite-Wizard作为流行的像素画导入插件,其内部实现了完整的Aseprite文件解析、图像合成与SpriteFrames资源构建流程,但在实际项目开发中,开发者常需根据特定命名规范自

weixin_33895657的博客 395

OhMyCaptcha多模态识别实战:OpenAI兼容视觉模型破解图片验证码原理详解

OhMyCaptcha 是一个可自托管的图片验证码破解服务,它把"看懂验证码图片"这件事交给 **OpenAI 兼容的多模态视觉模型**(如 Qwen3.5-2B)来完成——你只需提供验证码图片和问题,它返回点击坐标、滑动距离或格子索引。这篇文章带你从零看懂它的识别原理、配置方式和实战用法。 [![OhMyCaptcha 验证码求解服务概览:FastAPI 架构下通过多模态模型完成图片验证码识别

gitblog_00118的博客 835
上一篇: React Ref
下一篇: 【问题】TypeError: Cannot read properties of undefined (reading ‘getStackAddendum‘)
青菜小王子
博客等级 码龄11年 1695粉丝 126原创
评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

青菜小王子

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值