From d4a47f814ab1fa86ef51ad5f3c46b72a11e23d47 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 1 Feb 2026 15:34:48 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=E4=BF=AE=E6=AD=A3React=E9=87=8D=E7=B9=AA?= =?UTF-8?q?=E5=95=8F=E9=A1=8C=20=EF=BC=88Popup=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/components/ScriptMenuList/index.tsx | 46 +++++++--- src/pages/components/ScriptSetting/index.tsx | 2 +- src/pages/popup/App.tsx | 86 +++++++++++++------ 3 files changed, 95 insertions(+), 39 deletions(-) diff --git a/src/pages/components/ScriptMenuList/index.tsx b/src/pages/components/ScriptMenuList/index.tsx index 237434f8b..14b984163 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; }; @@ -337,9 +343,7 @@ const ScriptMenuList = React.memo( currentUrl, menuExpandNum, }: { - script: (ScriptMenu & { - menuUpdated?: number; - })[]; + script: ScriptMenuEntryBase[]; isBackscript: boolean; currentUrl: string; menuExpandNum: number; @@ -406,7 +410,9 @@ const ScriptMenuList = React.memo( return url; }, [currentUrl]); - const cache = useMemo(() => new Map(), []); + const cacheMetadata = useMemo(() => new Map(), []); + // 使用 WeakMap:当 ScriptMenuEntryBase 替换后,ScriptMenuEntryBase的引用会失去,ScriptMenuEntry能被自动回收。 + const cacheMergedItem = useMemo(() => new WeakMap(), []); // 以 异步方式 取得 metadata 放入 extraData // script 或 extraData 的更新时都会再次执行 useEffect(() => { @@ -414,15 +420,29 @@ const ScriptMenuList = React.memo( // 先从 cache 读取,避免重复请求相同 uuid 的 metadata Promise.all( script.map(async (item) => { - let metadata = cache.get(item.uuid); + const uuid = item.uuid; + // 检查 cacheMetadata 有没有记录 + let metadata = cacheMetadata.get(uuid); if (!metadata) { - const script = await scriptDAO.get(item.uuid); - if (script) { - metadata = script.metadata || {}; - } - cache.set(item.uuid, 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 { ...item, metadata: metadata || {} }; + return merged; }) ).then((newScriptMenuList) => { if (!isMounted) { @@ -433,7 +453,7 @@ const ScriptMenuList = React.memo( return () => { isMounted = false; }; - }, [cache, script]); + }, [cacheMetadata, 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..03a12d3d5 100644 --- a/src/pages/popup/App.tsx +++ b/src/pages/popup/App.tsx @@ -59,23 +59,45 @@ function App() { const { t } = useTranslation(); const pageTabIdRef = useRef(0); + // ------------------------------ 重要! 不要隨便更改 ------------------------------ + // > scriptList 會隨著 (( 任何 )) 子項狀態更新而進行物件參考更新 + // > (( 必須 )) 把物件參考更新切換成 原始类型(例如字串) + // normalEnables: 只随 script 数量和启动状态而改变的state + const normalEnables = useMemo(() => { + // 返回字串让 React 比对 state 有否改动 + return scriptList.map((script) => (script.enable ? 1 : 0)).join(","); + }, [scriptList]); + + // backEnables: 只随 script 数量和启动状态而改变的state + 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; @@ -87,6 +109,36 @@ function App() { return url?.hostname ?? ""; }, [currentUrl]); + const updateList = ( + list: ScriptMenu[], + update: (item: ScriptMenu) => ScriptMenu | undefined, + options: + | { + sort?: boolean; + } + | 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参考 + }; + const { subscribeMessage } = useAppContext(); useEffect(() => { let isMounted = true; @@ -97,25 +149,9 @@ function App() { 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); - }); + // 当 启用/禁用/菜单改变 时,如有必要则更新 list 参考 + setScriptList((prev) => updateList(prev, update, options)); + setBackScriptList((prev) => updateList(prev, update, options)); }; const unhooks = [ From 1847377370b57bab2232e5540023ab40b8e1a642 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:20:34 +0900 Subject: [PATCH 2/7] =?UTF-8?q?debug=20log=20=E7=94=A8=E4=BA=8E=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E5=9E=83=E5=9C=BEReact=E6=9C=89=E5=90=A6=E8=BF=87?= =?UTF-8?q?=E5=BA=A6=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/components/ScriptMenuList/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/components/ScriptMenuList/index.tsx b/src/pages/components/ScriptMenuList/index.tsx index 14b984163..fdf9c0435 100644 --- a/src/pages/components/ScriptMenuList/index.tsx +++ b/src/pages/components/ScriptMenuList/index.tsx @@ -208,7 +208,7 @@ const ListMenuItem = React.memo( }); }; - console.log("Rendered - " + item.name); // 用于检查垃圾React有否过度更新 + // console.log("Rendered - " + item.name); // 用于检查垃圾React有否过度更新 return ( Date: Sun, 1 Feb 2026 18:36:04 +0900 Subject: [PATCH 3/7] Keeps exhaustive-deps happy --- src/pages/components/ScriptMenuList/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/components/ScriptMenuList/index.tsx b/src/pages/components/ScriptMenuList/index.tsx index fdf9c0435..3f7987a2c 100644 --- a/src/pages/components/ScriptMenuList/index.tsx +++ b/src/pages/components/ScriptMenuList/index.tsx @@ -453,7 +453,7 @@ const ScriptMenuList = React.memo( return () => { isMounted = false; }; - }, [cacheMetadata, script]); + }, [script, cacheMetadata, cacheMergedItem]); useEffect(() => { // 注册菜单快速键(accessKey):以各分组第一个项目的 accessKey 作为触发条件。 From 366f79e86d36cb92d33d1083b8e70ed22f6b62b9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:49:47 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=E6=8A=8A=E7=8A=B6=E6=80=81=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E9=80=BB=E8=BE=91=E4=BB=8EReact=E5=85=83=E4=BB=B6?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E4=B8=AD=E6=8A=BD=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/components/ScriptMenuList/index.tsx | 69 +++++++++---------- 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/src/pages/components/ScriptMenuList/index.tsx b/src/pages/components/ScriptMenuList/index.tsx index 3f7987a2c..0ee82e32c 100644 --- a/src/pages/components/ScriptMenuList/index.tsx +++ b/src/pages/components/ScriptMenuList/index.tsx @@ -335,6 +335,37 @@ type ScriptMenuEntry = ScriptMenuEntryBase & { metadata: SCMetadata; }; +const cacheMetadata = new Map(); +// 使用 WeakMap:当 ScriptMenuEntryBase 替换后,ScriptMenuEntryBase的引用会失去,ScriptMenuEntry能被自动回收。 +const cacheMergedItem = new WeakMap(); +// 以 异步方式 取得 metadata 放入 extraData +// script 或 extraData 的更新时都会再次执行 +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( ({ @@ -410,50 +441,18 @@ const ScriptMenuList = React.memo( return url; }, [currentUrl]); - const cacheMetadata = useMemo(() => new Map(), []); - // 使用 WeakMap:当 ScriptMenuEntryBase 替换后,ScriptMenuEntryBase的引用会失去,ScriptMenuEntry能被自动回收。 - const cacheMergedItem = useMemo(() => new WeakMap(), []); - // 以 异步方式 取得 metadata 放入 extraData - // script 或 extraData 的更新时都会再次执行 useEffect(() => { let isMounted = true; - // 先从 cache 读取,避免重复请求相同 uuid 的 metadata - Promise.all( - script.map(async (item) => { - 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; - }) - ).then((newScriptMenuList) => { + Promise.all(script.map(fetchMergedList)).then((newList) => { if (!isMounted) { return; } - updateScriptMenuList(newScriptMenuList); + updateScriptMenuList(newList); }); return () => { isMounted = false; }; - }, [script, cacheMetadata, cacheMergedItem]); + }, [script]); useEffect(() => { // 注册菜单快速键(accessKey):以各分组第一个项目的 accessKey 作为触发条件。 From 9a5b23c795a7af312b4e6f946b10e2bfa213b2d1 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:53:12 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=E6=B3=A8=E9=87=8A=E4=BF=AE=E8=AE=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/components/ScriptMenuList/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/components/ScriptMenuList/index.tsx b/src/pages/components/ScriptMenuList/index.tsx index 0ee82e32c..14a165fa7 100644 --- a/src/pages/components/ScriptMenuList/index.tsx +++ b/src/pages/components/ScriptMenuList/index.tsx @@ -338,8 +338,7 @@ type ScriptMenuEntry = ScriptMenuEntryBase & { const cacheMetadata = new Map(); // 使用 WeakMap:当 ScriptMenuEntryBase 替换后,ScriptMenuEntryBase的引用会失去,ScriptMenuEntry能被自动回收。 const cacheMergedItem = new WeakMap(); -// 以 异步方式 取得 metadata 放入 extraData -// script 或 extraData 的更新时都会再次执行 +// scriptList 更新后会合并 从异步取得的 metadata 至 mergedList const fetchMergedList = async (item: ScriptMenuEntryBase) => { const uuid = item.uuid; // 检查 cacheMetadata 有没有记录 From 2897ae50a04954ee4785854fbb8cea8559b16a5a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:00:23 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=E6=8A=8A=E7=8A=B6=E6=80=81=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E9=80=BB=E8=BE=91=E4=BB=8EReact=E5=85=83=E4=BB=B6?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E4=B8=AD=E6=8A=BD=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/popup/App.tsx | 63 ++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 36 deletions(-) diff --git a/src/pages/popup/App.tsx b/src/pages/popup/App.tsx index 03a12d3d5..219fb7658 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 })[]>([]); @@ -109,46 +135,11 @@ function App() { return url?.hostname ?? ""; }, [currentUrl]); - const updateList = ( - list: ScriptMenu[], - update: (item: ScriptMenu) => ScriptMenu | undefined, - options: - | { - sort?: boolean; - } - | 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参考 - }; - const { subscribeMessage } = useAppContext(); useEffect(() => { let isMounted = true; - const updateScriptList = ( - update: (item: ScriptMenu) => ScriptMenu | undefined, - options?: { - sort?: boolean; - } - ) => { + const updateScriptList = (update: TUpdateEntryFn, options?: TUpdateListOption) => { // 当 启用/禁用/菜单改变 时,如有必要则更新 list 参考 setScriptList((prev) => updateList(prev, update, options)); setBackScriptList((prev) => updateList(prev, update, options)); From 7aa9c298cafbcf1f99890dce2b34ffadf262dd75 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:52:27 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=E5=8A=A0=E6=B3=A8=E9=87=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/popup/App.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/popup/App.tsx b/src/pages/popup/App.tsx index 219fb7658..bd794c49c 100644 --- a/src/pages/popup/App.tsx +++ b/src/pages/popup/App.tsx @@ -88,13 +88,16 @@ function App() { // ------------------------------ 重要! 不要隨便更改 ------------------------------ // > 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(",");