diff --git a/src/pages/components/ScriptMenuList/index.tsx b/src/pages/components/ScriptMenuList/index.tsx index 237434f8b..14a165fa7 100644 --- a/src/pages/components/ScriptMenuList/index.tsx +++ b/src/pages/components/ScriptMenuList/index.tsx @@ -208,6 +208,8 @@ const ListMenuItem = React.memo( }); }; + // console.log("Rendered - " + item.name); // 用于检查垃圾React有否过度更新 + return ( & { __length__?: number }; -type ScriptMenuEntry = ScriptMenu & { +type ScriptMenuEntryBase = ScriptMenu & { menuUpdated?: number; +}; + +// ScriptMenuEntryBase 加了 metadata 后变成 ScriptMenuEntry +type ScriptMenuEntry = ScriptMenuEntryBase & { metadata: SCMetadata; }; +const cacheMetadata = new Map(); +// 使用 WeakMap:当 ScriptMenuEntryBase 替换后,ScriptMenuEntryBase的引用会失去,ScriptMenuEntry能被自动回收。 +const cacheMergedItem = new WeakMap(); +// scriptList 更新后会合并 从异步取得的 metadata 至 mergedList +const fetchMergedList = async (item: ScriptMenuEntryBase) => { + const uuid = item.uuid; + // 检查 cacheMetadata 有没有记录 + let metadata = cacheMetadata.get(uuid); + if (!metadata) { + // 如没有记录,对 scriptDAO 发出请求 (通常在首次React元件绘画时进行) + const script = await scriptDAO.get(uuid); + metadata = script?.metadata || {}; // 即使 scriptDAO 返回失败也 fallback 一个空物件 + cacheMetadata.set(uuid, metadata); + } + // 检查 cacheMergedItem 有没有记录 + let merged = cacheMergedItem.get(item); + if (!merged || merged.uuid !== item.uuid) { + // 如没有记录或记录不正确,则重新生成记录 (新物件参考) + merged = { ...item, metadata }; + cacheMergedItem.set(item, merged); + } + // 如 cacheMergedItem 的记录中的 metadata 跟 (新)metadata 物件参考不一致,则更新 merged + if (merged.metadata !== metadata) { + // 新物件参考触发 React UI 重绘 + merged = { ...merged, metadata: metadata }; + cacheMergedItem.set(item, merged); + } + return merged; +}; + // Popup 页面使用的脚本/选单清单元件:只负责渲染与互动,状态与持久化交由外部 client 处理。 const ScriptMenuList = React.memo( ({ @@ -337,9 +373,7 @@ const ScriptMenuList = React.memo( currentUrl, menuExpandNum, }: { - script: (ScriptMenu & { - menuUpdated?: number; - })[]; + script: ScriptMenuEntryBase[]; isBackscript: boolean; currentUrl: string; menuExpandNum: number; @@ -406,34 +440,18 @@ const ScriptMenuList = React.memo( return url; }, [currentUrl]); - const cache = useMemo(() => new Map(), []); - // 以 异步方式 取得 metadata 放入 extraData - // script 或 extraData 的更新时都会再次执行 useEffect(() => { let isMounted = true; - // 先从 cache 读取,避免重复请求相同 uuid 的 metadata - Promise.all( - script.map(async (item) => { - let metadata = cache.get(item.uuid); - if (!metadata) { - const script = await scriptDAO.get(item.uuid); - if (script) { - metadata = script.metadata || {}; - } - cache.set(item.uuid, metadata); - } - return { ...item, metadata: metadata || {} }; - }) - ).then((newScriptMenuList) => { + Promise.all(script.map(fetchMergedList)).then((newList) => { if (!isMounted) { return; } - updateScriptMenuList(newScriptMenuList); + updateScriptMenuList(newList); }); return () => { isMounted = false; }; - }, [cache, script]); + }, [script]); useEffect(() => { // 注册菜单快速键(accessKey):以各分组第一个项目的 accessKey 作为触发条件。 diff --git a/src/pages/components/ScriptSetting/index.tsx b/src/pages/components/ScriptSetting/index.tsx index e5eef9372..495caaea1 100644 --- a/src/pages/components/ScriptSetting/index.tsx +++ b/src/pages/components/ScriptSetting/index.tsx @@ -94,7 +94,7 @@ const ScriptSetting: React.FC<{ }, ]; return ret; - }, [script, scriptRunEnv, scriptRunAt, t]); + }, [script.uuid, scriptRunEnv, scriptRunAt, t]); useEffect(() => { const scriptDAO = new ScriptDAO(); diff --git a/src/pages/popup/App.tsx b/src/pages/popup/App.tsx index 901ded12e..bd794c49c 100644 --- a/src/pages/popup/App.tsx +++ b/src/pages/popup/App.tsx @@ -41,6 +41,32 @@ const scriptListSorter = (a: ScriptMenu, b: ScriptMenu) => b.runNum - a.runNum || b.updatetime - a.updatetime; +type TUpdateEntryFn = (item: ScriptMenu) => ScriptMenu | undefined; + +type TUpdateListOption = { sort?: boolean }; + +const updateList = (list: ScriptMenu[], update: TUpdateEntryFn, options: TUpdateListOption | undefined) => { + // 如果更新跟当前 list 的子项无关,则不用更改 list 的物件参考 + const newList = []; + let changed = false; + for (let i = 0; i < list.length; i++) { + const oldItem = list[i]; + const newItem = update(oldItem); // 如没有更改,物件参考会保持一致 + if (newItem !== oldItem) changed = true; + if (newItem) { + newList.push(newItem); + } + } + if (options?.sort) { + newList.sort(scriptListSorter); + } + if (!changed && list.map((e) => e.uuid).join(",") !== newList.map((e) => e.uuid).join(",")) { + // 单一项未有改变,但因为 sort值改变 而改变了次序 + changed = true; + } + return changed ? newList : list; // 如子项没任何变化,则返回原list参考 +}; + function App() { const [loading, setLoading] = useState(true); const [scriptList, setScriptList] = useState<(ScriptMenu & { menuUpdated?: number })[]>([]); @@ -59,23 +85,48 @@ function App() { const { t } = useTranslation(); const pageTabIdRef = useRef(0); + // ------------------------------ 重要! 不要隨便更改 ------------------------------ + // > scriptList 會隨著 (( 任何 )) 子項狀態更新而進行物件參考更新 + // > (( 必須 )) 把物件參考更新切換成 原始类型(例如字串) + + // normalEnables: 只随 script 数量和启动状态而改变的state + // 故意生成一个字串 memo 避免因 scriptList 的参考频繁改动而导致 normalScriptCounts 的物件参考出现非预期更改。 + const normalEnables = useMemo(() => { + // 返回字串让 React 比对 state 有否改动 + return scriptList.map((script) => (script.enable ? 1 : 0)).join(","); + }, [scriptList]); + + // backEnables: 只随 script 数量和启动状态而改变的state + // 故意生成一个字串 memo 避免因 scriptList 的参考频繁改动而导致 backScriptCounts 的物件参考出现非预期更改。 + const backEnables = useMemo(() => { + // 返回字串让 React 比对 state 有否改动 + return backScriptList.map((script) => (script.enable ? 1 : 0)).join(","); + }, [backScriptList]); + // ------------------------------ 重要! 不要隨便更改 ------------------------------ + + // normalScriptCounts 的物件參考只會隨 原始类型(字串)的 normalEnables 狀態更新而重新生成 const normalScriptCounts = useMemo(() => { + // 拆回array + const enables = normalEnables.split(",").filter(Boolean); // 计算已开启了的数量 - const running = scriptList.reduce((p, c) => p + (c.enable ? 1 : 0), 0); + const running = enables.reduce((p, c) => p + (+c ? 1 : 0), 0); return { running, - total: scriptList.length, // 总数 + total: enables.length, // 总数 }; - }, [scriptList]); + }, [normalEnables]); + // backScriptCounts 的物件參考只會隨 原始类型(字串)的 backEnables 狀態更新而重新生成 const backScriptCounts = useMemo(() => { + // 拆回array + const enables = backEnables.split(",").filter(Boolean); // 计算已开启了的数量 - const running = backScriptList.reduce((p, c) => p + (c.enable ? 1 : 0), 0); + const running = enables.reduce((p, c) => p + (+c ? 1 : 0), 0); return { running, - total: backScriptList.length, // 总数 + total: enables.length, // 总数 }; - }, [backScriptList]); + }, [backEnables]); const urlHost = useMemo(() => { let url: URL | undefined; @@ -91,31 +142,10 @@ function App() { useEffect(() => { let isMounted = true; - const updateScriptList = ( - update: (item: ScriptMenu) => ScriptMenu | undefined, - options?: { - sort?: boolean; - } - ) => { - const updateList = (list: ScriptMenu[], update: (item: ScriptMenu) => ScriptMenu | undefined) => { - const newList = []; - for (let i = 0; i < list.length; i++) { - const newItem = update(list[i]); - if (newItem) { - newList.push(newItem); - } - } - if (options?.sort) { - newList.sort(scriptListSorter); - } - return newList; - }; - setScriptList((prev) => { - return updateList(prev, update); - }); - setBackScriptList((prev) => { - return updateList(prev, update); - }); + const updateScriptList = (update: TUpdateEntryFn, options?: TUpdateListOption) => { + // 当 启用/禁用/菜单改变 时,如有必要则更新 list 参考 + setScriptList((prev) => updateList(prev, update, options)); + setBackScriptList((prev) => updateList(prev, update, options)); }; const unhooks = [