瀏覽代碼

perf(dashboard-v6): 字典输入补全增加 300ms 防抖与竞态防护

visuddhinanda 2 周之前
父節點
當前提交
1c3beae1ba

+ 23 - 11
dashboard-v6/src/components/dict/SearchVocabulary.tsx

@@ -3,6 +3,7 @@ import type { IVocabularyListResponse } from "../../api/dict";
 import { useEffect, useRef, useState } from "react";
 import { useEffect, useRef, useState } from "react";
 import { AutoComplete, Input, Space, Typography } from "antd";
 import { AutoComplete, Input, Space, Typography } from "antd";
 import { DictIcon } from "../../assets/icon";
 import { DictIcon } from "../../assets/icon";
+import { useDebouncedCallback } from "./hooks/useDebouncedCallback";
 
 
 const { Text, Link } = Typography;
 const { Text, Link } = Typography;
 
 
@@ -30,7 +31,8 @@ const SearchVocabulary = ({
   const [fetching, setFetching] = useState(false);
   const [fetching, setFetching] = useState(false);
   const [input, setInput] = useState<string | undefined>(value);
   const [input, setInput] = useState<string | undefined>(value);
   const [factors, setFactors] = useState<string[]>([]);
   const [factors, setFactors] = useState<string[]>([]);
-  const intervalRef = useRef<number | null>(null);
+  // 请求序号:用于竞态防护,只保留最新一次请求的结果
+  const seqRef = useRef(0);
 
 
   // 外部 value(点击单词触发的查词)变化时,同步输入框显示。
   // 外部 value(点击单词触发的查词)变化时,同步输入框显示。
   // 仅在 value 真正变化时同步,用户手动输入时 value 不会变,因此不会打断输入。
   // 仅在 value 真正变化时同步,用户手动输入时 value 不会变,因此不会打断输入。
@@ -55,13 +57,6 @@ const SearchVocabulary = ({
     ),
     ),
   });
   });
 
 
-  const stopLookup = () => {
-    if (intervalRef.current) {
-      window.clearInterval(intervalRef.current);
-      intervalRef.current = null;
-    }
-  };
-
   const factorChange = (word?: string) => {
   const factorChange = (word?: string) => {
     if (typeof word === "undefined" || word.includes(":")) {
     if (typeof word === "undefined" || word.includes(":")) {
       setFactors([]);
       setFactors([]);
@@ -78,11 +73,14 @@ const SearchVocabulary = ({
   };
   };
 
 
   const search = (value: string) => {
   const search = (value: string) => {
-    stopLookup();
     if (value === "") return;
     if (value === "") return;
 
 
+    // 记录本次请求序号,响应回来时若已不是最新序号则丢弃(竞态防护)
+    const seq = ++seqRef.current;
+
     get<IVocabularyListResponse>(`/api/v2/${api}?view=key&key=${value}`)
     get<IVocabularyListResponse>(`/api/v2/${api}?view=key&key=${value}`)
       .then((json) => {
       .then((json) => {
+        if (seq !== seqRef.current) return;
         const words: ValueType[] = json.data.rows
         const words: ValueType[] = json.data.rows
           .map((item) => {
           .map((item) => {
             let weight = item.count / (item.strlen - value.length + 0.1);
             let weight = item.count / (item.strlen - value.length + 0.1);
@@ -103,10 +101,16 @@ const SearchVocabulary = ({
         setOptions(words);
         setOptions(words);
       })
       })
       .finally(() => {
       .finally(() => {
-        setFetching(false);
+        if (seq === seqRef.current) {
+          setFetching(false);
+        }
       });
       });
   };
   };
 
 
+  // 输入补全防抖:连续输入 300ms 内只发最后一次请求
+  const { debounced: debouncedSearch, cancel: cancelSearch } =
+    useDebouncedCallback(search, 300);
+
   return (
   return (
     <div style={{ width: "100%" }}>
     <div style={{ width: "100%" }}>
       {fetching ? <></> : null}
       {fetching ? <></> : null}
@@ -125,8 +129,16 @@ const SearchVocabulary = ({
         }}
         }}
         showSearch={{
         showSearch={{
           onSearch: (val: string) => {
           onSearch: (val: string) => {
+            if (val === "") {
+              // 空输入:立即取消挂起请求,并使在途请求失效,清空建议
+              seqRef.current++;
+              cancelSearch();
+              setOptions([]);
+              setFetching(false);
+              return;
+            }
             setFetching(true);
             setFetching(true);
-            search(val);
+            debouncedSearch(val);
           },
           },
         }}
         }}
         onSelect={(val: string) => {
         onSelect={(val: string) => {

+ 116 - 0
dashboard-v6/src/components/dict/hooks/useDebouncedCallback.ts

@@ -0,0 +1,116 @@
+// hooks/useDebouncedCallback.ts
+// 通用防抖回调 Hook。
+// 默认尾沿触发(trailing):连续调用时只有最后一次调用会真正执行;
+// 提供 leading 立即触发、cancel 取消挂起调用、flush 立即执行挂起调用。
+// debounced 返回引用稳定的函数,内部通过 ref 读取最新 callback,避免过期闭包。
+
+import { useCallback, useEffect, useRef } from "react";
+
+export interface IDebounceOptions {
+  /** 连续调用的第一次是否立即触发,默认 false */
+  leading?: boolean;
+  /** 静默期结束后是否触发最后一次,默认 true */
+  trailing?: boolean;
+}
+
+export interface IDebouncedCallback<Args extends unknown[]> {
+  /** 防抖后的函数(引用稳定) */
+  debounced: (...args: Args) => void;
+  /** 取消挂起的调用 */
+  cancel: () => void;
+  /** 立即执行当前挂起的调用 */
+  flush: () => void;
+}
+
+export function useDebouncedCallback<Args extends unknown[]>(
+  callback: (...args: Args) => void,
+  delay: number,
+  options: IDebounceOptions = {}
+): IDebouncedCallback<Args> {
+  const { leading = false, trailing = true } = options;
+
+  // 用 ref 保存最新值,debounced 内部通过 ref 读取,避免持有过期闭包
+  const callbackRef = useRef(callback);
+  const delayRef = useRef(delay);
+  const leadingRef = useRef(leading);
+  const trailingRef = useRef(trailing);
+
+  useEffect(() => {
+    callbackRef.current = callback;
+    delayRef.current = delay;
+    leadingRef.current = leading;
+    trailingRef.current = trailing;
+  });
+
+  const timerRef = useRef<number | null>(null);
+  const lastArgsRef = useRef<Args | null>(null);
+  // 上一次真正执行的时刻,用于 leading 的节流判断。
+  // 初始为 0:首调用时 time - 0 远大于 delay,视为可 leading。
+  const lastInvokeTimeRef = useRef(0);
+
+  const invoke = useCallback((time: number) => {
+    if (lastArgsRef.current === null) return;
+    const args = lastArgsRef.current;
+    lastArgsRef.current = null;
+    lastInvokeTimeRef.current = time;
+    callbackRef.current(...args);
+  }, []);
+
+  const cancel = useCallback(() => {
+    if (timerRef.current !== null) {
+      window.clearTimeout(timerRef.current);
+      timerRef.current = null;
+    }
+    lastArgsRef.current = null;
+  }, []);
+
+  const flush = useCallback(() => {
+    if (timerRef.current === null) return;
+    window.clearTimeout(timerRef.current);
+    timerRef.current = null;
+    invoke(Date.now());
+  }, [invoke]);
+
+  const debounced = useCallback(
+    (...args: Args) => {
+      const time = Date.now();
+      lastArgsRef.current = args;
+
+      // 距上次执行已超过 delay,且开启 leading 时,立即执行一次
+      const canLeading =
+        leadingRef.current &&
+        time - lastInvokeTimeRef.current >= delayRef.current;
+
+      // 清除旧的挂起定时器,重新计时(尾沿触发)
+      if (timerRef.current !== null) {
+        window.clearTimeout(timerRef.current);
+        timerRef.current = null;
+      }
+
+      if (canLeading) {
+        invoke(time);
+        // 仅 leading:不排尾沿定时器,直接返回
+        if (!trailingRef.current) return;
+      }
+
+      timerRef.current = window.setTimeout(() => {
+        timerRef.current = null;
+        if (trailingRef.current && lastArgsRef.current !== null) {
+          invoke(Date.now());
+        }
+      }, delayRef.current);
+    },
+    [invoke]
+  );
+
+  // 卸载时清理定时器,避免对已卸载组件 setState
+  useEffect(() => {
+    return () => {
+      if (timerRef.current !== null) {
+        window.clearTimeout(timerRef.current);
+      }
+    };
+  }, []);
+
+  return { debounced, cancel, flush };
+}