Skip to content

✨ JSON 配置仅存储用户差异并与最新默认配置合并#1538

Open
cyfung1031 wants to merge 1 commit into
scriptscat:mainfrom
cyfung1031:pr/json-update-2
Open

✨ JSON 配置仅存储用户差异并与最新默认配置合并#1538
cyfung1031 wants to merge 1 commit into
scriptscat:mainfrom
cyfung1031:pr/json-update-2

Conversation

@cyfung1031

Copy link
Copy Markdown
Collaborator

Checklist / 检查清单

  • Fixes mentioned issues / 修复已提及的问题
  • Code reviewed by human / 代码通过人工检查
  • Changes tested / 已完成测试

Description / 描述

实现 #1517 讨论中确定的方案(合并 = 用户 + 默认,有变动以用户配置为准,只保存用户改动),针对目前受影响的两个 JSON 配置项 eslint_configeditor_config

问题:用户改动过配置后,整份 JSON 被完整写入 storage。之后 ScriptCat 升级默认配置(新增 ESLint 规则、调整编辑器编译选项等)对这些用户永远不生效。

方案(VSCode 式两层配置,存储层编解码):

  • 写入:与内置默认配置做深度比较,storage 只保存稀疏差异;内容与默认配置完全一致时直接清除存储键。
  • 读取:将存储的差异深度合并到最新默认配置上(用户值优先,数组与标量整体替换),返回完整配置。
  • 缓存与 MessageQueue 广播中始终是合并后的完整配置,各上下文(设置页编辑器、linter worker、monaco)行为不变。

兼容性(已上架用户无感升级)

  • 旧版全量存储的配置:读取时同样走合并逻辑——用户改过的值保留,新版本新增的默认字段自动生效;用户下次保存后自动收敛为稀疏差异,无需迁移脚本。
  • chrome.storage.sync 单键 8KB 配额压力显著降低(全量 ESLint 配置数 KB → 通常几十字节的差异)。
  • 语义说明:在编辑器中删除某个默认键 = 恢复该键的默认值(与 VSCode 一致);要禁用某条规则请显式设为 "off"

测试:新增 src/pkg/config/json_overrides.test.ts(合并/差异算法)与 config.test.ts 中「JSON 配置的稀疏存储与默认值合并」共 15 个用例(含旧版全量配置兼容场景),全部通过;pnpm test(3023 个用例)与 pnpm run lint 均通过。

close #1517

🤖 Generated with Claude Code

storage 中只保存 eslint_config / editor_config 与内置默认配置的稀疏差异,
读取时将差异合并到最新默认配置(用户值优先),使扩展升级带来的默认配置
更新能自动生效,同时完整保留用户改动。旧版全量存储的配置读取时自动兼容,
下次保存后自动收敛为稀疏差异。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cyfung1031

Copy link
Copy Markdown
Collaborator Author

Case Studies:JSON 配置只保存用户差异,并与最新默认配置合并

1. PR 核心目的

这个 PR 解决的是:当用户修改过 JSON 配置后,旧逻辑会把整份 JSON 写入 storage,导致后续 ScriptCat 升级默认配置时,新增的 ESLint 规则、编辑器选项等无法自动作用到这些用户身上。PR 当前针对两个 JSON 配置项生效:eslint_configeditor_config

新的方案类似 VSCode 的“两层配置”模型:

最终运行配置 = 最新默认配置 + 用户差异配置

也就是说:

Default Config   系统维护,随版本升级
User Overrides   只保存用户改过的部分
Final Config     运行时 merge 后得到完整配置

写入时,系统会把用户提交的完整 JSON 与默认 JSON 做深度 diff,只把差异保存到 storage;读取时,再把 storage 中的差异合并到最新默认配置上,返回完整 JSON 给设置页、linter worker、Monaco 等调用方。


2. 本 PR 涉及的主要文件

文件 作用
src/pkg/config/json_overrides.ts 新增 JSON merge / diff 工具:deepMergedeepDiffmergeJsonConfigdiffJsonConfig
src/pkg/config/config.ts SystemConfig 读写流程中接入 JSON 配置的 decode / encode 逻辑。
src/pkg/config/json_overrides.test.ts 新增 merge / diff / JSON 编解码单元测试。
src/pkg/config/config.test.ts 新增 eslint_config / editor_config 在真实配置读写流程中的测试。

3. 行为规则总览

3.1 写入规则

用户在设置页保存时,前端仍然可以提交“完整 JSON”。但进入 storage 前,系统会计算:

stored = diff(userConfig, defaultConfig)

如果用户配置与默认配置完全一致:

stored = undefined

此时会删除对应 storage key,而不是存一份空 JSON 或完整默认 JSON。PR 中 encodeForStorage 会调用 diffJsonConfig,如果结果是 undefined 就执行 storage.remove(key)


3.2 读取规则

读取 storage 时,系统拿到的是用户差异配置:

{
  "rules": {
    "no-debugger": ["warn"]
  }
}

然后与当前版本内置的最新默认配置合并:

finalConfig = merge(defaultConfig, storedDiff)

最终返回给业务侧的仍然是完整 JSON,因此设置页、worker、Monaco 等上下文不需要改变使用方式。config.ts 中的 decodeStored 会对 JSON 配置调用 mergeJsonConfig,并把合并后的完整值放入缓存。


3.3 合并规则

deepMerge 的规则是:

类型 行为
普通对象 深度递归合并
数组 整体替换
标量值 整体替换
用户新增 key 保留
类型不一致 使用用户覆盖值

测试中覆盖了“嵌套对象合并”“数组整体替换”“覆盖配置独有 key 保留”“类型不同时使用覆盖值”等场景。


4. Case Studies

Case Study 1:用户没有修改过配置,始终使用最新默认值

场景

一个新用户安装 ScriptCat,或者一个老用户从未改过 eslint_config / editor_config

旧行为可能的问题

旧逻辑下,如果某次保存把完整默认 JSON 写进 storage,那么以后即使系统默认配置升级,用户也可能继续读到旧 storage 中的完整配置。

新行为

如果用户配置与默认值完全一致,storage 不保存任何差异。

storage 中没有 system_eslint_config
storage 中没有 system_editor_config

读取时:

getEslintConfig() -> 返回当前版本最新 eslintDefaultConfig
getEditorConfig() -> 返回当前版本最新 editorDefaultConfig

价值

用户没有表达过偏好时,系统默认配置可以持续演进。例如:

{
  "rules": {
    "no-empty": ["error"],
    "no-debugger": ["error"]
  }
}

以后 ScriptCat 新增默认规则:

{
  "rules": {
    "no-empty": ["error"],
    "no-debugger": ["error"],
    "no-eval": ["warn"]
  }
}

用户会自动获得 no-eval,因为他没有任何 override 阻止默认配置升级。

对应测试

config.test.ts 新增测试验证:未修改时,getEslintConfig()getEditorConfig() 返回默认配置。


Case Study 2:用户只修改一条 ESLint 规则,storage 只保存这一条差异

场景

默认 ESLint 配置中:

{
  "rules": {
    "no-debugger": ["error"],
    "no-empty": ["error"]
  }
}

用户把 no-debuggererror 改成 warn

{
  "rules": {
    "no-debugger": ["warn"],
    "no-empty": ["error"]
  }
}

新 storage 内容

系统不会保存整份 JSON,而是只保存差异:

{
  "rules": {
    "no-debugger": ["warn"]
  }
}

读取后的最终配置

运行时合并后,业务侧拿到的是完整配置:

{
  "rules": {
    "no-debugger": ["warn"],
    "no-empty": ["error"]
  }
}

价值

storage 只表达“用户真正改过什么”。这样系统之后更新其他默认规则时,不会被旧的完整 JSON 卡住。

对应测试

PR 中的测试验证了保存 ESLint 配置时,只会存储 rules.no-debugger 和用户新增规则这类差异,而不是整份默认配置。


Case Study 3:用户新增自定义 ESLint 规则,新增 key 会被保留

场景

用户希望加入项目自定义规则:

{
  "rules": {
    "no-debugger": ["warn"],
    "custom/added-rule": ["error"]
  }
}

其中:

no-debugger        默认已有,但用户改了值
custom/added-rule  默认没有,是用户新增 key

新 storage 内容

{
  "rules": {
    "no-debugger": ["warn"],
    "custom/added-rule": ["error"]
  }
}

为什么 custom/added-rule 会保留?

deepDiff 的规则是:如果某个 key 不存在于默认配置中,就认为它是用户自定义内容,需要保留。对应测试也验证了“用户新增的 key 应保留”。

价值

这个设计不会把用户自定义规则当成“未知字段”丢掉。它适合 ESLint 这类允许插件规则、项目自定义规则的配置。


Case Study 4:ScriptCat 升级新增默认规则,老用户自动获得新默认字段

场景

某用户旧版本已经保存过配置:

{
  "rules": {
    "no-debugger": ["warn"]
  }
}

之后 ScriptCat 新版本默认配置新增:

{
  "rules": {
    "no-empty": ["error"]
  }
}

新行为

读取时会执行:

finalConfig = latestDefaultConfig + userOverrides

最终得到:

{
  "rules": {
    "no-debugger": ["warn"],
    "no-empty": ["error"]
  }
}

关键点

用户改过的 no-debugger 保留为 warn;系统新增的 no-empty 自动生效。

价值

这正是本 PR 的核心收益:用户偏好不会丢,系统默认值也可以持续升级

对应测试

测试中模拟了 storage 只保存 { rules: { "no-debugger": ["warn"] } } 的场景,并验证读取结果既保留用户值,又包含默认配置中的其他规则与 globals。


Case Study 5:旧版“全量配置”也可以懒兼容

场景

PR 上线前,老用户 storage 里可能已经存了完整 JSON:

{
  "rules": {
    "no-debugger": ["off"]
  }
}

并且这份旧 JSON 可能缺少新版本后来新增的默认字段,例如:

{
  "rules": {
    "no-empty": ["error"]
  }
}

新行为

旧版全量配置读取时也走合并逻辑:

finalConfig = latestDefaultConfig + legacyStoredConfig

最终:

{
  "rules": {
    "no-debugger": ["off"],
    "no-empty": ["error"]
  }
}

用户下次保存后

如果用户再次保存,系统会重新计算 diff。原本的全量配置会自动收敛成稀疏差异:

{
  "rules": {
    "no-debugger": ["off"]
  }
}

价值

不需要额外迁移脚本,也不会打断老用户。PR 描述中明确提到旧版全量配置读取时会兼容,用户下次保存后会自动收敛为稀疏差异。


Case Study 6:用户把规则设为 "off",表示明确禁用,而不是删除 key

场景

默认配置:

{
  "rules": {
    "no-debugger": ["error"]
  }
}

用户想禁用这条规则。

正确做法

用户应显式设置:

{
  "rules": {
    "no-debugger": ["off"]
  }
}

storage diff:

{
  "rules": {
    "no-debugger": ["off"]
  }
}

错误理解

如果用户只是从编辑器里删除这个 key:

{
  "rules": {}
}

新机制会把“缺失默认 key”解释成“恢复默认值”,而不是“禁用规则”。

为什么这样设计?

deepDiff 的注释说明:value 中缺失的默认 key 不会被记录,语义是“恢复默认值”。PR 描述也说明,在编辑器中删除某个默认 key 等于恢复该 key 的默认值;要禁用规则应显式设为 "off"


Case Study 7:数组整体替换,避免 ESLint rule options 被错误合并

场景

ESLint 规则通常是数组:

{
  "rules": {
    "some-rule": ["error", { "allow": true }]
  }
}

用户改成:

{
  "rules": {
    "some-rule": ["warn"]
  }
}

新行为

数组不会逐项 merge,而是整体替换。

最终结果:

{
  "rules": {
    "some-rule": ["warn"]
  }
}

不会出现这种错误结果:

{
  "rules": {
    "some-rule": ["warn", { "allow": true }]
  }
}

价值

这对 ESLint 规则非常重要,因为 ESLint rule array 的每个位置都有语义。数组逐项合并可能产生无效或误导性配置。

对应测试

json_overrides.test.ts 中验证了数组应整体替换,而不是合并。


Case Study 8:editor_config 也走同样机制

场景

默认 editor config:

{
  "strict": true,
  "target": "ES2020"
}

用户只修改:

{
  "strict": false,
  "target": "ES2020"
}

新 storage 内容

{
  "strict": false
}

读取后的最终配置

{
  "strict": false,
  "target": "ES2020"
}

价值

editor_config 后续新增默认编译选项时,用户也可以自动获得。例如未来默认配置新增:

{
  "moduleResolution": "bundler"
}

只要用户没有覆盖这个字段,它就会在读取时自动进入最终配置。

对应测试

PR 中新增测试验证了 editor_config 会只保存 { strict: false } 这样的差异,并在读取时与默认 editor config 合并。


Case Study 9:用户恢复默认配置时,storage key 会被清除

场景

用户之前修改过:

{
  "rules": {
    "no-debugger": ["warn"]
  }
}

之后用户把配置改回默认值:

{
  "rules": {
    "no-debugger": ["error"]
  }
}

新行为

diff 结果为空:

diff = undefined

因此系统删除 storage key:

remove("system_eslint_config")

价值

这能避免 storage 中残留一份“与默认值完全一样”的 JSON。它也降低了 chrome.storage.sync 的配额压力。

对应实现

config.ts 中的写入逻辑会先 encodeForStorage,如果结果是 undefined 就 remove,否则才 set。


Case Study 10:保存空字符串等同于恢复默认配置

场景

某些设置入口可能用空字符串表示“清空配置”或“恢复默认”。

setEslintConfig("")
setEditorConfig("")

新行为

PR 中将空字符串转换成对应默认配置,再走统一的 JSON parse 与 _set 流程:

"" -> defaultConfig -> diff(defaultConfig, defaultConfig) -> undefined -> remove storage key

最终效果

storage key 被删除
下一次读取返回最新默认配置

价值

这让“清空配置”和“恢复默认配置”语义一致,同时不会绕过新的 diff / merge 机制。

对应实现与测试

setEslintConfig / setEditorConfig 对空字符串进行了默认值替换;测试也验证了保存空字符串会清除 storage,并在读取时返回默认配置。


5. Before / After 对比

Before:完整 JSON 存储

用户只改 1 个字段
↓
storage 保存整份 JSON
↓
未来默认配置新增字段
↓
用户继续读取旧完整 JSON
↓
新默认字段无法自动生效

问题:

  • storage 体积大
  • 无法判断字段是用户改的,还是旧默认值遗留
  • 默认配置升级难以触达老用户
  • 后续迁移成本高

这些问题与 #1517 中描述的背景一致:旧机制会导致用户偏好可能被覆盖、新默认字段无法同步、难以判断用户主动修改与旧默认遗留值。


After:稀疏 diff 存储

用户只改 1 个字段
↓
storage 只保存这个字段
↓
未来默认配置新增字段
↓
读取时 latest defaults + user diff
↓
用户保留偏好,同时获得新默认字段

收益:

  • 用户改动可精确表达
  • 默认配置可持续升级
  • storage 更小
  • 老用户可懒兼容
  • 业务侧仍拿完整配置,调用方式不变

PR 描述中也提到,chrome.storage.sync 单键 8KB 配额压力会显著降低,完整 ESLint 配置从数 KB 变成通常几十字节的差异。


6. 需要注意的边界

6.1 删除默认 key 不是禁用

删除默认 key 表示恢复默认值。要禁用 ESLint 规则,应该显式写:

{
  "rules": {
    "some-rule": ["off"]
  }
}

这点需要在设置 UI 或文档中说明,避免用户误以为删除 key 就能关闭规则。


6.2 当前实现不是完整三方合并

#1517 的概念方案中提到“三方合并”、字段重命名、字段删除、迁移脚本、冲突提示等机制;但 PR #1538 当前代码主要实现的是:

diff 当前用户配置 vs 当前默认配置
merge 最新默认配置 + storage 中的用户差异

也就是说,它已经解决“只存用户差异”和“新默认值自动生效”的核心问题,但并没有实现复杂版本迁移或冲突提示。#1517 中的三方合并与迁移机制属于更完整的后续演进方向。


6.3 数组整体替换是有意设计

不要把数组当对象逐项合并。对于 ESLint rule config:

["error", { "allow": true }]

和:

["warn"]

它们是两个完整语义单元。整体替换比逐项合并更安全。测试中已经覆盖了数组整体替换。


7. 可作为 PR 说明的精简版总结

本 PR 为 eslint_configeditor_config 引入“默认配置 + 用户差异”的 JSON 配置存储机制。

  • 写入时:将用户提交的完整 JSON 与当前默认配置做深度 diff,只保存差异。
  • 读取时:将 storage 中的差异 merge 到最新默认配置,返回完整配置。
  • 如果用户配置与默认配置一致,则删除 storage key。
  • 普通对象深度合并,数组和标量整体替换。
  • 用户新增 key 会保留。
  • 删除默认 key 表示恢复默认值;禁用 ESLint 规则应显式设为 "off"
  • 旧版全量配置读取时可兼容,并在下次保存后自动收敛为稀疏 diff。

这样可以在保留用户个性化配置的同时,让 ScriptCat 后续新增或调整默认 JSON 配置时自动对用户生效,并减少 chrome.storage.sync 的存储压力。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

配置 JSON 更新与用户偏好保留改善计划

1 participant