CC Switch 怎么防首帧白闪:内联脚本与 CSP 哈希
问题只存在于启动那一瞬间
一个用 WebView 做界面的桌面应用,暗色主题不是写在原生控件里的,而是写在 CSS 里。CSS 要等 HTML 解析、要等样式表就位;决定「这次该用亮色还是暗色」的那份用户偏好,通常又存在 localStorage 里,得等 JavaScript 跑起来才读得到。这中间有一段窗口期:文档已经开始画,主题还没定。
CC Switch 仓库里对这件事的记录很直接。提交 d4fefefcb9dfb7d5f36fe031aa97d2451971ae5b 的标题写的是 fix(windows): eliminate startup white-black flash (FOUC) (#6252),作者 SaladDay,日期 2026-08-16;发布说明 docs/release-notes/v3.20.0-zh.md:20 与 :163 把同一条记成「启动不再白/黑闪屏」。这一篇就顺着这个提交,把它落在代码里的三层结构拆开。
先说清楚这篇文章的依据
本文对应的仓库快照是 3217f725,仓库内版本号 3.20.1,核对日 2026-08-31,对照用的旧快照是 v3.19.2。所有结论都来自静态阅读源码与仓库文档:我们没有编译过这个项目,没有安装或运行过这个桌面应用,因此不会出现任何关于界面长相、动画观感或启动快慢的描述。凡是提到「闪屏」的地方,都是在转述提交标题和发布说明的文字,不是我们看到的现象。
第一层:src/index.html 第 7 行的那段 IIFE
src/index.html 一共只有 12 行。:10 是挂载点 <div id="root">,:11 引入 ./main.tsx——注意入口目录不是仓库根,vite.config.ts:7 把 root 设成了 "src"。而在 <head> 里,:7 有一行压缩过的立即执行函数,它做三件事:
- 读
localStorage.getItem("cc-switch-theme"),取不到就当作"system"; - 算一个布尔值:主题是
dark,或者主题是system且matchMedia("(prefers-color-scheme: dark)")命中; document.documentElement.classList.toggle("dark", d)。
整段包在 try{}catch{} 里,读存储失败不影响后续加载。
它必须写成内联脚本而不是打包进 bundle,原因就在位置上:它排在 <head> 里、排在 main.tsx 那个 type="module" 的脚本之前。要让 .dark 类在首屏绘制之前就挂到 <html> 上,这段代码就不能等任何网络或模块加载。
.dark 这个类名不是随手取的。tailwind.config.cjs:4 写的是 darkMode: ["selector", ".dark"],:3 的 content 扫描范围又恰好包含 ./src/index.html。也就是说,Tailwind 生成的所有暗色变体都以 .dark 祖先选择器为条件,而挂这个类的动作被前置到了 HTML 里,两处闭环。主题变量本体在 src/index.css:5-61,:root 与 .dark 各定义同一组 HSL 变量(--background、--foreground、--card、--border 等);:134 的 html { color-scheme: light } 与 :144-146 的 html.dark { color-scheme: dark } 则是为了让原生滚动条、表单控件这类不吃 CSS 变量的东西也跟着换。
顺带一个耦合点:cc-switch-theme 这个键的读写点散在三个文件里——src/index.html:7 直接用字面量,src/components/theme-provider.tsx:30 是默认参数值、:37 与 :52 经变量 storageKey 读写,src/main.tsx:99,124 两处又各把字面量显式传了一遍。也就是说这个字符串在骨架层被独立写了四次,没有抽成共享常量。改名要三个文件一起改。
内联脚本撞上 CSP:那串 SHA-256 从哪来
Tauri 应用的内容安全策略写在 src-tauri/tauri.conf.json:29,是一行长字符串。v3.19.2 时它的 script-src 段是:
script-src 'self'
v3.20.1 变成了:
script-src 'self' 'sha256-ls27Lc1OytcVawXPVMplz6LyKz2i9KYAVBw3u79qyB4='
多出来的正是内联脚本的哈希白名单写法:CSP 允许把一段内联脚本内容的摘要列进 script-src,从而在不放开 unsafe-inline 的前提下只放行这一段(具体语义以 CSP 规范与 Tauri 官方文档为准)。所以「加一段内联脚本」这件事在 Tauri 项目里并不是改一个文件就完事——d4fefefc 这个提交的 diff 同时覆盖了 src/index.html(+1 行)、src-tauri/tauri.conf.json(改 1 行)和 src-tauri/src/lib.rs(+22 行)。少改中间那个文件,脚本就进不了白名单。
这串哈希可以自己复算。在仓库根目录下:
python3 -c "
import hashlib,base64,re
h=open('src/index.html','r',encoding='utf-8').read()
s=re.search(r'<script>(.*?)</script>',h,re.S).group(1)
print('sha256-'+base64.b64encode(hashlib.sha256(s.encode()).digest()).decode())
"
输出与 tauri.conf.json:29 里写的那串逐字符一致。这个复算过程说明了一件对维护者更要紧的事:哈希覆盖的是 <script> 与 </script> 之间的字节序列本身。给那段代码补一个空格、换一次缩进、加一行注释,摘要立刻变,而配置里那串是硬编码的常量,没有任何构建步骤会替你更新它。这是一个格式化工具就能碰坏的耦合——顺便说一句,这个仓库的 CI 里 pnpm format:check 跑的是 prettier --check "src/**/*.{js,jsx,ts,tsx,css,json}",glob 不含 .html。
第二层:Windows 侧把「什么时候显示窗口」也管起来
只靠 CSS 类还不够,d4fefefc 那 22 行 Rust 改动动的是另一个维度:窗口本身什么时候出现。
src-tauri/tauri.conf.json:13-27 只声明了一个窗口,label 为 main,而且 visible: false。Windows 平台走 src-tauri/tauri.windows.conf.json 覆盖,:8 把 titleBarStyle 从 Overlay 改成 Visible、:7 给了标题 CC Switch,但 :9 同样保留 visible: false。窗口默认不显示,由 Rust 侧决定何时 show()。
src-tauri/src/lib.rs:390-402 是一段 #[cfg(target_os = "windows")] 包起来的 on_page_load 钩子,:392 先声明一个 AtomicBool 保证只处理一次首屏,然后要四个条件同时成立才调 webview.window().show():
- webview 的 label 是
main; - 事件是
PageLoadEvent::Finished; - URL 的 scheme 不是
about; - 设置里的
silent_startup为假。
非 Windows 平台不走这条路,仍然是启动即 let _ = window.show();。也就是说这套「先隐藏、页面加载完成再显示」的时序是平台特定的补丁,不是全平台统一行为——本站读者以 Windows 居多,这一层恰好只对你们生效。另外注意第四个条件:静默启动被打开时,这个钩子不会显示窗口,显示逻辑归别处管。
第三层:ThemeProvider 与那个 MutationObserver 桥
内联脚本只负责首帧。React 起来之后,主题的所有权交给 src/components/theme-provider.tsx(154 行)。:10 定义 type Theme = "light" | "dark" | "system",:29-30 的默认值是 defaultTheme = "system"、storageKey = "cc-switch-theme"(这是 v3.20.1 的默认配置,可配,且会随版本变)。它挂了四个 effect:
:47-53主题变化写回 localStorage;:55-72先root.classList.remove("light","dark"),再按当前值加类,system时查一次 media query;:74-96订阅 media query 的change事件,仅当theme === "system"才跟随切换;:98-128把主题同步到原生窗口,invoke("set_window_theme", { theme })。
第四个 effect 里 :116-118 那段注释值得单独看:system 时要把字符串 "system" 原样传给后端,让 Tauri 用 None(跟随系统),这样 WebView 的 prefers-color-scheme 才和真实系统主题一致,第三个 effect 的 media query 监听才会在系统换主题时触发。三个 effect 之间是有依赖关系的,不是四段互不相干的副作用。这个 effect 失败只 console.debug(:112),注释写「例如不在 Tauri 中运行时」;:104 与 :125-127 用 isCancelled 闭包变量防止清理后仍写窗口主题。
这一层里还有一条旁路,专给不消费 Context 的组件用:src/hooks/useDarkMode.ts:10-29 不读主题 Context,而是用 MutationObserver 观察 document.documentElement 的 class 属性(:21-24 的 attributeFilter: ["class"]),返回一个布尔 isDark。:3-9 的注释写明了用途:CodeMirror 这类编辑器需要的是一个布尔 darkMode prop。真相源始终是 <html> 上那个类,前面三方(内联脚本、ThemeProvider、这个 hook)都围着它转。
两处对不上的地方
其一,内联脚本和 ThemeProvider 的类操作不完全等价。 src/index.html:7 只做 toggle("dark", d),从不添加 light;theme-provider.tsx:61-71 则是先 remove 掉两个类再显式补上 light 或 dark 之一。结论就是:从首帧到 React 挂载之间,<html> 上不会有 light 类。这是否会造成任何可见差异,我们没有运行过应用,不作推断。
其二,提交信息里有一件事没落到代码里。 d4fefefc 的提交信息第一句是 “Set window backgroundColor to match the dark theme surface…”,但在这份快照里,grep -rn "backgroundColor" src-tauri/ --include=*.json --include=*.rs 是 0 命中,grep -rn "background_color" src-tauri/src 同样是 0。提交信息描述的三件事里,设置窗口背景色这一件没有出现在合入的改动中。差异记录到此为止。
稳定的部分
对照 v3.19.2 与 v3.20.1 的骨架层 diff:src/main.tsx、src/components/theme-provider.tsx、src/lib/query/queryClient.ts、src/lib/platform.ts 这几个文件的增删行数都是 0,一行没动。这一版骨架层的改动几乎全落在「多接一个被托管的 CLI」那条线上。防闪这套东西是在 v3.20.0 那一轮加进来、v3.20.1 原样保留的——它是一处一次性成本,而不是持续维护的负担,唯一需要留心的就是前面说的哈希耦合:改动 src/index.html:7 那一行时,src-tauri/tauri.conf.json:29 必须同步。
本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册、路由指南与发布说明,以及 src/、src-tauri/、tests/ 的源码整理,核对日 2026-08-31,对应仓库快照 3217f725(仓库内版本号 3.20.1)。本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用,因此不涉及界面外观、操作手感与切换速度的任何描述。文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。
留言讨论
评论发布后会被人工复核,违规内容将被删除。
如果发表没有反应,可以前往联系我们告诉我们。