Skip to content
62 changes: 39 additions & 23 deletions src/pages/components/ScriptMenuList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,11 +324,45 @@ ListMenuItem.displayName = "ListMenuItem";

type TGrouppedMenus = Record<string, GroupScriptMenuItemsProp> & { __length__?: number };

type ScriptMenuEntry = ScriptMenu & {
type ScriptMenuEntryBase = ScriptMenu & {
menuUpdated?: number;
};

// ScriptMenuEntryBase 加了 metadata 后变成 ScriptMenuEntry
type ScriptMenuEntry = ScriptMenuEntryBase & {
metadata: SCMetadata;
};

const cacheMetadata = new Map<string, SCMetadata | undefined>();
// 使用 WeakMap:当 ScriptMenuEntryBase 替换后,ScriptMenuEntryBase的引用会失去,ScriptMenuEntry能被自动回收。
const cacheMergedItem = new WeakMap<ScriptMenuEntryBase, ScriptMenuEntry>();
// 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);
Comment on lines +347 to +348
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchMergedListscriptDAO.get(uuid) 读不到脚本时会把 metadata 回退成 {} 并写入 cacheMetadata,导致后续即使数据恢复也不会再尝试读取真实 metadata(缓存被“永久”填充)。建议只在成功读取到 script 时才写入缓存,或缓存 undefined 以允许后续重试/失效刷新。

Suggested change
metadata = script?.metadata || {}; // 即使 scriptDAO 返回失败也 fallback 一个空物件
cacheMetadata.set(uuid, metadata);
if (script) {
// 只有在成功读取到脚本时才写入缓存
metadata = script.metadata || {};
cacheMetadata.set(uuid, metadata);
} else {
// 读取失败时只在当前调用中使用空物件,不写入缓存,允许后续重试
metadata = {};
}

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

对。如果真的发生读取失败,就不要再尝试
不然这个物件参考会改不停

}
// 检查 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(
({
Expand All @@ -337,9 +371,7 @@ const ScriptMenuList = React.memo(
currentUrl,
menuExpandNum,
}: {
script: (ScriptMenu & {
menuUpdated?: number;
})[];
script: ScriptMenuEntryBase[];
isBackscript: boolean;
currentUrl: string;
menuExpandNum: number;
Expand Down Expand Up @@ -406,34 +438,18 @@ const ScriptMenuList = React.memo(
return url;
}, [currentUrl]);

const cache = useMemo(() => new Map<string, SCMetadata | undefined>(), []);
// 以 异步方式 取得 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 作为触发条件。
Expand Down
2 changes: 1 addition & 1 deletion src/pages/components/ScriptSetting/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ const ScriptSetting: React.FC<{
},
];
return ret;
}, [script, scriptRunEnv, scriptRunAt, t]);
}, [script.uuid, scriptRunEnv, scriptRunAt, t]);

useEffect(() => {
const scriptDAO = new ScriptDAO();
Expand Down
92 changes: 61 additions & 31 deletions src/pages/popup/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: ScriptMenu[] = [];
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;
Comment on lines +63 to +65
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list.map(...).join(",") !== newList.map(...).join(",") 这段在 !changed 时每次都会做两次 O(n) 的遍历/拼接,即使没有开启 sort 也会执行,可能抵消这里想减少重绘的优化效果。建议仅在 options?.sort 为 true(且确实需要判断排序变化)时再做该比较,或用更轻量的顺序比较方式。

Suggested change
if (!changed && list.map((e) => e.uuid).join(",") !== newList.map((e) => e.uuid).join(",")) {
// 单一项未有改变,但因为 sort值改变 而改变了次序
changed = true;
if (!changed && options?.sort) {
// 单一项未有改变,但因为 sort值改变 而改变了次序
if (list.length !== newList.length) {
changed = true;
} else {
for (let i = 0; i < list.length; i++) {
if (list[i].uuid !== newList[i].uuid) {
changed = true;
break;
}
}
}

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

@cyfung1031 cyfung1031 Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

太长了。这个 micro optimization 不值得
另外这个对比在 前跟后的sort值不一致 的情况都会起作用

}
return changed ? newList : list; // 如子项没任何变化,则返回原list参考
};
Comment on lines 48 to 68
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里新增了 updateList(决定是否复用旧 list 引用、以及排序/删除逻辑),属于影响渲染与状态更新的关键路径;当前 tests/pages/popup/App.test.tsx 只覆盖了基础渲染,未覆盖“无变更时必须保持引用不变 / sort 时顺序变化需触发更新”等场景。建议补充针对该更新策略的测试(可考虑抽到独立 util 后做纯函数单测),避免后续优化回归。

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

???


function App() {
const [loading, setLoading] = useState(true);
const [scriptList, setScriptList] = useState<(ScriptMenu & { menuUpdated?: number })[]>([]);
Expand All @@ -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;
Expand All @@ -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 = [
Expand Down
Loading