[v1.3] 重构通讯机制:采用 storage.local 广播 + 符合 Firefox MV3 scripting 规范 + 不可追踪的动态同步 MessageFlag#1067
Conversation
a08c0b8 to
2396bb5
Compare
我测试了一下,没有发现问题
这个消息投递的次数并没有消失,只是进行了转移,从我们的业务代码转移到了浏览器的内部机制,现在是真真正正的要投递触发100次 chrome.storage.local.onChanged.addListener 事件消息,同时有一次存储消耗,之前的逻辑反而可以根据实际的运行情况只推指定的tab,当然这是一个不错的思路,只是我觉得性能消耗要根据实际情况来看
变成了先传到 scripting 再传到 inject,复杂度并没有消失,如果不是考虑firefox的话,我觉得不需要这一层
需要 查 chrome.storage 的 messageFlag 再跟 content 请求对话,early-start 的脚本,在极端情况下,可能丢失message |
因为每一个 tab 有自己的 scripting serviceWorker 是单线程。所有 scripts 的后台都是它在处理
这个还好吧。虽然更进一步是可以直接在 value 储存那边做。现在先这样。
valueUpdate 没有指定 tab
你拿 虚拟机 跑几百个脚本试试吧。应该能测试得出分别。
531ac10 这里有处理。 理想的话,应该是改成 这样的话后台API全部都经由 scripting 跟 service_worker 对话 但很可惜我不太懂如何改 rspack.config.ts 的设定,让它生成 wrapper 用的js, 然后跟脚本代码一同放在 userScripts API register 里 |
不是很理解这个想法,为什么不用多出 content.js 和 inject.js,难道把 GM、沙盒之类的逻辑也放到 userscript 中? 另外注入的脚本可能是在页面环境中的(inject),也还是要由 content/scripting 转发消息给 service_worker |
对。 这样就不用等 content.js 和 inject.js 的载入
对。转发消息用 dispatchEvent 根据 messageFlag 给 scripting 脚本环境是 userScript API 的 main / content_script (没 chrome.storage) |
|
那这样可能需要打包出一个 模板,service worker读取这个模板,然后将脚本代码放进去,说实话,不是很必要,这样有多少个脚本就要执行多少次初始化代码 TM 的 UserScripts API Dynamic 模式也是有 inject 和 content的 |
|
代码:归档.zip 里面的时间是消息到达时间 storage的耗时会短一些,但是cpu消耗这个不好评估 |
|
chrome.tab.query 耗时 1ms 左右,这个差距并不是很大 2x 的样子,但是实际情况会更复杂一些,valueUpdate并不算是广播,会根据实际情况去推送具体的tab,广播所有的tab都会去检查valueUpdate也是一个额外的消耗
|
|
你拿这个跑跑看结果~ 看 SW 执行时间 和 latency 就好 |
所以我才說, 把wrapper的部份直接塞到代碼腳本就好 對話的部份是沒辨法,一定要有scripting /content script 但動態代碼都是userscripts api那邊搞 不過這些更進一步的改動可以之後處理 |
wrapper赛到脚本代码里面,那么每个脚本都要去处理沙盒和一些共用的东西,这样消耗的更多,始终还是要 inject 和 content 的,而且就算是塞进去,那也是另外一种概念上的 inject 和 content,你的脚本始终是要在这两个环境中运行的,不要考虑这个了 不考虑firefox的情况下,我感觉这个scripting还是意义不大,还是不太想引入scripting加大复杂度,光看性能测试,差距不算很大,而且还额外加入了一个页面的消耗,实际情况也不是会去给每个tab发的 或者只在firefox的环境下使用这种模式,chrome使用老的模式 |
|
我看到了你在mozilla的提问了,本来想等他们回复再决定加不加scripting的,但是没信了? 还是不太想加入scripting,有额外的消耗,且加大了代码和维护的复杂度,至于考虑Firefox的兼容问题,我觉得再等一段时间看看吧,Firefox的mv3缺少好多东西 |
还好吧。用 scripting 解决了, Firefox MV3 版就大致完成了 这个PR的scripting 是在异步协助跟service_worker的沟通 userScriptAPI 的 content 环境肯定是有限制 |
勉强可以接受,但是我一直想等firefox的回复,如果支持了在 userScripts 中使用 onMessage,我会毫不犹豫的砍掉 scripting 来保证架构简单 |
|
看起来是不会被截取的,因为协商过程会比网页的js运行还快,所以不会被网站截取到,忽略掉我说的这些相关的 说实话,我对chrome这边引入scripting还是很排斥。。。。。valueUpdate也不完全是广播的,每个页面+iframe也都要消耗一个scripting的资源 那现在就是另外一个问题了,early-start如何与页面进行协商,感觉可以直接用SC_RANDOM_KEY作为flag,运行比页面js快,也只需要生效一次,不用考虑截取问题 |
|
是的。改了 d37dee2. 这跟 无关,也可以另外做一个KEY避免混淆。 |
基于这个思路,其实也不需要 performance.timeOrigin+SC_RANDOM_KEY 了,直接固定一个flag,协商后续的随机flag就行了,我重构一下这块 |
最初考考慮是避免頁面重載時呼叫之前的event |
* 处理service worker没有MouseEvent的问题 * wip * wip * wip * 重构消息机制 * 修复type问题 * 解决冲突 * 单元测试 * ScriptCat代碼 不使用 EventListenerObject * 刪無關Debug代碼 * 刪未使用 getMessageFlag() * 注釋修訂 * 统一写法降低维护成本 * ScriptCat代碼 不使用 EventListenerObject * revised negotiateEventFlag * Update common.ts * 修复flag顺序导致的协商问题 * 调整单元测试参数位置 * vitest env fix * Revert: 调整单元测试参数位置 * lint * Revert: 调整单元测试参数位置 * 調整代碼 * 加入 readyDeferred 和 isReady * fix * fix unit test * 调整单元测试 * 调整单元测试 * 单元测试 * 单元测试 * 统一大小写 * 刪無用代碼 * 抽取至共通 ReadyWrap * ReadyWrap 釋放已使用 resolve, promise * 删除debug日志和调整测试 * 调整日志等级 * 修复测试脚本GM log使用问题 --------- Co-authored-by: cyfung1031 <44498510+cyfung1031@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 39 changed files in this pull request and generated 7 comments.
Comments suppressed due to low confidence (2)
src/app/service/content/scripting.ts:63
- 在 storage.local.onChanged 监听器中,如果同时接收到两个 valueUpdate 通知(通过 extServer 和 storage.local),会导致同一个 valueUpdate 被广播两次到 content 和 inject。这可能导致脚本接收到重复的值更新通知。建议添加去重逻辑或明确说明这种行为是预期的。
src/app/service/content/scripting.ts:93 - CAT_fetchDocument 的实现中使用了 this.senderToInject 来发送 relatedTarget,但根据第 95 行的 isContent 参数,应该根据来源选择正确的消息桥。如果请求来自 content 脚本,应该使用 this.senderToContent。建议修改为:const msg = data.params[1] ? this.senderToContent : this.senderToInject; 然后使用 msg.sendRelatedTarget(xhr.response)。
| return Promise.all([ | ||
| sendMessage(this.senderToContent, "content/" + action, data), | ||
| sendMessage(this.senderToInject, "inject/" + action, data), | ||
| ]).then(() => undefined); |
There was a problem hiding this comment.
如果是 scripting -> content/inject
都没有回传
是单向的
因此可以共用同一个 page event
这样就不用发两次
There was a problem hiding this comment.
content.js
document.addEventListener("evt-for-broadcast", ()=>{...})
inject.js
document.addEventListener("evt-for-broadcast", ()=>{...})
scripting.js
document.dispatchEvent(new CustomEvent("evt-for-broadcast"))
There was a problem hiding this comment.
但是这样的话,content/inject还需要多监听一个广播的事件,问题不大
记录一下PR #1162 重构了这个 1067 的代码
记录一下 同步Flag 的代码/**
* 在同一个页面中,通过自定义事件「协商」出一个唯一可用的 EventFlag
*
* 设计目的:
* - 页面中可能同时存在多个实例
* - 需要确保最终只有一个 EventFlag 被选中并使用
*
* 协商思路(基于同步事件机制):
* 1. 先广播一次【不带 EventFlag 的询问事件】
* 2. 所有实例都会收到该事件,并根据收到的内容做判断:
* - 如果收到【已带 EventFlag 的事件】
* → 说明已有实例成功声明旗标,直接采用该值
* → 如果不是自己期望的旗标,立刻退出协商
* - 如果收到【不带 EventFlag 的事件】
* → 视为一次“空回应”
* → 在可接受次数内,主动声明自己的 preferredFlag
* 3. 若空回应次数超过上限仍未成功,则放弃协商
*
* 注意事项:
* - dispatchEvent 是同步执行的
* - 实例也会收到自己发出的事件
* - 只有一个实例时,通常立即采用 preferredFlag
* - 多实例并存时,先成功拦截并声明的实例胜出
*/
export function negotiateEventFlag(channelKey: string, preferredFlag: string, maxEmptyResponses: number = 3) {
/** 协商所使用的事件名称 */
const eventName = `${channelKey}_negotiate`;
/** 最终确认并采用的 EventFlag */
let finalFlag = "";
/** 已收到的“空事件”次数(不带 EventFlag) */
let emptyEventCount = 0;
/**
* 处理协商事件的核心监听函数
*/
const onNegotiationEvent: EventListener = (event) => {
if (!(event instanceof CustomEvent)) return;
if (event.defaultPrevented) return;
const receivedFlag = event.detail?.EventFlag;
// ───────────── 情况一:收到已声明 EventFlag 的事件 ─────────────
if (receivedFlag) {
// 只在尚未确定最终结果时处理
if (!finalFlag) {
finalFlag = receivedFlag;
// 若旗标不是自己期望的,说明其他实例已胜出
if (receivedFlag !== preferredFlag) {
pageRemoveEventListener(eventName, onNegotiationEvent);
}
}
return;
}
// ───────────── 情况二:收到不带 EventFlag 的空事件 ─────────────
emptyEventCount++;
if (emptyEventCount <= maxEmptyResponses) {
// 在允许范围内,主动声明自己的旗标
pageDispatchCustomEvent(eventName, {
EventFlag: preferredFlag,
});
// 阻止事件继续传播,避免被其他实例抢先处理
event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation();
} else {
// 超过最大尝试次数,放弃协商
pageRemoveEventListener(eventName, onNegotiationEvent);
}
};
// 开始监听协商事件
pageAddEventListener(eventName, onNegotiationEvent);
// 发送第一次询问事件(不带 EventFlag)
pageDispatchCustomEvent(eventName, {});
if (!finalFlag) {
throw new Error("negotiateEventFlag: 未能成功协商出 EventFlag");
}
return finalFlag;
} |




这次 PR 对 ScriptCat 的核心通讯机制进行了大幅重构,主要解决以下问题:
tabs.sendMessage逐个分发的 O(n) 通讯模式,在开启大量分页(例如 50~100+ 个)时会造成 service worker 严重负载,甚至阻塞runtime.onMessage无法在 scripting 环境使用,导致通讯不相容- 旧有 MessageFlag 设计可被追踪/拦截的风险较高- inject / content 层级的类别抽象过度复杂,维护困难主要改动与设计思路
通讯方式全面转为 chrome.storage.local 广播
chrome.storage.local.set({ rId: xxx, ... })发送指令chrome.storage.onChanged→ 实现近似原生 broadcast 的效果,service worker 不再需要逐一分页呼叫 sendMessage,大幅降低 SW 负载
适配 Firefox MV3 限制
runtime.onMessage的依赖(FF MV3 scripting 环境不支援接收)采用不可追踪、每次执行不同的动态同步标志(MessageFlag & EventFlag)
injectFlagEvt、executorEnvReadyKey等一次性/动态 keySC_RANDOM_KEY+进行初始握手performance.timeOrigininject & content 代码大幅简化
早期注入(document-start)支援更稳定
其他细节优化
效能与相容性
已知限制 / 未来方向
- 长期考虑方向:探索 UserScripts API(dynamic)或 file.js?query 方式,进一步减少 content/inject 层测试重点建议