-
Notifications
You must be signed in to change notification settings - Fork 309
[v1.3] 重构通讯机制 - storage.local 广播、符合 FF MV3 的 scripting 设计、采用不可追踪不断变动的同步 MessageFlag #1067
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: release/v1.3
Are you sure you want to change the base?
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 来保证架构简单 |
因此要支持 Firefox MV3, 需要 scripting 做转发。 |
|
|
先整理一下代碼 |
878ac1a to
2173bdd
Compare
|
可以重新 review 了 2026-01-01
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
这个 PR 实施了一次重大的通讯机制重构,主要目的是支持 Firefox MV3 并优化性能。
Changes:
- 引入新的 scripting.ts 作为通信中间层,使用 chrome.storage.local 广播机制替代 tabs.sendMessage
- 采用动态 MessageFlag(基于 performance.timeOrigin 和编译时随机密钥生成),提高安全性和唯一性
- 简化 inject.ts 和 content.ts,移除复杂的类层次结构,改用函数式设计
Reviewed changes
Copilot reviewed 32 out of 33 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
| src/scripting.ts | 新增 scripting 层,作为 service_worker 和 inject/content 之间的桥梁 |
| src/inject.ts | 完全重写,移除 InjectRuntime 类,改用函数式设计并直接处理握手 |
| src/content.ts | 完全重写,移除 ContentRuntime 类,简化为直接消息处理 |
| src/message-delivery.ts | 新增 MessageDelivery 类,用于缓冲和分发消息 |
| packages/message/custom_event_message.ts | 重构为使用 PageMessaging 对象,支持动态 token 绑定 |
| packages/message/common.ts | 新增公共工具函数,提取 pageDispatchEvent 等避免页面篡改 |
| src/app/service/service_worker/value.ts | 改用 chrome.storage.local 广播 valueUpdate,移除 tabs.query 循环 |
| src/app/service/service_worker/runtime.ts | 调整脚本注册逻辑,移除 Firefox 特殊处理,统一使用 scripting.js |
| rspack.config.ts | 添加 SC_RANDOM_KEY 编译时常量,用于 MessageFlag 生成 |
| 其他文件 | 更新 uuid 导入路径,调整测试用例以适配新架构 |
| function createScriptcatExpose(pageMsg: Message) { | ||
| const scriptExpose: App.ExternalScriptCat = { | ||
| isInstalled(name: string, namespace: string, callback: (res: App.IsInstalledResponse | undefined) => unknown) { | ||
| sendMessage<App.IsInstalledResponse>(pageMsg, "scripting/script/isInstalled", { name, namespace }).then(callback); |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sendMessage 返回的 Promise 如果被 reject(例如通信失败),callback 将不会被调用,这可能导致调用方一直等待回调。建议添加 .catch() 处理,在发生错误时也调用 callback,传递 undefined 或错误信息。
| sendMessage<App.IsInstalledResponse>(pageMsg, "scripting/script/isInstalled", { name, namespace }).then(callback); | |
| sendMessage<App.IsInstalledResponse>(pageMsg, "scripting/script/isInstalled", { name, namespace }) | |
| .then(callback) | |
| .catch(() => callback(undefined)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
应该不用吧。这个改动跟PR没直接关系。有需要可另外处理
| chrome.storage.local.set({ | ||
| valueUpdateDelivery: { | ||
| rId: `${Date.now()}.${Math.random()}`, |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
变量 rId 使用 Date.now() 和 Math.random() 组合生成唯一标识,但这种方式在高并发场景下可能产生重复值(虽然概率极低)。建议改用 uuidv4() 以确保唯一性,与项目其他地方的ID生成方式保持一致。例如:rId: uuidv4()
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Date.now() 和 Math.random() 的组合就够了
uuidv4() 速度比较慢,不需要这么搞
| const node = parentNode || document.head || document.body || document.querySelector("*"); | ||
| node.appendChild(el); |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
在 GM_addElement 中,如果 document.querySelector("*") 返回 null(例如在空文档中),则 node.appendChild(el) 将抛出异常。虽然这种情况极为罕见,但建议增加空值检查或至少添加 try-catch 处理,以避免整个 scripting 环境崩溃。
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ScriptCat 作者假設總是有document.documentElement 嘛
| // readyFlag 位运算:inject=1,content=2,凑齐 3 表示都 ready. ready 后设为 4 避免再触发 | ||
| let readyFlag = 0; | ||
|
|
||
| const finalizeWhenReady = () => { | ||
| if (readyFlag === 3) { | ||
| readyFlag = 4; | ||
|
|
||
| // 统一设置 token | ||
| scriptingMessaging.et = injectFlagEvt; | ||
| scriptExecutorMsgIT.et = `${injectFlagEvt}_${ScriptEnvTag.inject}`; | ||
| scriptExecutorMsgCT.et = `${injectFlagEvt}_${ScriptEnvTag.content}`; | ||
|
|
||
| // 绑定 receiver(允许 inject/content 发消息给 scripting) | ||
| scriptExecutorMsgTxIT.bindReceiver(); | ||
| scriptExecutorMsgTxCT.bindReceiver(); | ||
|
|
||
| // 建立 server:inject/content -> scripting 通道 | ||
| const server = new Server("scripting", [scriptExecutorMsgTxIT, scriptExecutorMsgTxCT]); | ||
| prepareServer(server, senderToExt, scriptExecutorMsgTxIT, scriptExecutorMsgTxCT); | ||
|
|
||
| // 建立向页面投递消息的 delivery 通道 | ||
| setupDeliveryChannel(); | ||
| } | ||
| }; | ||
|
|
||
| // 接收 inject/content 的 ready 回执 | ||
| pageAddEventListener(`${injectFlagEvt}`, (ev) => { | ||
| if (!(ev instanceof CustomEvent)) return; | ||
|
|
||
| const key = `emitterKeyFor${injectFlagEvt}`; | ||
| let value = ev.detail?.[key]; | ||
| if (!value) return; | ||
|
|
||
| if (value !== ScriptEnvType.content) value = ScriptEnvType.inject; // 使 value 必定为 1 或 2 | ||
| readyFlag |= value; | ||
| finalizeWhenReady(); | ||
| }); |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
握手过程中,如果只有 inject 或只有 content 一方响应,readyFlag 将永远无法达到 3,导致 finalizeWhenReady 永远不会执行,scripting 通道无法建立。建议添加超时机制,在一定时间后即使未收到全部响应也继续初始化,或者根据页面类型(是否为 userScripts 环境)判断是否需要等待 content 响应。
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
如果只有一方响应,整个ScriptCat设计就跑不动啦
不需要加超时强制触发 readyFlag 3
如果跑不动,自然会有Bug Report
| // const { storageName } = sendData; | ||
| chrome.storage.local.set({ | ||
| valueUpdateDelivery: { | ||
| rId: `${Date.now()}.${Math.random()}`, | ||
| sendData, | ||
| }, | ||
| }); | ||
| // chrome.tabs.query({}, (tabs) => { | ||
| // const lastError = chrome.runtime.lastError; | ||
| // if (lastError) { | ||
| // console.error("chrome.runtime.lastError in chrome.tabs.query:", lastError); | ||
| // // 没有 tabs 资讯,无法发推送到 tabs | ||
| // return; | ||
| // } | ||
| // // 推送到所有加载了本脚本的tab中 | ||
| // for (const tab of tabs) { | ||
| // const tabId = tab.id!; | ||
| // this.popup!.getScriptMenu(tabId).then((scriptMenu) => { | ||
| // if (scriptMenu.find((item) => item.storageName === storageName)) { | ||
| // this.runtime!.sendMessageToTab( | ||
| // { | ||
| // tabId, | ||
| // }, | ||
| // "valueUpdate", | ||
| // sendData | ||
| // ); | ||
| // } | ||
| // }); | ||
| // } | ||
| // }); | ||
| // 推送到offscreen中 | ||
| this.runtime!.sendMessageToTab( |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
使用 chrome.storage.local 广播 valueUpdate 时,每次更新都会触发所有 tabs 的 onChanged 监听器,即使某些 tabs 不包含该脚本。这可能导致性能问题,尤其是在有大量 tabs 时。虽然注释提到"注意资讯量不要过大",但建议考虑添加额外的过滤机制(例如基于 storageName 的白名单),或者在注释中明确说明这种设计权衡的原因。
| // const { storageName } = sendData; | |
| chrome.storage.local.set({ | |
| valueUpdateDelivery: { | |
| rId: `${Date.now()}.${Math.random()}`, | |
| sendData, | |
| }, | |
| }); | |
| // chrome.tabs.query({}, (tabs) => { | |
| // const lastError = chrome.runtime.lastError; | |
| // if (lastError) { | |
| // console.error("chrome.runtime.lastError in chrome.tabs.query:", lastError); | |
| // // 没有 tabs 资讯,无法发推送到 tabs | |
| // return; | |
| // } | |
| // // 推送到所有加载了本脚本的tab中 | |
| // for (const tab of tabs) { | |
| // const tabId = tab.id!; | |
| // this.popup!.getScriptMenu(tabId).then((scriptMenu) => { | |
| // if (scriptMenu.find((item) => item.storageName === storageName)) { | |
| // this.runtime!.sendMessageToTab( | |
| // { | |
| // tabId, | |
| // }, | |
| // "valueUpdate", | |
| // sendData | |
| // ); | |
| // } | |
| // }); | |
| // } | |
| // }); | |
| // 推送到offscreen中 | |
| this.runtime!.sendMessageToTab( | |
| // 为避免使用 chrome.storage.local 广播导致所有 tabs 的 storage.onChanged 被触发, | |
| // 这里通过 chrome.tabs.query + runtime.sendMessageToTab 仅向实际加载了对应脚本的 tab 推送。 | |
| const { storageName } = sendData; | |
| chrome.tabs.query({}, (tabs) => { | |
| const lastError = chrome.runtime.lastError; | |
| if (lastError) { | |
| // 没有 tabs 资讯或查询失败,无法发推送到 tabs | |
| this.logger.error("chrome.tabs.query error in pushValueToTab", lastError); | |
| return; | |
| } | |
| // 推送到所有加载了本脚本的 tab 中 | |
| for (const tab of tabs) { | |
| const tabId = tab.id; | |
| if (tabId == null) { | |
| continue; | |
| } | |
| this.popup | |
| ?.getScriptMenu(tabId) | |
| .then((scriptMenu) => { | |
| if (!scriptMenu) { | |
| return; | |
| } | |
| if (scriptMenu.find((item) => item.storageName === storageName)) { | |
| this.runtime?.sendMessageToTab( | |
| { | |
| tabId, | |
| }, | |
| "valueUpdate", | |
| sendData | |
| ); | |
| } | |
| }) | |
| .catch((err) => { | |
| this.logger.error("getScriptMenu error in pushValueToTab", err); | |
| }); | |
| } | |
| }); | |
| // 推送到 offscreen 中 | |
| this.runtime?.sendMessageToTab( |
| chrome.storage.local.onChanged.addListener((changes) => { | ||
| if (changes["localStorage:scriptInjectMessageFlag"]?.newValue) { | ||
| messageDeliveryToPage.dispatch({ | ||
| tag: "localStorage:scriptInjectMessageFlag", | ||
| value: changes["localStorage:scriptInjectMessageFlag"]?.newValue, | ||
| }); | ||
| } | ||
| if (changes["valueUpdateDelivery"]?.newValue) { | ||
| messageDeliveryToPage.dispatch({ | ||
| tag: "valueUpdateDelivery", | ||
| value: changes["valueUpdateDelivery"]?.newValue, | ||
| }); | ||
| } | ||
| }); |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
chrome.storage.local.onChanged 监听器缺少错误处理。如果 messageDeliveryToPage.dispatch 抛出异常,将导致后续的 storage 变更无法被处理。建议添加 try-catch 包裹 dispatch 调用,或者在 MessageDelivery.dispatch 内部处理异常。
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
这个不需要考虑处理异常
| chrome.userScripts?.unregister(), | ||
| chrome.scripting.unregisterContentScripts(), | ||
| this.localStorageDAO.save({ key: "scriptInjectMessageFlag", value: runtimeGlobal.messageFlag }), | ||
| chrome.storage.session.set({ unregisterUserscriptsFlag: `${Date.now()}.${Math.random()}` }), |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
同样的问题:使用 Date.now() 和 Math.random() 组合生成 flag。建议改用 uuidv4() 以确保唯一性和一致性。
| chrome.storage.session.set({ unregisterUserscriptsFlag: `${Date.now()}.${Math.random()}` }), | |
| chrome.storage.session.set({ unregisterUserscriptsFlag: uuidv4() }), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Date.now() 和 Math.random() 的组合就够了
uuidv4() 速度比较慢,不需要这么搞
|
想重构一下,每次来都没review完整 |
? 什麼意思? |
太难阅读了,每次都花费很多时间,看到后面越来越没有耐心 😧 |
新年放假才看吧 |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>




概述 Descriptions
已测试在Chrome版和Firefox版都能够正常执行
后台脚本没有测试。
(VSCodeConnect以外的功能,Firefox MV3 能完全执行。可以正常在Firefox跑)
不会被浏览器截取。inject 要发一个 id key, content 要配合这个 key 发另一个沟通key (同步处理)
沟通key = uuidv5(inject/scripting发出的requestId, content的messageFlagId)双方要用同一个沟通key才能对话
scripting在 scripting 分离环境直接透过 chrome.storage.onChange 取得 service worker 的broadcast 指示,再转发到 content & inject
这个机制减省了很多不必要的处理
(不用 tabs.query, 不用 tabs.sendMessage, 不用先传到 content 再传到 inject)
(不然的话,我浏览器有100个以上的tab, 每一次 valueUpdate 都要发 100次 sendMessage, 对 serviceWorker 本身处理影响很大)
scripting.js 无法像 inject 跟 content 把messageFlag封装到代码
而是一个通用脚本直接查 chrome.storage 的 messageFlag 再跟 content 请求对话
因为它是用来被动接收 service worker 的东西,所以不需要一个同步的messageFlag
不会影响 inject 和 content 的同步执行
同时,也解决了 Firefox MV3 中,runtime 没有 onMessage 的问题
(根据官方文档,这个看起来不像 Bug. userscriptAPI 的 runtime 就是只能发不能接收)
这个改动里, early-start 处理不变。
因为改的部份是 extServer 的接收处理。
非broadcast类的通讯,例如 emitEventToTab 那些有留著。
在 scripting 中接收然后转发。
变更内容 Changes
inject.js 和 content.js 也改动了,不再是一层套一层的 class 设计
代码不会重用。
直接写代码就好。方便理解和维护
为了方便使用 uuidv5, messageFlag 改成 uuidv4 格式
截图 Screenshots