CC Switch 的批量开关为什么必须串行跑

2026-08-31

一个管理面板提供「全部启用」这类批量动作时,最省事的写法是 Promise.all 把 N 个请求一起发出去。CC Switch 没有这么写。它把批量动作交给一个只有三十几行的工具函数,逐条 await,前一条没回来就不发下一条。

这不是保守,是被数据模型逼出来的。写这篇的起点是一句代码注释——它把「为什么不能并发」讲得比任何设计文档都直白。

本文的快照口径

下面所有行号都来自 CC Switch 仓库 v3.20.1 的快照 3217f725,核对日 2026-08-31。我们只是把源码文件逐个打开读,没有编译、没有运行,也从来没有安装过这个桌面应用,所以本文不会出现任何关于界面长相、点击反馈或速度快慢的描述。凡是涉及「跑起来会怎样」的,一律只能说「代码是这么写的」。

根因写在一句注释里

串行的实现集中在 src/lib/utils/sequentialBulkAction.ts,一个三十来行的小文件(这是 v3.20.1 快照下的长度,会随版本变)。核心函数 runSequentialBulkAction 的语义很单调:拿一个数组和一个异步动作,用 for 循环逐个 await,成功的塞进一个数组,抛错的连同原始条目塞进另一个数组,最后返回 { succeeded, failed }

它有两个刻意为之的设计。

第一个是不抛异常sequentialBulkAction.ts:15-31)。一整批里有一条失败,函数不会中断,也不会把错误往上扔,而是把失败项收集起来继续跑完剩下的。调用方拿到的不是「成功或抛错」两态,而是一份完整的分账:哪些成了、哪些没成、没成的那条原始入参是什么。批量操作最讨厌的失败模式是「跑到一半炸了,我不知道前面几条到底改没改」,这个返回值就是冲着它去的。

第二个是顺序本身是承诺,不是巧合。文件 11-14 行的注释给了理由:这些动作写的是本地配置文件,而多个应用适配器采用的是整文件重写,并行写会互相覆盖。

这句话值得停一下。整文件重写意味着每一次「启用某条目到某应用」,落盘动作都是「读出整份配置 → 改一个字段 → 把整份写回去」。两个这样的动作并发跑,后写的那份是基于它开始时读到的旧内容生成的,会把中间那次写入的结果整个盖掉。丢的不是一个字段,是另一次操作的全部结果。串行不是为了礼貌排队,是这个写盘模型下唯一不丢更新的做法。

顺带一提,「配置怎么落盘才不被写坏」是另一个话题,我们在原子写入与自动备份那篇单独讲过;那讲的是单次写入的完整性,这里讲的是多次写入之间的相互干扰,两件事不重叠。

这个工具函数在 tests/ 下有对应的单元测试文件 tests/lib/sequentialBulkAction.test.ts——一个三十几行的工具单独配一份测试,本身就说明作者认为这里的行为是需要被钉住的契约,而不是随手写的循环。

谁在用它:两个 hook,措辞不同

src/ 里用到 runSequentialBulkAction 的调用点,我们核到的是两处,都在 hook 层(此外就是上面那份单元测试也引用了它)。

一处是 MCP 侧的 useBulkToggleMcpAppsrc/hooks/useMcp.ts:31-50)。它的文档注释是一行英文,原文写着 Toggle multiple MCP servers serially to avoid lost whole-file writes.——「串行切换多个 MCP 服务器,以避免整文件写入被丢失」。注意 lost writes 这个词,它准确指出了并发的后果不是报错,而是静悄悄少了一次更新

另一处是 Skills 侧的 useBulkToggleSkillAppsrc/hooks/useSkills.ts:178-195),用的是同一个工具函数。两个功能完全不相干的模块共用一个循环工具,说明这条约束不属于某个面板,属于「往本机 CLI 配置里写东西」这件事本身。

批量结果既然可能是「几条成了、几条没成」,前端手上那份状态就不再可信。这件事在 Skills 的 hook 文件里有个现成的参照:安装与卸载 Skill 的 mutation 在 onSuccess 里会直接改写 ["skills","installed"] 那份缓存数组,但 onSettled 里无论成败都要再向权威列表要一次(src/hooks/useSkills.ts:87-100:107-120)。注释给的理由很实在——后端可能已经落库了,但 live 配置的同步失败了。两件事的底色是一样的:写盘这条链路上任何一环都可能半途出岔,界面那边不该拿自己的推算当结论。

面板层多做的两件事

批量按钮真正的入口在 src/components/mcp/UnifiedMcpPanel.tsxhandleToggleAll(176-210 行)。这段函数在调 hook 之前,做了两件容易被忽略的事。

第一件是作用域。 代码注释写得很清楚:AppCountBar summarizes the complete collection, so its bulk action must use the complete collection too, even while a search filter is active.——统计条统计的是全量,那它带的批量按钮就必须作用于全量,哪怕搜索框正在过滤。落到代码上,这里用的是 serverEntries 而不是 filteredServerEntries

这条约束的来源是 src/components/common/AppCountBar.tsx,一个很短的共享组件。它是「统计 + 批量开关」二合一:只有当调用方同时传了总数和 onToggleAll 时,那个计数徽章才升级成 role="checkbox",并用 aria-checked 表达三态,全部启用是 true,部分启用是 mixed;组件还会往 DOM 上挂一个 data-selection-state,取值是 pending / all / partial / none。不传 onToggleAll 时,它就退化成一个纯展示的数字徽章。既然徽章上那个数字统计的是全量,用户按下它时期待的作用范围自然也是全量——把统计口径和操作口径绑死在同一个组件里,比在两处分别写一遍注释可靠。

第二件是先做差集。 182-184 行先过滤出「当前状态与目标状态不一致」的条目,只把这批 id 交给串行工具;如果过滤完是空数组,就直接 endWrite() 返回,一个请求都不发。这不只是省请求——在整文件重写的模型下,一次「其实没有变化」的写入同样是一次完整的读-改-写,同样会参与竞争。

第二层保险:写锁只存在于三个文件里

串行解决的是「一批之内不打架」,还有个问题它管不着:用户连点两下,或者一次批量还没跑完又点了另一个开关。

CC Switch 在前端加了一把写锁,一对 beginWrite() / endWrite() 函数。在 src/components 下 grep beginWrite,命中的恰好是三个文件:mcp/UnifiedMcpPanel.tsx(103-120 行)、prompts/PromptPanel.tsx(115-129 行)、skills/UnifiedSkillsPanel.tsx(181-199 行)。

三处实现是同一个套路:一个 ref 加一个 state 双写。ref 用来做判定,因为它同步生效,React 的批处理不会让两次快速调用都拿到「未加锁」的旧值;state 只用来驱动渲染,让按钮进入禁用态。只用 state 的写法在连点场景下是漏的,只用 ref 的写法则没有任何东西驱动按钮变成禁用态,所以两个都要。

锁还留了个例外口子:mcp 与 skills 的 beginWrite 都接受一个参数(UnifiedMcpPanel.tsx:103UnifiedSkillsPanel.tsx:181),用于「确认弹窗已经开着,而当前执行的正是这个弹窗的 onConfirm」这种场景——按常规判定,此时有浮层打开应当拒绝写入,但这次写入恰恰是那个浮层要求的。

提示词面板那处最复杂:它除了写锁还有重载锁和「排队重载」。外部事件触发的刷新如果撞上正在进行的写操作,就先记一个待办标记,等 endWrite() 时补跑(PromptPanel.tsx:95-113、126-128 行),并用一个代序号保证只有最新一轮有权清掉 pending 标记。

反过来看更有意思:统一供应商面板和代理面板完全没有这层锁,只靠 mutation 的 isPending 去禁用按钮。所以这不是全站统一的基建,而是三个「一次动作可能落到多份本地配置文件」的面板各自装上的。

第三条串行:不在 hook 层,在组件里

还有一处批量也是串行,但没走那个工具函数。UnifiedSkillsPanel.tsxhandleUpdateAll(471-497 行)——「全部更新」——是组件里手写的 for 循环逐个 await,而不是调某个后端批量接口。单条失败只弹这一条的错误、继续跑下一条,最后按 successCount 汇总一次成功提示。

它前面还有一道过滤:applicableSkillUpdates(201-204 行)先用「已安装 id 集合」筛一遍后端报回来的更新条目,某条对应的项目已经不在已安装列表里,就不算数。后端给的清单和前端当前看到的状态之间隔着一段时间,这一步是在为这段时间差兜底。

这三处放在一起,能看出一条一致的取舍:凡是会写本机 CLI 配置文件的批量动作,宁可逐条来。 代价是步数随条目数线性增长——这一点我们没有运行过,不给任何耗时判断,只陈述循环的语义。

版本口径与几条别越界的话

v3.19.2 时,这两个批量 hook 走的已经是同一个串行工具,这部分没变。v3.20.1 在 MCP 面板上补的是运行时守卫:handleToggleApphandleToggleAll 的入口各加了一句 if (!isMcpAppId(app)) return;(177 行是批量那处),这一版整个文件的差异也就这八行。

值得说清的是,类型层的收紧和这道运行时守卫是同一版里一起做的,不是新旧两代的叠加。v3.20.1 才把 MCP_APP_IDS 改成显式数组,并配上 McpAppId = Exclude<AppId, ...> 这个排除型(src/config/appConfig.tsx:88-96);而 v3.19.2 时 MCP_APP_IDS 还只是 [...SKILLS_APP_IDS]——直接复制 Skills 那份列表,两个面板共用一个口径。所以这一版是在同一个位置同时补了两道:类型层把不该出现的应用挡在编译期,运行时那句 if 再兜一次动态传进来的值。

最后有三条边界,写清楚免得读者过度延伸:

一是别把「串行」读成「不会丢更新」。我们能确认的是前端按顺序发起了这些调用,以及代码注释给出的理由;后端各个应用适配器实际怎么落盘、有没有别的进程同时在动同一份文件,不在本文核实范围内。

二是这些动作写的是你本机上真实的 CLI 配置文件。批量开关一次会触及多个条目、多份配置,出问题的爆炸半径比单条操作大。这属于本机敏感数据,本文只描述源码里的机制,不构成任何安全保证。

三是行号会随版本漂。上面每一处引用都带了文件路径,自己去仓库里 grep 函数名比对着行号翻更靠谱——runSequentialBulkActionbeginWritehandleToggleAll 这三个词就够把整条链路串起来了。如果你更关心这些开关最终写到哪个文件的哪个键,统一 MCP 面板那篇按应用逐个拆过落盘路径。


本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册、 路由指南与发布说明,以及 src/src-tauri/tests/ 的源码整理, 核对日 2026-08-31,对应仓库快照 3217f725(仓库内版本号 3.20.1)。 本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用, 因此不涉及界面外观、操作手感与切换速度的任何描述。 文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。 该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。

留言讨论

评论发布后会被人工复核,违规内容将被删除。

    还没有人评论,来说说你的看法

    如果发表没有反应,可以前往联系我们告诉我们。

    在 CC Switch 里加一个国内直连的供应商

    力达云网关,注册送 ¥5 额度,一期提供 DeepSeek。

    去添加

    这个页面有问题?

    提交时会附带当前页面地址和浏览器信息,帮助我们定位问题。不填联系方式即为匿名。