优化脚本加载缓存并修复 Popup 菜单残留#1511
Conversation
- Severity: Low
- Status: Open
- Details: `getDisabledMatcher()` calls `stackAsyncTask("runtime_disabled_matcher", ...)` with a hardcoded string key. The `stacks` map in `async_queue.ts` is module-global. In production (single SW process, one RuntimeService), this is harmless. In tests that run concurrent `describe` blocks, multiple RuntimeService instances share this queue — their tasks are serialized unnecessarily. Correctness is maintained (each task closes over its own `this`), but cross-instance queue coupling is a latent fragility and harms parallel test throughput.
- Required action: Replace the hard-coded key with a per-instance unique string (e.g. `this.instanceId` set in the constructor via `crypto.randomUUID()` or a counter, used as `runtime_disabled_matcher:${this.instanceId}`).
- Severity: Low
- Status: Open
- Details: `runtime.ts` line 1151 (the JSDoc block above `buildDisabledMatcher`): "require disabledMatcherVersion **intergrity** check." is a misspelling of "integrity".
- Required action: Fix the typo.
- Severity: Note
- Status: Open
- Details: `updateSorter()` always calls `invalidateDisabledMatcher()` internally. In the `deleteScripts` listener (runtime.ts ~line 583), the explicit `this.invalidateDisabledMatcher()` before `this.updateSorter(...)` is redundant because both calls happen synchronously before any `await`. (By contrast, in `installScript` the explicit early call is necessary because `updateSorter` is deferred until after an `await scriptDAO.get()`.) The redundancy is harmless but slightly misleading about why the pattern is used.
- Required action: None strictly required. A short inline comment distinguishing the redundant-but-defensive call from the necessary early-call pattern (as in installScript) would help readers understand intent. Acceptable to leave as-is.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
我用了手上最强的 skills 组合也找不出什么问题。实际测试也持续中。 |
|
用 Claude 也再看了一次。结论也是全部正向。我先 mark 这个PR为可以review 的。 逐行代码变更详解
一、
|
| 类型 | 用途 |
|---|---|
TCodeCache |
缓存「解析后的代码 + metadata + userConfig」,键 cacheKey |
TRuntimeResource |
Resource 的变体,base64 变可选(有 content 时删 base64 省内存) |
TLocalResourceCache |
记录 file:/// 本地资源的 url/type/sha512,用于每次刷新比对 |
TPageLoadScriptCache |
整条「页面加载用脚本信息」缓存:代码、metadata、资源、本地资源清单、匹配模式 |
- 为什么:后面要做「页面加载缓存」,这些类型把缓存里到底存什么、能省什么(如
base64)说清楚。✅
5.2 字段变化(约 80–93 行)
scriptMatchEnable: UrlMatch<string> = new UrlMatch<string>();
- scriptMatchDisable: UrlMatch<string> = new UrlMatch<string>();
blackMatch: UrlMatch<string> = new UrlMatch<string>();
+ private readonly disabledMatcherTaskKey = `runtime_disabled_matcher:${Math.random()}`;
+ private disabledMatcher: UrlMatch<string> | null = null;
+ private disabledMatcherVersion = 0;
+ private sorter: Record<string, number> = {};
+ private readonly codeCacheMap = new Map<string, TCodeCache>();
+ private readonly pageLoadCaches = new Map<string, TPageLoadScriptCache>();
+ private readonly cachedPatterns = new Map<string, {...}>();- 删掉
scriptMatchDisable:这是整个重构的核心。禁用的脚本只有 Popup 需要(显示成灰色菜单项),页面注入路径根本不看它。所以不再在 Service Worker 里常驻一份禁用匹配器。 - 新增:
disabledMatcher/disabledMatcherVersion/disabledMatcherTaskKey:禁用脚本改为「Popup 用时才懒构建 + 缓存 + 版本号防陈旧」。sorter:集中保存排序权重(之前散落,没有统一来源)。codeCacheMap/pageLoadCaches/cachedPatterns:三层缓存,避免每次页面加载都重读数据库、重新解析。
- 小建议:
disabledMatcherTaskKey用Math.random()做实例隔离,理论上有碰撞概率;用模块级自增计数器更稳、也更好测。(polish)
5.3 新增一组小工具方法(约 102–135 行)
private getOriginalMatchUuid(uuid) { return `${uuid}${ORIGINAL_URLMATCH_SUFFIX}`; }
private invalidateDisabledMatcher() { if (++this.disabledMatcherVersion > 1e9) this.disabledMatcherVersion = 1; this.disabledMatcher = null; }
private deleteScriptRuntimeCache(uuid) { this.pageLoadCaches.delete(uuid); this.codeCacheMap.delete(uuid); this.cachedPatterns.delete(uuid); }
private updateSorter(mutate) { const next = {...this.sorter}; mutate(next); this.sorter = next; this.scriptMatchEnable.setupSorter(next); this.invalidateDisabledMatcher(); }
private setScriptSort(next, script) { next[script.uuid] = script.sort; next[this.getOriginalMatchUuid(script.uuid)] = script.sort; }
private deleteScriptSort(next, uuid) { delete next[uuid]; delete next[this.getOriginalMatchUuid(uuid)]; }getOriginalMatchUuid:把散落各处的`${uuid}${ORIGINAL_URLMATCH_SUFFIX}`收敛成一个方法。invalidateDisabledMatcher:让懒构建的禁用匹配器失效(版本号 +1 并置空)。版本号有个>1e9防溢出的兜底(实际上 SW 生命周期内到不了,属冗余分支)。deleteScriptRuntimeCache:一次性清掉某脚本的三层缓存。updateSorter是关键:- 它克隆出新对象
{...this.sorter}再改,而不是原地改。 - 为什么:
UrlMatch.setupSorter(见match.ts:83)只有当传入对象引用变化时才会清空 URL 结果缓存。原地改对象 → 引用没变 → 旧的匹配顺序缓存不刷新 → 排序错乱。所以必须「换对象」。 - 同时它会调用
invalidateDisabledMatcher(),因为排序也影响禁用匹配器。
- 它克隆出新对象
setScriptSort/deleteScriptSort:同时维护主 uuid 和{uuid}{Ori}(自定义排除产生的原始匹配键) 的排序权重。- 结论:这组方法把「易错的重复逻辑」收敛成单一入口,是正面重构。✅
5.4 waitInit 启动流程重写(约 346–423 行)
改动 A:不再全量加载 CompiledResources
- const [cRuntimeStartFlag, compiledResources, allScripts] = await Promise.all([
- cacheInstance.get<boolean>("runtimeStartFlag"),
- this.compiledResourceDAO.all(),
- this.scriptDAO.all(),
- ]);
- const cleanUpPreviousRegister = !compiledResources.length;
+ const [cRuntimeStartFlag, storedNamespace, allScripts] = await Promise.all([
+ cacheInstance.get<boolean>("runtimeStartFlag"),
+ this.localStorageDAO.getValue<string>("compiledResourceNamespace"),
+ this.scriptDAO.all(),
+ ]);
+ const shouldCleanUpPreviousRegister = storedNamespace !== CompiledResourceNamespace;- 旧:
compiledResourceDAO.all()把所有已编译资源(可能很大)全读进内存,只为了判断.length是否为 0 来决定是否清理。 - 新:只读一个轻量的命名空间字符串
compiledResourceNamespace,与常量CompiledResourceNamespace比较。不一致 → 需要清理旧缓存。 - 好处:启动更快、更省内存;「需要清理」的触发也更明确——以后改了 CompiledResource 结构,只要改那个常量字符串即可强制清旧缓存。
改动 B:拆成两阶段
// Stage 1(同步):分类脚本、更新排序、算出要反注册的目标——刻意不放任何 await
for (const script of allScripts) {
const isNormalScript = script.type === SCRIPT_TYPE_NORMAL;
const enable = script.status === SCRIPT_STATUS_ENABLE;
if (!isNormalScript || !enable || shouldCleanUpPreviousRegister) unregisterScriptIds.push(script.uuid);
if (isNormalScript && enable) enabledNormalScripts.push(script);
}
this.updateSorter((next) => { for (const script of allScripts) this.setScriptSort(next, script); });
// Stage 2(异步):只为「启用的普通脚本」预热。禁用脚本启动时一律不建,交给 Popup 懒构建。
this.initialCompiledResourcePromise = Promise.all(enabledNormalScripts.map(async (script) => { ... }));- Stage 1 全同步:注释明说「刻意同步,避免有人不小心往循环里塞 await」。因为分类 + 排序必须在任何 await 之前一次性算完,避免竞态。
- Stage 2 只预热启用脚本:和「删掉
scriptMatchDisable」一致——禁用脚本不再在启动时预建 CompiledResource,也不进匹配器。 - 同时把
scriptMatchEnable.addRules和cachedPatterns.set一起写好(启用脚本的匹配模式顺手缓存)。
改动 C:保存命名空间标记
if (shouldCleanUpPreviousRegister) {
await this.localStorageDAO.saveValue("compiledResourceNamespace", CompiledResourceNamespace);
}- 清理完成后写回当前命名空间,下次启动就不会重复清理。
- 小风险:即使 Stage 2 里个别脚本预热失败,这里也照样保存命名空间 → 那个失败脚本下次启动不会被再清一遍。影响很小,和旧行为大体相当。(low)
5.5 事件订阅改造(约 508–598 行)
所有「会改变脚本集合 / 匹配器」的事件,开头都先同步失效缓存,再做异步处理。核心理由(注释原文):数据库写入发生在队列事件之前,所以这之后任何懒重建都能读到最新状态。
enableScripts(启用/禁用)
+ this.invalidateDisabledMatcher();
+ for (const { uuid } of data) this.deleteScriptRuntimeCache(uuid);
...
} else { // 禁用
- unregisteyUuids.push(uuid);
+ this.scriptMatchEnable.clearRules(uuid);
+ this.scriptMatchEnable.clearRules(this.getOriginalMatchUuid(uuid));
+ unregisterUuids.push(uuid);
}- 禁用脚本时,主动从启用匹配器移除规则(旧代码只反注册页面脚本,没清匹配器规则——因为旧逻辑靠
scriptMatchDisable接手;现在没有那个匹配器了,必须显式清)。 - 顺手修了拼写:
unregisteyUuids→unregisterUuids。
installScript(安装/更新)
+ this.invalidateDisabledMatcher();
+ this.deleteScriptRuntimeCache(uuid);
const script = await this.scriptDAO.get(uuid);
...
+ this.updateSorter((next) => this.setScriptSort(next, script));
...
} else { // 禁用状态安装
- // 还是要建立 CompiledResoure, 否则 Popup 看不到 Script
- await this.buildAndSaveCompiledResourceFromScript(script, false);
+ // 禁用脚本不再预建 CompiledResource、也不进 SW 匹配器;Popup 用 metadata 懒构建禁用匹配模式
+ this.scriptMatchEnable.clearRules(uuid);
+ this.scriptMatchEnable.clearRules(this.getOriginalMatchUuid(uuid));
}- 重要语义变化:旧代码即使脚本是禁用状态,安装时也会建 CompiledResource,理由是「否则 Popup 看不到」。新代码不建了——因为 Popup 的禁用匹配器改成直接从脚本 metadata 懒构建(见 5.7 的
buildDisabledMatcher),所以 Popup 依然看得到禁用脚本。两边是一致的。✅ - 注意这里也显式调了
invalidateDisabledMatcher(),因为updateSorter()(它内部也会失效)在一个await之后才执行,必须在 await 之前先失效一次。
deleteScripts(删除)
+ this.invalidateDisabledMatcher();
const unregisterUuids = [];
+ this.updateSorter((next) => {
for (const { uuid } of data) {
unregisterUuids.push(uuid);
+ this.deleteScriptRuntimeCache(uuid);
+ this.deleteScriptSort(next, uuid);
this.scriptMatchEnable.clearRules(uuid);
this.scriptMatchEnable.clearRules(this.getOriginalMatchUuid(uuid));
- this.scriptMatchDisable.clearRules(uuid);
- this.scriptMatchDisable.clearRules(`${uuid}${ORIGINAL_URLMATCH_SUFFIX}`);
}
+ });
await this.unregistryPageScripts(unregisterUuids);- 不再清
scriptMatchDisable(已删除);改为清三层运行时缓存 + 从 sorter 删权重 + 清启用匹配器规则。 - 注释解释了为什么开头显式调一次
invalidateDisabledMatcher():虽然下面updateSorter()内部也会失效,但这是为了和installScript模式一致(确保任何 await 之前就已失效)。
sortedScripts(排序)
- const uuidSort = Object.fromEntries(scripts.map(({uuid, sort}) => [uuid, sort]));
- this.scriptMatchEnable.setupSorter(uuidSort);
- this.scriptMatchDisable.setupSorter(uuidSort);
+ this.updateSorter((next) => { for (const script of scripts) this.setScriptSort(next, script); });- 改用统一的
updateSorter,顺带维护{uuid}{Ori}键、失效禁用匹配器。
5.6 匹配查询方法拆分(约 1147–1226 行)
// 对外(页面注入用):只查启用匹配器
getPageScriptMatchingResultByUrl(url, includeNonEffective = false) {
return this.getPageScriptMatchingResultByUrlInternal(url, undefined, includeNonEffective);
}
// 对外(Popup 用):先拿到懒构建的禁用匹配器,再合并查询
async getPopupPageScriptMatchingResultByUrl(url) {
const disabledMatcher = await this.getDisabledMatcher();
return this.getPageScriptMatchingResultByUrlInternal(url, disabledMatcher, true);
}
// 内部统一实现
private getPageScriptMatchingResultByUrlInternal(url, disabledMatcher, includeNonEffective) {
const matchedUuids = disabledMatcher
? [...this.scriptMatchEnable.urlMatch(url), ...disabledMatcher.urlMatch(url)]
: this.scriptMatchEnable.urlMatch(url);
...
}- 旧:一个方法用
includeDisabled布尔参数,内部读常驻的scriptMatchDisable。 - 新:拆成「页面用」和「Popup 用」两个清晰入口;禁用匹配器作为参数传入(页面用传
undefined,Popup 用传懒构建结果)。 - 好处:页面注入这条最热的路径完全不碰禁用脚本逻辑,职责清晰。✅
5.7 禁用匹配器的懒构建 + 版本防陈旧(约 373–406 行)⭐并发关键
private async buildDisabledMatcher() {
const matcher = new UrlMatch<string>();
matcher.setupSorter(this.sorter);
const scripts = await this.scriptDAO.all();
for (const script of scripts) {
if (script.type !== SCRIPT_TYPE_NORMAL || script.status !== SCRIPT_STATUS_DISABLE) continue;
const patterns = this.getOrBuildPatternCache(buildScriptRunResourceBasic(script));
if (!patterns) continue;
matcher.addRules(script.uuid, patterns.scriptUrlPatterns);
if (patterns.originalUrlPatterns !== patterns.scriptUrlPatterns) {
matcher.addRules(this.getOriginalMatchUuid(script.uuid), patterns.originalUrlPatterns);
}
}
return matcher;
}
private getDisabledMatcher(): Promise<UrlMatch<string>> {
return stackAsyncTask(this.disabledMatcherTaskKey, async () => {
let matcher = this.disabledMatcher;
if (matcher) return matcher; // 快路径:已有缓存直接用
let buildVersion;
do {
buildVersion = this.disabledMatcherVersion; // 记下开工时的版本
matcher = await this.buildDisabledMatcher(); // 期间可能有脚本事件
} while (this.disabledMatcherVersion !== buildVersion); // 版本变了 = 期间有改动 → 重建
return (this.disabledMatcher = matcher);
});
}- 这是全 PR 最需要仔细看的并发逻辑。
stackAsyncTask(key, fn):按 key 的 FIFO 队列串行执行。多个 Popup 同时打开 → 共用同一次构建,不会并发重复建 N 份。do/while版本检查:构建过程要await scriptDAO.all()(异步)。如果构建途中有删除/安装/启用事件invalidateDisabledMatcher()把版本 +1,那么本次构建出的快照就过时了 → 循环检测到版本不一致 → 重建。这就是「Popup 不会显示刚被删除的脚本」的护栏。- 测试覆盖:
runtime.test.ts第 408–443 行专门测了「构建期间失效会重取」「并发请求共享一次构建」。✅ T2 已通过。 - 构建来源:
scriptDAO.all()+ 脚本 metadata(经getOrBuildPatternCache),不依赖预建的 CompiledResource——这正是 5.4/5.5 里不再为禁用脚本建 CompiledResource 的前提。
5.8 页面加载相关的新私有方法(约 432–609 行)
| 方法 | 作用 | 关键点 |
|---|---|---|
shouldSkipPageLoadScript(scriptRes, frameId) |
判断脚本在本次加载是否应跳过 | 禁用 / run-in 环境不符(普通 vs 隐身标签)/ iframe 且 noframes |
getPageLoadScriptCacheKey(scriptRes) |
生成页面加载缓存键 | status:type:updatetime~[match,include,exclude],作为兜底失效手段 |
getCodeCacheKey(script) |
代码缓存键 | createtime:updatetime |
getScriptInfoForCode(script) |
读并解析代码(命中 codeCacheMap 就不重读) |
解析出 metadataStr / userConfigStr / userConfig |
cloneRuntimeResource(resource) |
浅拷贝资源对象 | 有 content 就删 base64 省内存 |
getLocalResourceCacheList(resource) |
抽出 file:/// 本地资源清单 |
记录 url/type/sha512 供后续比对刷新 |
getOrBuildPatternCache(scriptRes) |
拿/建匹配模式缓存 | 命中 cachedPatterns 就复用,否则 scriptURLPatternResults 计算 |
buildPageLoadScriptCache(...) |
组装整条页面加载缓存 | 并行取资源 + 代码信息 |
createPageLoadScriptInfo(scriptRes, cache) |
由缓存生成本次请求用的脚本信息对象 | value: {}(值不缓存)、资源再克隆一份 |
refreshLocalResourcesForPageLoad(...) |
每次加载刷新 file:/// 资源 |
sha512 没变就跳过,变了就更新缓存与本次对象 |
- 关于缓存键的兜底说明(注释原文要点):事件处理器(enable/install/delete)才是代码/原始 metadata/userConfig/资源变化的主要失效机制;缓存键只是给「
selfMetadata改了 match/include/exclude 但没必然 bump updatetime」这类情况兜底。 shouldSkipPageLoadScript读的是「实时」字段(来自buildScriptRunResourceBasic),所以status/run-in/noframes改了不会被缓存掩盖——这点很关键,让缓存只缓存「同版本内不变」的东西。refreshLocalResourcesForPageLoad的注意点:它会就地修改共享缓存对象(cache.resource[...]、localResource.sha512)。两个并发的同脚本页面加载理论上会交错写。由于写入的是同一个重新拉取的值(幂等),实际无害——但getScriptsForTab没有并发护栏,属可关注项(见 findings F-1)。
5.9 getScriptsForTab 主体重写(约 1459–1540 行)
改动 A:匹配调用简化
- const matchingResult = this.getPageScriptMatchingResultByUrl(url, false, false);
+ const matchingResult = this.getPageScriptMatchingResultByUrl(url);改动 B:两趟式(缓存命中 / 未命中)
const scripts = await this.scriptDAO.gets(uuids);
const enableScriptListByIndex = []; // 用下标占位,保持排序
const cacheMisses = [];
// 第 1 趟:能命中缓存的直接生成;命不中的收集起来
for (let idx = 0; idx < uuids.length; idx++) {
const script = scripts[idx];
if (!script) continue;
const scriptRes = buildScriptRunResourceBasic(script);
if (this.shouldSkipPageLoadScript(scriptRes, frameId)) continue;
const scriptCacheKey = this.getPageLoadScriptCacheKey(scriptRes);
const cached = this.pageLoadCaches.get(script.uuid);
if (cached?.scriptCacheKey === scriptCacheKey) {
enableScriptListByIndex[idx] = this.createPageLoadScriptInfo(scriptRes, cached);
} else {
cacheMisses.push({ index: idx, script, scriptRes, scriptCacheKey });
}
}
// 第 2 趟:只为未命中的脚本批量取 CompiledResource,并行构建缓存
if (cacheMisses.length) {
const compiledResources = await this.compiledResourceDAO.gets(cacheMisses.map((m) => m.script.uuid));
await Promise.all(cacheMisses.map(async (miss, missIndex) => {
let compiledResource = compiledResources[missIndex];
if (!compiledResource?.scriptUrlPatterns?.length) {
const ret = await this.buildAndSaveCompiledResourceFromScript(miss.script, false);
compiledResource = ret?.compiledResource;
}
if (!compiledResource?.scriptUrlPatterns?.length) return;
const cache = await this.buildPageLoadScriptCache(miss.scriptRes, compiledResource, miss.scriptCacheKey);
if (!cache) return;
this.pageLoadCaches.set(miss.script.uuid, cache);
enableScriptListByIndex[miss.index] = this.createPageLoadScriptInfo(miss.scriptRes, cache);
}));
}
const enableScriptList = enableScriptListByIndex.filter((item) => item !== undefined);
if (!enableScriptList.length) return null;- 核心收益:每次导航不再无脑重读代码 + CompiledResource + 重新解析 metadata。命中缓存的脚本直接复用;只有未命中的才查库、构建。
enableScriptListByIndex用下标占位:保证最终enableScriptList仍按原匹配排序输出,最后.filter(undefined)去掉空洞。- 旧代码里那段冗长的「内联资源键名解析 + run-in/noframes 判断 + 资源比对」全部抽到了 5.8 的方法里。
改动 C:本地资源刷新抽成方法
- // 旧:~50 行内联的 file:// 资源比对与字段逐个赋值
+ const scriptsWithUpdatedResources = await this.refreshLocalResourcesForPageLoad(enableScriptList, scriptCodes);改动 D:值(value)永远实时读
- enableScriptList.flatMap((script) => [
- value.getScriptValue(script).then(...),
- resource.getScriptResources(script, false).then(...),
- scriptDAO.scriptCodeDAO.get(script.uuid).then(...),
- ])
+ enableScriptList.map(async (script) => {
+ // value 必须每次页面加载都实时读,绝不走页面加载缓存
+ script.value = await value.getScriptValue(script);
+ })- 重点:资源和代码现在来自缓存;但 GM 值(value)每次都重新读——因为值是会在页面间变化的,缓存它就是 Bug。代码注释把这个边界写清楚了。✅
- 这是正确的「该缓存的缓存、不该缓存的绝不缓存」边界。
改动 E:去掉非空断言
- await chrome.userScripts.getScripts({ ids: [...] })
+ (await chrome.userScripts.getScripts({ ids: [...] })) as RegisteredUserScriptWithJsCode[]
...
- compileInjectionCode(scriptRes, scriptDAOCode, scriptRes.scriptUrlPatterns!)
+ compileInjectionCode(scriptRes, scriptDAOCode, scriptRes.scriptUrlPatterns)- 借助 5.1 的类型,去掉了
scriptUrlPatterns!非空断言。 - 小提醒:
as RegisteredUserScriptWithJsCode[]仍是「未经校验的断言」(Chrome 返回的.js可能为空),但紧接着代码就会重新给.js赋值,所以安全;加一行注释「.js下面会重写,断言仅为类型」可避免后人误信。
5.10 buildAndSaveCompiledResourceFromScript 尾部(约 1624–1642 行)
- const uuidOri = `${uuid}${ORIGINAL_URLMATCH_SUFFIX}`;
+ const uuidOri = this.getOriginalMatchUuid(uuid);
+ this.cachedPatterns.set(uuid, { scriptUrlPatterns, originalUrlPatterns });
this.scriptMatchEnable.clearRules(uuid);
this.scriptMatchEnable.clearRules(uuidOri);
- this.scriptMatchDisable.clearRules(uuid);
- this.scriptMatchDisable.clearRules(uuidOri);
- const scriptMatch = scriptRes.status === ENABLE ? this.scriptMatchEnable : this.scriptMatchDisable;
- scriptMatch.addRules(uuid, scriptUrlPatterns);
- if (...) scriptMatch.addRules(uuidOri, originalUrlPatterns);
+ if (scriptRes.status === SCRIPT_STATUS_ENABLE) {
+ this.scriptMatchEnable.addRules(uuid, scriptUrlPatterns);
+ if (originalUrlPatterns && originalUrlPatterns !== scriptUrlPatterns) {
+ this.scriptMatchEnable.addRules(uuidOri, originalUrlPatterns);
+ }
+ }- 顺手把
cachedPatterns写好(模式缓存)。 - 不再写
scriptMatchDisable:只有启用脚本才进scriptMatchEnable;禁用脚本交给 Popup 的懒匹配器(从 DB 状态构建、不可变)。 - 注释也点明了这个写入器只负责「启用匹配器」。
六、小结:改动的「必要性」一句话版
| 改动 | 必要吗 | 一句话理由 |
|---|---|---|
删 scriptMatchDisable + Popup 懒匹配器 |
✅ 必要 | 禁用脚本只有 Popup 用,没必要在 SW 常驻 |
| 三层页面加载缓存 | ✅ 必要(性能) | 每次导航不再重读库 / 重解析 |
updateSorter 换对象 |
✅ 必要(正确性) | UrlMatch 只在引用变化时清缓存,否则排序陈旧 |
| 启动改命名空间版本判断 | ✅ 必要(性能) | 不再全量读 CompiledResource 进内存 |
| value 永不缓存 | ✅ 必要(正确性) | 值会变,缓存即 Bug |
resource.ts path 修复 |
✅ 必要(Bug) | 两段式 @resource file:// 之前不会刷新 |
| Popup 删除扫全部标签页缓存 | ✅ 必要(Bug) | 旧代码只扫 -1,导致残留 |
类型 RequireField / cast |
✅ 必要(合规) | 去掉非空断言,符合仓库规则 |
七、验证记录(本次审阅实际执行)
- ✅
npx vitest run runtime.test.ts popup.test.ts resource.test.ts→ 24 全过 - ✅
npx tsc --noEmit→ 退出码 0 - 🔍 已对照阅读:
match.ts(sorter 语义)、async_queue.ts、repo.ts#gets、resource.ts#getResourceByType、CompiledResourceNamespace ⚠️ 未做实机复现:F-3(改 selfMetadata 不 bump updatetime 是否导致缓存陈旧)、F-1(同标签并发加载竞态)——两者均属需实机 T2 复现的类别,已在03-findings.md标注,未断言为确定 Bug。
将本次 getScriptsForTab/Popup 重构新增的英文注释统一改为简体中文(风格与现有注释一致), 并在若干非显而易见处补充说明,纯注释改动、无逻辑变更: - runtime.ts: - updateSorter 必须替换 sorter 对象(UrlMatch 仅在引用变化时清缓存) - invalidateDisabledMatcher / deleteScriptRuntimeCache / setScriptSort 等辅助方法用途 - waitInit 阶段一同步、阶段二仅预热启用脚本;命名空间作为清旧缓存的触发与回写 - 事件处理器先同步失效缓存的原因(DB 写入先于队列事件) - getDisabledMatcher 版本号防陈旧与 stackAsyncTask 共享构建的并发要点 - 页面加载缓存键仅为兜底;value 必须每次实时读取;两趟式缓存命中/未命中结构 - refreshLocalResourcesForPageLoad 就地更新共享缓存(幂等) - popup.ts:迟到 GM_registerMenuCommand 的读侧防护、倒序 splice、扫描全部 tab 缓存修残留 - resource.ts:说明 file:/// 判断须用 path 而非 uri(两段式 @resource) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
新增 26 个测试用例,覆盖此前缺失的逻辑分支,与现有 3 个用例合计 29 个: - addScriptRunNumber(5 个) - frameId=0 重置旧缓存后写入新脚本 - subframe 叠加多脚本 runNum / runNumByIframe - 缓存不存在时新增记录 - scriptmenus 与缓存均为空时不写入 storage - getPopupData(5 个) - URL 匹配脚本出现在 scriptList,isEffective/enable 正确 - 运行缓存与匹配结果合并(保留 runNum,更新 hasUserConfig 等) - 无匹配时 scriptList 为空、backScriptList 正常 - isBlacklist 由 runtime.isUrlBlacklist 控制 - 已删除但仍在运行缓存的脚本不出现在 scriptList - dealBackgroundScriptInstall(8 个) - installScript:启用后台脚本加入 -1 缓存;普通脚本/禁用脚本/已存在脚本均跳过 - enableScripts:启用加入 / 禁用移除 -1 缓存 - scriptRunStatus:running 置 runNum=1;complete 归零 - removeDeletedScriptsFromPendingMenuCommands(3 个) - 过滤已删命令;全删时移除 tabId 记录;无变化时不替换数组引用 - removeDeletedScriptsFromPopupCaches(5 个) - 空 uuids 返回 false;无命中返回 false - 清空后 tx.del / 有剩余 tx.set;多标签页同时清理 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
新增 23 个测试用例,原有 15 个保留,合计 38 个:
- shouldSkipPageLoadScript(7 个)
- 启用/禁用状态;noframes + frameId(有/无);
run-in: normal-tabs / incognito-tabs / all
- getPageLoadScriptCacheKey(4 个)
- 相同内容→同一 key;updatetime 变化→不同 key;
match 变化→不同 key;status 变化→不同 key
- getScriptsForTab 附加边界场景(7 个)
- isLoadScripts=false → null;URL 在黑名单 → null;无匹配 → null
- DAO 返回 DISABLE 被过滤 → null
- noframes + frameId 非 0 被过滤 → null
- noframes + frameId=undefined 不过滤,正常返回
- compiledResource 缺失时调用 buildAndSave
- MQ 事件处理效果(5 个)
- deleteScripts:匹配器清除 + 缓存删除 + sorter 权重移除
- sortedScripts:updateSorter 使 URL 缓存失效、顺序反映新 sort
- enableScripts 禁用分支:匹配器清除 + disabledMatcher 置 null
- installScript:invalidateDisabledMatcher 使缓存失效
- deleteScriptRuntimeCache:一次性清除三层缓存
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
描述
本 PR 优化了 RuntimeService 的脚本匹配与页面加载缓存逻辑,并修复删除脚本后 Popup 仍可能显示残留菜单的问题。同时补齐了相关回归测试,覆盖脚本删除、禁用脚本匹配、排序缓存、本地资源刷新等场景。
主要改动
优化
RuntimeService.getScriptsForTab:修复 Popup 菜单残留问题:
tabScript:*缓存,而不只清理后台菜单。getPopupData读取运行缓存时,通过 DAO 过滤已删除脚本。GM_registerMenuCommand。修复本地资源刷新判断:
@resource使用file:///路径时,改为判断解析后的path,避免两段式资源写法无法刷新。补充测试:
file:///命名资源刷新测试。测试
已补充/更新相关 Vitest 测试,覆盖本次改动的主要边界场景。