深色站切浅色,最容易犯的错不是配色,是顺序:等 React 挂载完再读 localStorage,用户会先看到一帧深色,然后闪成浅色。
这一帧很短,但它发生在一个站最贵的位置——首屏。
主题必须在 React 之前定
index.html 里有一段同步脚本,就干一件事:
<script>
;(() => {
try {
const stored = localStorage.getItem('theme')
const system = window.matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: 'dark'
document.documentElement.dataset.theme =
stored === 'light' || stored === 'dark' ? stored : system
} catch {
/* localStorage 不可用时退回默认深色 */
}
})()
</script>它写在 <head> 里,阻塞渲染,所以在第一帧之前就已经把 data-theme 放好了。这也是唯一能根治闪烁的位置。
于是不能再用 useState
如果 React 里再存一份 theme,就有了两个真相:DOM 上的 data-theme,和 state 里的字符串。它们会分叉——尤其是在另一个标签页切换主题、或者用户在 DevTools 里手改属性的时候。
所以 src/lib/theme.ts 把 DOM 当唯一真相源,React 只订阅:
function getSnapshot(): Theme {
return document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'
}
function getServerSnapshot(): Theme {
return 'dark'
}
export function useThemeState() {
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
// ...
}getServerSnapshot 返回固定的 dark,是这段代码里最关键的一行:预渲染输出的永远是深色版,浏览器 hydration 之后才切成用户偏好。如果没有它,服务端拿不到 localStorage,React 会报 mismatch,严重时整棵树重渲染。
CSS 侧只做两级映射
第一级是语义变量,两套主题各一份:
:root {
--bg: #0b0f19;
--fg: #e6e8ee;
}
[data-theme='light'] {
--bg: #fcfcfd;
--fg: #16181d;
}第二级把它们映射成工具类,关键是 inline:
@theme inline {
--color-bg: var(--bg);
--color-fg: var(--fg);
}inline 的含义是"把这个值原样写进工具类"。产物里可以直接验证:
.bg-bg { background-color: var(--bg) }如果工具类引用的是中间变量(--color-bg),切换主题就多了一跳;那一跳跟不跟着 data-theme 走,取决于它声明在哪个选择器上。inline 把这一跳去掉,让工具类直接落到会变的那个变量上。
这类问题不会报错,只会表现为"有一部分颜色不跟着变"——所以判断标准不是配置写没写,而是去产物里搜一条工具类,看它最终引用的是谁。
主题库解决的不是这个问题
next-themes 这类库的核心机制和上面完全一样:内联脚本 + data-* 属性 + 订阅。它能省的是"记住偏好、跟随系统、多标签同步"这些边角,代价是一个运行时依赖和一层抽象。
这个站的边角只有一条:localStorage 写入失败时静默忽略(隐私模式)。try/catch 三行就够了,不值得为它引一个包。