From 6537fdd405ddf82521e853dd0cce302b8b002ce9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:13:35 +0900 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E9=87=8D=E6=9E=84=20GM=20?= =?UTF-8?q?value=20=E6=9B=B4=E6=96=B0=E6=B5=81=E7=A8=8B=EF=BC=9A=E4=BB=A5?= =?UTF-8?q?=20storageName=20=E5=88=86=E7=BB=84=E6=89=B9=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=B9=B6=E4=BF=9D=E8=AF=81=E9=A1=BA=E5=BA=8F=E4=B8=8E=20update?= =?UTF-8?q?time=20=E4=B8=A5=E6=A0=BC=E9=80=92=E5=A2=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../service/content/create_context.test.ts | 40 +-- src/app/service/content/exec_script.ts | 17 +- src/app/service/content/gm_api/gm_api.test.ts | 52 ++-- src/app/service/content/gm_api/gm_api.ts | 79 +++--- src/app/service/content/script_executor.ts | 13 +- src/app/service/content/script_runtime.ts | 4 +- src/app/service/content/scripting.ts | 4 +- src/app/service/content/types.ts | 16 +- src/app/service/queue.ts | 4 +- src/app/service/sandbox/runtime.ts | 35 ++- src/app/service/service_worker/runtime.ts | 14 +- src/app/service/service_worker/value.test.ts | 243 ++++++++++++------ src/app/service/service_worker/value.ts | 199 ++++++++------ 13 files changed, 443 insertions(+), 277 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 54961d107..5f5bc880c 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -141,7 +141,7 @@ describe.concurrent("createContext", () => { expect((context as any).loadScriptResolve).toBeUndefined(); }); - it.concurrent("setInvalidContext 会释放监听器且后续 valueUpdate 不再触发", () => { + it.concurrent("setInvalidContext 会释放监听器且后续 valueStoreUpdate 不再触发", async () => { const script = createScriptInfo(); const context = createContext( script, @@ -154,28 +154,34 @@ describe.concurrent("createContext", () => { const listener = vi.fn(); context.GM_addValueChangeListener("foo", listener); - context.valueUpdate({ - id: "remote-1", - uuid: script.uuid, - storageName: "", - sender: { runFlag: "other-run-flag", tabId: 7 }, - entries: [["foo", encodeRValue("next"), encodeRValue("bar")]], - valueUpdated: true, - }); + context.valueStoreUpdate(script.value, [ + { + id: "remote-1", + uuid: script.uuid, + storageName: "", + sender: { runFlag: "other-run-flag", tabId: 7 }, + valueChanges: [["foo", encodeRValue("next"), encodeRValue("bar")]], + }, + ]); + // 监听器回调延后到下一个 microTask 执行 + expect(listener).not.toHaveBeenCalled(); + await Promise.resolve(); expect(listener).toHaveBeenCalledWith("foo", "bar", "next", true, 7); context.setInvalidContext(); context.setInvalidContext(); expect(context.isInvalidContext()).toBe(true); - context.valueUpdate({ - id: "remote-2", - uuid: script.uuid, - storageName: "", - sender: { runFlag: "other-run-flag", tabId: 8 }, - entries: [["foo", encodeRValue("again"), encodeRValue("next")]], - valueUpdated: true, - }); + context.valueStoreUpdate(script.value, [ + { + id: "remote-2", + uuid: script.uuid, + storageName: "", + sender: { runFlag: "other-run-flag", tabId: 8 }, + valueChanges: [["foo", encodeRValue("again"), encodeRValue("next")]], + }, + ]); + await Promise.resolve(); expect(listener).toHaveBeenCalledTimes(1); }); }); diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index 6c5b93730..e63773bcc 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -7,7 +7,8 @@ import type { Message } from "@Packages/message/types"; import type { ValueUpdateDataEncoded } from "./types"; import { evaluateGMInfo } from "./gm_api/gm_info"; import type { IGM_Base } from "./gm_api/gm_api"; -import type { TScriptInfo } from "@App/app/repo/scripts"; +import type { ScriptRunResource, TScriptInfo } from "@App/app/repo/scripts"; +import { getStorageName } from "@App/pkg/utils/utils"; // 执行脚本,控制脚本执行与停止 export default class ExecScript { @@ -74,8 +75,18 @@ export default class ExecScript { this.sandboxContext?.emitEvent(event, eventId, data); } - valueUpdate(data: ValueUpdateDataEncoded) { - this.sandboxContext?.valueUpdate(data); + valueUpdate(storageName: string, uuid: string, responses: ValueUpdateDataEncoded[]) { + const scriptRes = this.scriptRes; + if (scriptRes.uuid !== uuid && getStorageName(scriptRes) !== storageName) return; + const context = this.sandboxContext; + if (!context) return; + const contextScriptRes = context.scriptRes as ScriptRunResource | null | undefined; + if (!contextScriptRes) return; + // 以 extValueStoreCopy(SW 确认顺序的快照)为基准应用更新,使各页面 valueStore 的 key 顺序一致 + contextScriptRes.value = context.extValueStoreCopy || contextScriptRes.value; + const valueStore = contextScriptRes.value; + context.valueStoreUpdate(valueStore, responses); + context.extValueStoreCopy = { ...valueStore }; } execContext: any; diff --git a/src/app/service/content/gm_api/gm_api.test.ts b/src/app/service/content/gm_api/gm_api.test.ts index 8a94e1b66..cbdef52e3 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -6,6 +6,7 @@ import { compileScript, compileScriptCode } from "../utils"; import type { Message } from "@Packages/message/types"; import { encodeRValue } from "@App/pkg/utils/message_value"; import { uuidv4 } from "@App/pkg/utils/uuid"; +import { getStorageName } from "@App/pkg/utils/utils"; const nilFn: ScriptFunc = () => {}; const scriptRes = { @@ -1115,14 +1116,15 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 const retPromise = exec.exec(); expect(mockSendMessage).toHaveBeenCalledTimes(1); // 模拟值变化 - exec.valueUpdate({ - id: "id-1", - entries: [["param1", encodeRValue(123), encodeRValue(undefined)]], - uuid: script.uuid, - storageName: script.uuid, - sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, - valueUpdated: true, - }); + exec.valueUpdate(getStorageName(script), script.uuid, [ + { + id: "id-1", + valueChanges: [["param1", encodeRValue(123), encodeRValue(undefined)]], + uuid: script.uuid, + storageName: script.uuid, + sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, + }, + ]); const ret = await retPromise; expect(ret).toEqual({ name: "param1", oldValue: undefined, newValue: 123, remote: false }); }); @@ -1155,14 +1157,15 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 const retPromise = exec.exec(); expect(mockSendMessage).toHaveBeenCalledTimes(1); // 模拟值变化 - exec.valueUpdate({ - id: "id-2", - entries: [["param2", encodeRValue(456), encodeRValue(undefined)]], - uuid: script.uuid, - storageName: "testStorage", - sender: { runFlag: "user", tabId: -2 }, - valueUpdated: true, - }); + exec.valueUpdate(getStorageName(script), script.uuid, [ + { + id: "id-2", + valueChanges: [["param2", encodeRValue(456), encodeRValue(undefined)]], + uuid: script.uuid, + storageName: "testStorage", + sender: { runFlag: "user", tabId: -2 }, + }, + ]); const ret2 = await retPromise; expect(ret2).toEqual({ name: "param2", oldValue: undefined, newValue: 456, remote: true }); }); @@ -1195,14 +1198,15 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 expect(id).toBeTypeOf("string"); expect(id.length).greaterThan(0); // 触发valueUpdate - exec.valueUpdate({ - id: id, - entries: [["a", encodeRValue(123), encodeRValue(undefined)]], - uuid: script.uuid, - storageName: script.uuid, - sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, - valueUpdated: true, - }); + exec.valueUpdate(getStorageName(script), script.uuid, [ + { + id: id, + valueChanges: [["a", encodeRValue(123), encodeRValue(undefined)]], + uuid: script.uuid, + storageName: script.uuid, + sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, + }, + ]); const ret = await retPromise; expect(ret).toEqual(123); diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 945d5e936..4a3ccb051 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -15,11 +15,10 @@ import { base64ToBlob, randNum, randomMessageFlag, strToBase64 } from "@App/pkg/ import LoggerCore from "@App/app/logger/core"; import EventEmitter from "eventemitter3"; import GMContext from "./gm_context"; -import { type ScriptRunResource } from "@App/app/repo/scripts"; +import type { ScriptRunResource, ValueStore } from "@App/app/repo/scripts"; import type { ValueUpdateDataEncoded } from "../types"; import { connect, sendMessage } from "@Packages/message/client"; import { ScriptEnvTag } from "@Packages/message/consts"; -import { getStorageName } from "@App/pkg/utils/utils"; import { ListenerManager } from "../listener_manager"; import { decodeRValue, encodeRValue, type REncoded } from "@App/pkg/utils/message_value"; import { type TGMKeyValue } from "@App/app/repo/value"; @@ -44,7 +43,7 @@ void CATAgentOPFSApi; export interface IGM_Base { sendMessage(api: string, params: any[]): Promise; connect(api: string, params: any[]): Promise; - valueUpdate(data: ValueUpdateDataEncoded): void; + valueStoreUpdate(valueStore: ValueStore, responses: ValueUpdateDataEncoded[]): void; emitEvent(event: string, eventId: string, data: any): void; } @@ -61,6 +60,15 @@ let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; const valueChangePromiseMap = new Map(); +const generateValChangeId = () => { + if (valChangeCounterId > 1e8) { + // 防止 valChangeCounterId 过大导致无法正常工作 + valChangeCounterId = 0; + valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; + } + return `${valChangeRandomId}::${++valChangeCounterId}`; +}; + const execEnvInit = (execEnv: GMApi) => { if (!execEnv.contentEnvKey) { execEnv.contentEnvKey = randomMessageFlag(); // 不重复识别字串。用于区分 mainframe subframe 等执行环境 @@ -168,12 +176,13 @@ class GM_Base implements IGM_Base { } @GMContext.protected() - public valueUpdate(data: ValueUpdateDataEncoded) { - if (!this.scriptRes || !this.valueChangeListener) return; - const scriptRes = this.scriptRes; - const { id, uuid, entries, storageName, sender, valueUpdated } = data; - if (uuid === scriptRes.uuid || storageName === getStorageName(scriptRes)) { - const valueStore = scriptRes.value; + public extValueStoreCopy?: ValueStore; // 以 SW 确认顺序为准的 store 快照,使各页面 valueStore 的 key 顺序一致 + + @GMContext.protected() + public valueStoreUpdate(valueStore: ValueStore, responses: ValueUpdateDataEncoded[]) { + // 同步更新 valueStore;监听器回调延后到下一个 microTask,避免阻塞更新流程 + for (const response of responses) { + const { id, valueChanges, sender } = response; const remote = sender.runFlag !== this.runFlag; if (!remote && id) { const fn = valueChangePromiseMap.get(id); @@ -182,21 +191,18 @@ class GM_Base implements IGM_Base { fn(); } } - if (valueUpdated) { - const valueChanges = entries; - for (const [key, rTyped1, rTyped2] of valueChanges) { - const value = decodeRValue(rTyped1); - const oldValue = decodeRValue(rTyped2); - // 触发,并更新值 - if (value === undefined) { - if (valueStore[key] !== undefined) { - delete valueStore[key]; - } - } else { - valueStore[key] = value; - } - this.valueChangeListener.execute(key, oldValue, value, remote, sender.tabId); + for (const [key, rTyped1, rTyped2] of valueChanges) { + const value = decodeRValue(rTyped1); + const oldValue = decodeRValue(rTyped2); + // 触发,并更新值 + if (value !== undefined) { + valueStore[key] = value; + } else if (valueStore[key] !== undefined) { + delete valueStore[key]; } + Promise.resolve().then(() => { + this.valueChangeListener?.execute(key, oldValue, value, remote, sender.tabId); + }); } } } @@ -283,17 +289,17 @@ export default class GMApi extends GM_Base { static _GM_setValue(a: GMApi, promise: any, key: string, value: any) { key = `${key}`; if (!a.scriptRes) return; - if (valChangeCounterId > 1e8) { - // 防止 valChangeCounterId 过大导致无法正常工作 - valChangeCounterId = 0; - valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; - } - const id = `${valChangeRandomId}::${++valChangeCounterId}`; + const id = generateValChangeId(); if (promise) { valueChangePromiseMap.set(id, promise); } + const valueStore = a.scriptRes.value; + if (!a.extValueStoreCopy) { + // 本地乐观写入前记下快照;快照只随 SW 确认的更新演进,key 顺序以 SW 为准 + a.extValueStoreCopy = { ...valueStore }; + } if (value === undefined) { - delete a.scriptRes.value[key]; + delete valueStore[key]; a.sendMessage("GM_setValue", [id, key]); } else { // 对object的value进行一次转化 @@ -301,7 +307,7 @@ export default class GMApi extends GM_Base { value = customClone(value); } // customClone 可能返回 undefined - a.scriptRes.value[key] = value; + valueStore[key] = value; if (value === undefined) { a.sendMessage("GM_setValue", [id, key]); } else { @@ -313,16 +319,15 @@ export default class GMApi extends GM_Base { static _GM_setValues(a: GMApi, promise: any, values: TGMKeyValue) { if (!a.scriptRes) return; - if (valChangeCounterId > 1e8) { - // 防止 valChangeCounterId 过大导致无法正常工作 - valChangeCounterId = 0; - valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; - } - const id = `${valChangeRandomId}::${++valChangeCounterId}`; + const id = generateValChangeId(); if (promise) { valueChangePromiseMap.set(id, promise); } const valueStore = a.scriptRes.value; + if (!a.extValueStoreCopy) { + // 本地乐观写入前记下快照;快照只随 SW 确认的更新演进,key 顺序以 SW 为准 + a.extValueStoreCopy = { ...valueStore }; + } const keyValuePairs = [] as [string, REncoded][]; for (const [key, value] of Object.entries(values)) { let value_ = value; diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 75eb90bec..b8c5e4888 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -1,8 +1,7 @@ import type { Message } from "@Packages/message/types"; -import { getStorageName } from "@App/pkg/utils/utils"; import type { EmitEventRequest } from "../service_worker/types"; import ExecScript from "./exec_script"; -import type { GMInfoEnv, ScriptFunc, ValueUpdateDataEncoded } from "./types"; +import type { GMInfoEnv, ScriptFunc, ValueUpdateSendData } from "./types"; import { addStyleSheet, definePropertyListener, waitBody } from "./utils"; import type { ScriptLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; import { DefinedFlags } from "../service_worker/runtime.consts"; @@ -46,12 +45,12 @@ export class ScriptExecutor { } } - valueUpdate(data: ValueUpdateDataEncoded) { + valueUpdate(sendData: ValueUpdateSendData) { // runtime/valueUpdate - const { uuid, storageName } = data; - for (const val of this.execScriptMap.values()) { - if (val.scriptRes.uuid === uuid || getStorageName(val.scriptRes) === storageName) { - val.valueUpdate(data); + const { storageName, storageChanges } = sendData; + for (const [uuid, responses] of Object.entries(storageChanges)) { + for (const execScript of this.execScriptMap.values()) { + execScript.valueUpdate(storageName, uuid, responses); } } } diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 817dd2a23..da8ba9fdd 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -3,7 +3,7 @@ import type { Message } from "@Packages/message/types"; import { initEnvInfo, type ScriptExecutor } from "./script_executor"; import type { TScriptInfo } from "@App/app/repo/scripts"; import type { EmitEventRequest } from "../service_worker/types"; -import type { GMInfoEnv, ValueUpdateDataEncoded } from "./types"; +import type { GMInfoEnv, ValueUpdateSendData } from "./types"; import type { ScriptEnvTag } from "@Packages/message/consts"; import { onInjectPageLoaded } from "./external"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; @@ -59,7 +59,7 @@ export class ScriptRuntime { // 转发给脚本 this.scriptExecutor.emitEvent(data); }); - this.server.on("runtime/valueUpdate", (data: ValueUpdateDataEncoded) => { + this.server.on("runtime/valueUpdate", (data: ValueUpdateSendData) => { this.scriptExecutor.valueUpdate(data); }); diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index 4a08a0265..3d200a69d 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -6,7 +6,7 @@ import { RuntimeClient } from "../service_worker/client"; import { getStorageName, makeBlobURL } from "@App/pkg/utils/utils"; import type { Logger } from "@App/app/repo/logger"; import LoggerCore from "@App/app/logger/core"; -import type { ValueUpdateDataEncoded } from "./types"; +import type { ValueUpdateSendData } from "./types"; const PageOrContent = { PAGE: 1, @@ -73,7 +73,7 @@ export default class ScriptingRuntime { deliveryStorage.onChanged.addListener((changes) => { const record = changes["valueUpdateDelivery"]; if (record?.newValue) { - const sendData = (record.newValue as { sendData: ValueUpdateDataEncoded }).sendData; + const sendData = (record.newValue as { sendData: ValueUpdateSendData }).sendData; const activeOn = this.activeStorageNames === null ? PageOrContent.PAGE_AND_CONTENT diff --git a/src/app/service/content/types.ts b/src/app/service/content/types.ts index 30fe88e80..c1d4e6a6a 100644 --- a/src/app/service/content/types.ts +++ b/src/app/service/content/types.ts @@ -12,24 +12,20 @@ export type ValueUpdateSender = { /** * key, value, oldValue */ -export type ValueUpdateDataEntry = [string, any, any]; export type ValueUpdateDataREntry = [string, REncoded, REncoded]; -export type ValueUpdateData = { +export type ValueUpdateDataEncoded = { id?: string; - entries: ValueUpdateDataEntry[]; + valueChanges: ValueUpdateDataREntry[]; uuid: string; storageName: string; // 储存name sender: ValueUpdateSender; }; -export type ValueUpdateDataEncoded = { - id?: string; - entries: ValueUpdateDataREntry[]; - uuid: string; - storageName: string; // 储存name - sender: ValueUpdateSender; - valueUpdated: boolean; +// 以 storageName 为单位的推送数据;storageChanges 以 uuid 分组,同组内按处理顺序排列 +export type ValueUpdateSendData = { + storageName: string; + storageChanges: Record; }; // gm_api.ts diff --git a/src/app/service/queue.ts b/src/app/service/queue.ts index cb3070714..e5fb206c8 100644 --- a/src/app/service/queue.ts +++ b/src/app/service/queue.ts @@ -1,4 +1,4 @@ -import type { Script, SCRIPT_RUN_STATUS, SCRIPT_STATUS, SCRIPT_TYPE } from "../repo/scripts"; +import type { SCRIPT_RUN_STATUS, SCRIPT_STATUS, SCRIPT_TYPE } from "../repo/scripts"; import type { InstallSource, SWScriptMenuItemOption, @@ -30,8 +30,6 @@ export type TEnableScript = { uuid: string; enable: boolean }; export type TScriptRunStatus = { uuid: string; runStatus: SCRIPT_RUN_STATUS }; -export type TScriptValueUpdate = { script: Script; valueUpdated: boolean }; - export type TScriptMenuRegister = { uuid: string; key: TScriptMenuItemKey; diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts index f5df396d0..b1d28d22b 100644 --- a/src/app/service/sandbox/runtime.ts +++ b/src/app/service/sandbox/runtime.ts @@ -14,7 +14,7 @@ import { proxyUpdateRunStatus } from "../offscreen/client"; import { BgExecScriptWarp } from "../content/exec_warp"; import type ExecScript from "../content/exec_script"; import { compileScriptCodeByResource } from "../content/utils"; -import type { ValueUpdateDataEncoded } from "../content/types"; +import type { ValueUpdateSendData } from "../content/types"; import { getStorageName, getMetadataStr, getUserConfigStr, getISOWeek } from "@App/pkg/utils/utils"; import type { EmitEventRequest, ScriptLoadInfo } from "../service_worker/types"; import { CATRetryError } from "../content/exec_warp"; @@ -340,21 +340,28 @@ export class Runtime { return this.execScript(loadScript, { execOnce: true }); } - valueUpdate(data: ValueUpdateDataEncoded) { + valueUpdate(sendData: ValueUpdateSendData) { // runtime/valueUpdate - const dataEntries = data.entries; - // 转发给脚本 - this.execScriptMap.forEach((val) => { - if (val.scriptRes.uuid === data.uuid || getStorageName(val.scriptRes) === data.storageName) { - val.valueUpdate(data); + const { storageName, storageChanges } = sendData; + for (const [uuid, responses] of Object.entries(storageChanges)) { + // 转发给脚本 + for (const execScript of this.execScriptMap.values()) { + execScript.valueUpdate(storageName, uuid, responses); } - }); - // 更新crontabScripts中的脚本值 - for (const script of this.crontabSripts) { - if (script.uuid === data.uuid || getStorageName(script) === data.storageName) { - for (const [key, rTyped1, _rTyped2] of dataEntries) { - const value = decodeRValue(rTyped1); - script.value[key] = value; + // 更新crontabScripts中的脚本值 + for (const script of this.crontabSripts) { + if (script.uuid === uuid || getStorageName(script) === storageName) { + const valueStore = script.value; + for (const { valueChanges } of responses) { + for (const [key, rTyped1, _rTyped2] of valueChanges) { + const value = decodeRValue(rTyped1); + if (value !== undefined) { + valueStore[key] = value; + } else if (valueStore[key] !== undefined) { + delete valueStore[key]; + } + } + } } } } diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 55b7e0be6..bb73b2bd4 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -49,7 +49,7 @@ import { type SystemConfig } from "@App/pkg/config/config"; import { type ResourceService } from "./resource"; import { type LocalStorageDAO } from "@App/app/repo/localStorage"; import Logger from "@App/app/logger/logger"; -import type { GMInfoEnv, ValueUpdateDataEncoded } from "../content/types"; +import type { GMInfoEnv, ValueUpdateSendData } from "../content/types"; import { initLocalesPromise, localePath } from "@App/locales/locales"; import { DocumentationSite } from "@App/app/const"; import { extractUrlPatterns, RuleType, type URLRuleEntry } from "@App/pkg/utils/url_matcher"; @@ -458,7 +458,7 @@ export class RuntimeService { await this.loadPageScript(script, apiScript!); } - public async pushValueUpdate(script: Script, sendData: ValueUpdateDataEncoded) { + public async pushValueUpdate(updatedScripts: Script[], sendData: ValueUpdateSendData) { try { // 前台腳本 (推送值到tab) await deliveryStorage!.set({ @@ -474,8 +474,8 @@ export class RuntimeService { await sendMessage(this.msgSender, "offscreen/runtime/valueUpdate", sendData); } - // valueUpdate 消息用于 early script 的处理 - if (sendData.valueUpdated) { + // updatedScripts 为本批次有实际值变更的脚本,用于 early script 的处理 + for (const script of updatedScripts) { if (script.status === SCRIPT_STATUS_ENABLE && isEarlyStartScript(script.metadata)) { // 如果是预加载脚本,需要更新脚本代码重新注册 // scriptMatchInfo 里的 value 改变 => compileInjectionCode -> injectionCode 改变 @@ -483,11 +483,7 @@ export class RuntimeService { } } } catch (e) { - this.logger.error( - "push value update failed", - { uuid: script.uuid, storageName: sendData.storageName }, - Logger.E(e) - ); + this.logger.error("push value update failed", { storageName: sendData.storageName }, Logger.E(e)); } } diff --git a/src/app/service/service_worker/value.test.ts b/src/app/service/service_worker/value.test.ts index 7dcc2ae9a..bd8e22b42 100644 --- a/src/app/service/service_worker/value.test.ts +++ b/src/app/service/service_worker/value.test.ts @@ -13,7 +13,9 @@ import { Server } from "@Packages/message/server"; import EventEmitter from "eventemitter3"; import { MessageQueue } from "@Packages/message/message_queue"; import type { ValueUpdateSender } from "../content/types"; -import { getStorageName } from "@App/pkg/utils/utils"; +import { deferred, getStorageName } from "@App/pkg/utils/utils"; +import { CACHE_KEY_SET_VALUE } from "@App/app/cache_key"; +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import type { TKeyValuePair } from "@App/pkg/utils/message_value"; import { encodeRValue } from "@App/pkg/utils/message_value"; import { TrashScriptDAO, type TrashScript } from "@App/app/repo/trash_script"; @@ -26,8 +28,10 @@ initTestEnv(); beforeEach(() => createMockOPFS()); +const nextMacroTask = () => new Promise((r) => setTimeout(r, 0)); + /** - * ValueService.setValue 方法的单元测试 + * ValueService.setValues 方法的单元测试 * * 测试覆盖的场景: * 1. 设置新脚本的值(首次设置) @@ -35,8 +39,11 @@ beforeEach(() => createMockOPFS()); * 3. 值未改变时的处理(不进行保存) * 4. 删除值(设置为undefined) * 5. 脚本不存在时的错误处理 + * 6. 同一 storageName 的并发操作会被合并为一次读写与一次推送 + * 7. setValues 的处理顺序与调用顺序一致 + * 8. updatetime 严格递增 */ -describe("ValueService - setValue 方法测试", () => { +describe("ValueService - setValues 方法测试", () => { let valueService: ValueService; let mockGroup: Group; let mockMessageQueue: IMessageQueue; @@ -65,6 +72,19 @@ describe("ValueService - setValue 方法测试", () => { tabId: -2, }); + // pushValueUpdate 中单个 ValueUpdateDataEncoded 的期望结构 + const expectResponse = (id: string, mockScript: Script, changeCount: number) => + expect.objectContaining({ + id, + valueChanges: Array(changeCount).fill(expect.anything()), + uuid: mockScript.uuid, + storageName: getStorageName(mockScript), + sender: expect.objectContaining({ + runFlag: expect.any(String), + tabId: expect.any(Number), + }), + }); + beforeEach(() => { // 创建消息系统 const eventEmitter = new EventEmitter(); @@ -128,17 +148,12 @@ describe("ValueService - setValue 方法测试", () => { expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1); expect(valueService.pushValueUpdate).toHaveBeenNthCalledWith( 1, - mockScript, + [mockScript], expect.objectContaining({ - entries: expect.any(Object), - id: "testId-4021", - sender: expect.objectContaining({ - runFlag: expect.any(String), - tabId: expect.any(Number), - }), storageName: getStorageName(mockScript), - uuid: mockScript.uuid, - valueUpdated: true, + storageChanges: { + [mockScript.uuid]: [expectResponse("testId-4021", mockScript, 1)], + }, }) ); @@ -180,17 +195,12 @@ describe("ValueService - setValue 方法测试", () => { expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1); expect(valueService.pushValueUpdate).toHaveBeenNthCalledWith( 1, - mockScript, + [mockScript], expect.objectContaining({ - entries: expect.any(Object), - id: "testId-4022", - sender: expect.objectContaining({ - runFlag: expect.any(String), - tabId: expect.any(Number), - }), storageName: getStorageName(mockScript), - uuid: mockScript.uuid, - valueUpdated: true, + storageChanges: { + [mockScript.uuid]: [expectResponse("testId-4022", mockScript, 1)], + }, }) ); @@ -241,17 +251,12 @@ describe("ValueService - setValue 方法测试", () => { expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1); expect(valueService.pushValueUpdate).toHaveBeenNthCalledWith( 1, - mockScript, + [mockScript], expect.objectContaining({ - entries: expect.any(Object), - id: "testId-4023", - sender: expect.objectContaining({ - runFlag: expect.any(String), - tabId: expect.any(Number), - }), storageName: getStorageName(mockScript), - uuid: mockScript.uuid, - valueUpdated: true, + storageChanges: { + [mockScript.uuid]: [expectResponse("testId-4023", mockScript, 1)], + }, }) ); @@ -291,24 +296,19 @@ describe("ValueService - setValue 方法测试", () => { isReplace: false, }); - // 验证结果 - 不应该保存或发送更新 + // 验证结果 - 不应该保存,但仍然推送(客户端依赖 id 回执解除等待) expect(mockScriptDAO.get).toHaveBeenCalledWith(mockScript.uuid); expect(mockValueDAO.get).toHaveBeenCalled(); expect(mockValueDAO.save).not.toHaveBeenCalled(); // 值未改变,不应该保存 expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1); expect(valueService.pushValueUpdate).toHaveBeenNthCalledWith( 1, - mockScript, + [], // 没有实际变更的脚本 expect.objectContaining({ - entries: expect.any(Object), - id: "testId-4024", - sender: expect.objectContaining({ - runFlag: expect.any(String), - tabId: expect.any(Number), - }), storageName: getStorageName(mockScript), - uuid: mockScript.uuid, - valueUpdated: false, + storageChanges: { + [mockScript.uuid]: [expectResponse("testId-4024", mockScript, 0)], + }, }) ); // 值未改变 }); @@ -352,7 +352,7 @@ describe("ValueService - setValue 方法测试", () => { expect(savedValue.data.otherKey).toBe("otherValue"); // 其他键保持不变 }); - it("当脚本不存在时应该抛出错误", async () => { + it("当脚本不存在时应该抛出错误,且不影响后续 setValues", async () => { // 准备测试数据 const nonExistentUuid = randomUUID(); const mockSender = createMockValueSender(); @@ -376,11 +376,25 @@ describe("ValueService - setValue 方法测试", () => { expect(mockValueDAO.get).not.toHaveBeenCalled(); expect(mockValueDAO.save).not.toHaveBeenCalled(); expect(valueService.pushValueUpdate).not.toHaveBeenCalled(); - expect(mockMessageQueue.emit).toHaveBeenCalledTimes(0); + + // 顺序队列不应因单次错误而卡死,后续 setValues 正常处理 + const mockScript = createMockScript(); + vi.mocked(mockScriptDAO.get).mockResolvedValue(mockScript); + vi.mocked(mockValueDAO.get).mockResolvedValue(undefined); + vi.mocked(mockValueDAO.save).mockResolvedValue({} as any); + await valueService.setValues({ + uuid: mockScript.uuid, + id: "testId-4027", + keyValuePairs: keyValuePairs1, + valueSender: mockSender, + isReplace: false, + }); + expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1); }); - it("应该正确处理并发访问的缓存键", async () => { - // 这个测试验证 stackAsyncTask 的使用,确保相同 storageName 的操作不会冲突 + it("同一 storageName 的并发 setValues 应合并为一次读写与一次推送", async () => { + // 这个测试验证以 storageName 分组的批处理: + // 排队期间累积的多个 setValues 由一次 setValuesByStorageName 集中处理 const mockScript = createMockScript(); const mockSender = createMockValueSender(); const key1 = "key1"; @@ -396,10 +410,14 @@ describe("ValueService - setValue 方法测试", () => { expect(mockValueDAO.save).toHaveBeenCalledTimes(0); expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(0); + // 先占住该 storageName 的处理队列,让两个 setValues 的任务都先入队 + const d = deferred(); + stackAsyncTask(`${CACHE_KEY_SET_VALUE}${getStorageName(mockScript)}`, () => d.promise); + // 并发执行两个setValue操作 const keyValuePairs1 = [[key1, encodeRValue(value1)]] satisfies TKeyValuePair[]; const keyValuePairs2 = [[key2, encodeRValue(value2)]] satisfies TKeyValuePair[]; - await Promise.all([ + const ret = Promise.all([ valueService.setValues({ uuid: mockScript.uuid, id: "testId-4041", @@ -415,41 +433,122 @@ describe("ValueService - setValue 方法测试", () => { isReplace: false, }), ]); - - // 验证两个操作都被调用 + await nextMacroTask(); + // 队列被占住期间,两个任务都已入队但未处理 expect(mockScriptDAO.get).toHaveBeenCalledTimes(2); - expect(mockValueDAO.save).toHaveBeenCalledTimes(2); - expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(2); + expect(mockValueDAO.save).toHaveBeenCalledTimes(0); + d.resolve(); + await ret; + + // 验证两个操作被合并为一次批处理 + expect(mockValueDAO.get).toHaveBeenCalledTimes(1); + expect(mockValueDAO.save).toHaveBeenCalledTimes(1); + expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1); expect(valueService.pushValueUpdate).toHaveBeenNthCalledWith( 1, - mockScript, + [mockScript], expect.objectContaining({ - entries: expect.any(Object), - id: "testId-4041", - sender: expect.objectContaining({ - runFlag: expect.any(String), - tabId: expect.any(Number), - }), storageName: getStorageName(mockScript), - uuid: mockScript.uuid, - valueUpdated: true, + storageChanges: { + [mockScript.uuid]: [ + expectResponse("testId-4041", mockScript, 1), + expectResponse("testId-4042", mockScript, 1), + ], + }, }) ); - expect(valueService.pushValueUpdate).toHaveBeenNthCalledWith( - 2, - mockScript, - expect.objectContaining({ - entries: expect.any(Object), - id: "testId-4042", - sender: expect.objectContaining({ - runFlag: expect.any(String), - tabId: expect.any(Number), - }), - storageName: getStorageName(mockScript), + + // 验证两个键都已写入同一份数据 + const saveCall = vi.mocked(mockValueDAO.save).mock.calls[0]; + const savedValue = saveCall[1]; + expect(savedValue.data[key1]).toBe(value1); + expect(savedValue.data[key2]).toBe(value2); + }); + + it("setValues 的处理顺序应与调用顺序一致", async () => { + // scriptDAO.get 的耗时差异不应打乱 setValues 的处理顺序 + const mockScript = createMockScript(); + const mockSender = createMockValueSender(); + const key = "orderKey"; + + // 第一次调用的 scriptDAO.get 较慢,第二次立即返回 + vi.mocked(mockScriptDAO.get) + .mockImplementationOnce(() => new Promise((r) => setTimeout(() => r(mockScript), 20))) + .mockResolvedValue(mockScript); + vi.mocked(mockValueDAO.get).mockResolvedValue(undefined); + vi.mocked(mockValueDAO.save).mockResolvedValue({} as any); + + // 占住处理队列,保证两个任务进入同一个批次以便观察顺序 + const d = deferred(); + stackAsyncTask(`${CACHE_KEY_SET_VALUE}${getStorageName(mockScript)}`, () => d.promise); + + const ret = Promise.all([ + valueService.setValues({ uuid: mockScript.uuid, - valueUpdated: true, - }) - ); + id: "testId-4051", + keyValuePairs: [[key, encodeRValue("first")]] satisfies TKeyValuePair[], + valueSender: mockSender, + isReplace: false, + }), + valueService.setValues({ + uuid: mockScript.uuid, + id: "testId-4052", + keyValuePairs: [[key, encodeRValue("second")]] satisfies TKeyValuePair[], + valueSender: mockSender, + isReplace: false, + }), + ]); + await new Promise((r) => setTimeout(r, 50)); + d.resolve(); + await ret; + + // 后写的胜出:最终值必须是第二次调用的 "second" + const saveCall = vi.mocked(mockValueDAO.save).mock.calls[0]; + expect(saveCall[1].data[key]).toBe("second"); + // 推送的响应顺序也与调用顺序一致 + expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1); + const sendData = vi.mocked(valueService.pushValueUpdate).mock.calls[0][1]; + expect(sendData.storageChanges[mockScript.uuid].map((r) => r.id)).toEqual(["testId-4051", "testId-4052"]); + }); + + it("值变更时 updatetime 应严格递增", async () => { + const mockScript = createMockScript(); + const mockSender = createMockValueSender(); + const existingValueModel: Value = { + uuid: mockScript.uuid, + storageName: getStorageName(mockScript), + data: { k: "v0" }, + createtime: Date.now() - 1000, + updatetime: Date.now() - 1000, + }; + + vi.mocked(mockScriptDAO.get).mockResolvedValue(mockScript); + vi.mocked(mockValueDAO.get).mockResolvedValue(existingValueModel); + const savedUpdatetimes: number[] = []; + vi.mocked(mockValueDAO.save).mockImplementation((_storageName, model) => { + savedUpdatetimes.push(model.updatetime); + return Promise.resolve({} as any); + }); + + // 连续两次变更(同一时间片内也必须保证 updatetime 变化) + await valueService.setValues({ + uuid: mockScript.uuid, + id: "testId-4061", + keyValuePairs: [["k", encodeRValue("v1")]] satisfies TKeyValuePair[], + valueSender: mockSender, + isReplace: false, + }); + await valueService.setValues({ + uuid: mockScript.uuid, + id: "testId-4062", + keyValuePairs: [["k", encodeRValue("v2")]] satisfies TKeyValuePair[], + valueSender: mockSender, + isReplace: false, + }); + + expect(savedUpdatetimes).toHaveLength(2); + expect(savedUpdatetimes[0]).toBeGreaterThan(existingValueModel.createtime); + expect(savedUpdatetimes[1]).toBeGreaterThan(savedUpdatetimes[0]); }); }); diff --git a/src/app/service/service_worker/value.ts b/src/app/service/service_worker/value.ts index 6f5af7ba5..b69c5503f 100644 --- a/src/app/service/service_worker/value.ts +++ b/src/app/service/service_worker/value.ts @@ -6,8 +6,13 @@ import { TrashScriptDAO } from "@App/app/repo/trash_script"; import type { IGetSender, Group } from "@Packages/message/server"; import { type RuntimeService } from "./runtime"; import { type PopupService } from "./popup"; -import { getStorageName } from "@App/pkg/utils/utils"; -import type { ValueUpdateDataEncoded, ValueUpdateDataREntry, ValueUpdateSender } from "../content/types"; +import { aNow, getStorageName } from "@App/pkg/utils/utils"; +import type { + ValueUpdateDataEncoded, + ValueUpdateDataREntry, + ValueUpdateSendData, + ValueUpdateSender, +} from "../content/types"; import { type TDeleteScript } from "../queue"; import { type IMessageQueue } from "@Packages/message/message_queue"; import { CACHE_KEY_SET_VALUE } from "@App/app/cache_key"; @@ -24,6 +29,15 @@ export type TSetValuesParams = { valueSender?: ValueUpdateSender; }; +type ValueUpdateTask = { + script: Script; + id: string; + keyValuePairs: TKeyValuePair[]; + valueSender: ValueUpdateSender; + isReplace: boolean; + ts: number; +}; + export class ValueService { logger: Logger; scriptDAO: ScriptDAO = new ScriptDAO(); @@ -76,43 +90,38 @@ export class ValueService { return this.getScriptValueDetails(script).then((res) => res[0]); } - async pushValueUpdate(script: Script, sendData: T) { - return this.runtime!.pushValueUpdate(script, sendData); + async pushValueUpdate(updatedScripts: Script[], sendData: ValueUpdateSendData) { + return this.runtime!.pushValueUpdate(updatedScripts, sendData); } - // 批量设置 - async setValues(params: TSetValuesParams) { - const { uuid, keyValuePairs, isReplace } = params; - const id = params.id || ""; - const ts = params.ts || 0; - const valueSender = params.valueSender || { - runFlag: "user", - tabId: -2, - }; - // 查询出脚本 - const script = await this.scriptDAO.get(uuid); - if (!script) { - throw new Error("script not found"); - } - // 查询老的值 - const storageName = getStorageName(script); - let oldValueRecord: ValueStore = {}; - const cacheKey = `${CACHE_KEY_SET_VALUE}${storageName}`; - const entries = [] as ValueUpdateDataREntry[]; - const _flag = await stackAsyncTask(cacheKey, async () => { - let valueModel: Value | undefined = await this.valueDAO.get(storageName); + // 排队中的 setValues 任务,以 storageName 分组,由 setValuesByStorageName 集中处理 + private valueUpdateTasks = new Map(); + + // 集中处理同一 storageName 下累积的 setValues 任务:一次读取、按序应用、一次保存、一次推送 + async setValuesByStorageName(storageName: string) { + const taskList = this.valueUpdateTasks.get(storageName); + if (!taskList?.length) return; + // 同步取走整批任务;之后入队的任务会建立新列表,由其对应的队列调用处理 + this.valueUpdateTasks.delete(storageName); + let valueModel: Value | undefined = await this.valueDAO.get(storageName); + const storageChanges: Record = {}; + // 有实际值变更的脚本(uuid 去重),供 early-start 脚本重新注册使用 + const updatedScripts = new Map(); + let valueModelUpdated = false; + for (const task of taskList) { + const { script, id, keyValuePairs, valueSender, isReplace, ts } = task; + const uuid = script.uuid; + const entries = [] as ValueUpdateDataREntry[]; + const now = aNow(); // 保证 updatetime 严格递增 + let changed = false; + let isNewModel = false; + let oldValueRecord: ValueStore = {}; + let dataModel: ValueStore; if (!valueModel) { - const now = Date.now(); - const dataModel: ValueStore = {}; - for (const [key, rTyped1] of keyValuePairs) { - const value = decodeRValue(rTyped1); - if (value !== undefined) { - dataModel[key] = value; - entries.push([key, rTyped1, R_UNDEFINED]); - } - } - // 即使是空 dataModel 也进行更新 - // 由于没entries, valueUpdated 是 false, 但 valueDAO 会有一个空的 valueModel 记录 updatetime + isNewModel = true; + // 即使无实际变更也保存空 dataModel,让 valueDAO 记录 updatetime + changed = true; + dataModel = {}; valueModel = { uuid: uuid, storageName: storageName, @@ -121,53 +130,89 @@ export class ValueService { updatetime: ts ? Math.min(ts, now) : now, }; } else { - let changed = false; - let dataModel = (oldValueRecord = valueModel.data); - dataModel = { ...dataModel }; // 每次储存使用新参考 - const containedKeys = new Set(); - for (const [key, rTyped1] of keyValuePairs) { - containedKeys.add(key); - const value = decodeRValue(rTyped1); - const oldValue = dataModel[key]; - if (oldValue === value) continue; - changed = true; - if (value === undefined) { - delete dataModel[key]; - } else { - dataModel[key] = value; - } - const rTyped2 = encodeRValue(oldValue); - entries.push([key, rTyped1, rTyped2]); + oldValueRecord = valueModel.data; + dataModel = { ...oldValueRecord }; // 每次储存使用新参考 + } + const containedKeys = new Set(); + for (const [key, rTyped1] of keyValuePairs) { + containedKeys.add(key); + const value = decodeRValue(rTyped1); + const oldValue = dataModel[key]; + if (oldValue === value) continue; + changed = true; + if (value === undefined) { + delete dataModel[key]; + } else { + dataModel[key] = value; } - if (isReplace) { - // 处理oldValue有但是没有在data.values中的情况 - for (const key of Object.keys(oldValueRecord)) { - if (!containedKeys.has(key)) { - changed = true; - const oldValue = oldValueRecord[key]; - delete dataModel[key]; // 这里使用delete是因为保存不需要这个字段了 - const rTyped2 = encodeRValue(oldValue); - entries.push([key, R_UNDEFINED, rTyped2]); - } + const rTyped2 = encodeRValue(oldValue); + entries.push([key, rTyped1, rTyped2]); + } + if (isReplace) { + // 处理oldValue有但是没有在data.values中的情况 + for (const key of Object.keys(oldValueRecord)) { + if (!containedKeys.has(key)) { + changed = true; + const oldValue = oldValueRecord[key]; + delete dataModel[key]; // 这里使用delete是因为保存不需要这个字段了 + const rTyped2 = encodeRValue(oldValue); + entries.push([key, R_UNDEFINED, rTyped2]); } } - if (!changed) return false; + } + if (changed) { valueModel.data = dataModel; // 每次储存使用新参考 + if (!isNewModel) { + valueModel.updatetime = now; + } + valueModelUpdated = true; + } + if (entries.length > 0 && !updatedScripts.has(uuid)) { + updatedScripts.set(uuid, script); + } + const list = storageChanges[uuid] || (storageChanges[uuid] = []); + list.push({ + id, + valueChanges: entries, + uuid, + storageName, + sender: valueSender, + }); + } + if (valueModelUpdated) { + await this.valueDAO.save(storageName, valueModel!); + } + // 推送到所有加载了本脚本的tab中;即使无实际变更也要推送,客户端依赖 id 回执解除等待 + this.pushValueUpdate([...updatedScripts.values()], { storageName, storageChanges }); + } + + // 批量设置 + async setValues(params: TSetValuesParams): Promise { + const { uuid, keyValuePairs, isReplace } = params; + const id = params.id || ""; + const ts = params.ts || 0; + const valueSender = params.valueSender || { + runFlag: "user", + tabId: -2, + }; + let storageName!: string; + // 顺序队列:入队顺序与 setValues 调用顺序一致, + // 不因 scriptDAO.get 的异步耗时差异而打乱 + await stackAsyncTask("valueChangeOnSequence", async () => { + // 查询出脚本 + const script = await this.scriptDAO.get(uuid); + if (!script) { + throw new Error("script not found"); + } + storageName = getStorageName(script); + let taskList = this.valueUpdateTasks.get(storageName); + if (!taskList) { + this.valueUpdateTasks.set(storageName, (taskList = [])); } - await this.valueDAO.save(storageName, valueModel); - return true; + taskList.push({ script, id, keyValuePairs, valueSender, isReplace, ts }); }); - // 推送到所有加载了本脚本的tab中 - const valueUpdated = entries.length > 0; - const sendData = { - id, - entries: entries, - uuid, - storageName, - sender: valueSender, - valueUpdated, - } as ValueUpdateDataEncoded; - this.pushValueUpdate(script, sendData); + // valueDAO 读写以 storageName 为单位串行 + await stackAsyncTask(`${CACHE_KEY_SET_VALUE}${storageName}`, () => this.setValuesByStorageName(storageName)); } setScriptValues(params: Pick, _sender: IGetSender) {