Skip to content

Commit eed553b

Browse files
committed
docs: add post theme-blinking
1 parent af58f09 commit eed553b

7 files changed

Lines changed: 216 additions & 0 deletions

File tree

105 KB
Loading
72.8 KB
Loading
56.6 KB
Loading
1.4 KB
Loading
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
---
2+
title: 记一次主题闪烁问题
3+
author: Joey Zhong
4+
pubDate: 2026-04-07
5+
tags: ['react', 'tailwindcss', 'CSS', 'bugfix', 'theme', 'useEffect']
6+
category: '技术实践'
7+
---
8+
9+
为站点添加亮暗模式切换组件,却在黑暗模式下,遇到主题闪烁的问题,如图:
10+
11+
![主题闪烁](./blinking.gif)
12+
13+
## 主题初始化
14+
15+
添加切换组件之前,已经做好了亮暗模式的获取,即通过 `window.matchMedia('(prefers-color-scheme: dark)')` 获取信息,由于使用了 [tailwindcss](https://tailwindcss.com/) , 可控制 `document` 节点的 `'dark'` 类名切换页面亮暗模式。
16+
17+
在初始化站点亮暗模式之前,还注册了对 `document` 节点 `class` 变化的监听,根据有无 `'dark'` 类名,将亮暗模式信息持久化储存。
18+
19+
代码如下:
20+
21+
```tsx
22+
const getThemePreference = () => {
23+
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
24+
return localStorage.getItem('theme');
25+
}
26+
return window.matchMedia('(prefers-color-scheme: dark)').matches
27+
? 'dark'
28+
: 'light';
29+
};
30+
const isDark = getThemePreference() === 'dark';
31+
32+
if (typeof localStorage !== 'undefined') {
33+
const observer = new MutationObserver(() => {
34+
const isDark = document.documentElement.classList.contains('dark');
35+
localStorage.setItem('theme', isDark ? 'dark' : 'light');
36+
});
37+
observer.observe(document.documentElement, {
38+
attributes: true,
39+
attributeFilter: ['class'],
40+
});
41+
}
42+
43+
document.documentElement.classList[isDark ? 'add' : 'remove']('dark');
44+
```
45+
46+
## 主题闪烁
47+
48+
在浏览器暗黑模式下,进入页面,页面已经初始化为暗黑模式。但 `ModeToggle` 组件的渲染引发了主题闪烁。
49+
50+
组件代码如下:
51+
52+
```tsx
53+
import { Button } from '@/components/ui/button';
54+
import { Sun, Moon } from 'lucide-react';
55+
import { useState, useEffect } from 'react';
56+
57+
type Theme = 'light' | 'dark';
58+
59+
const ModeToggle = () => {
60+
const [theme, setTheme] = useState<Theme>('light');
61+
62+
useEffect(() => {
63+
const isDark = document.documentElement.classList.contains('dark');
64+
setTheme(isDark ? 'dark' : 'light');
65+
}, []);
66+
67+
useEffect(() => {
68+
const docClassList = document.documentElement.classList;
69+
if (theme === 'dark' && !docClassList.contains('dark')) {
70+
docClassList.add('dark');
71+
} else if (theme === 'light' && docClassList.contains('dark')) {
72+
docClassList.remove('dark');
73+
}
74+
}, [theme]);
75+
76+
const handleClick = () => {
77+
setTheme(theme === 'light' ? 'dark' : 'light');
78+
};
79+
80+
return (
81+
<Button size="icon" onClick={handleClick}>
82+
{theme === 'light' ? <Moon /> : <Sun />}
83+
</Button>
84+
);
85+
};
86+
87+
export default ModeToggle;
88+
```
89+
90+
分析一下执行流程。
91+
92+
组件将 `theme` 初始化为 `'light'`
93+
94+
初次渲染,依次执行组件的两个 `useEffect`
95+
96+
首先,是依赖项为空数组的 `useEffect`,此时,页面已经为暗黑模式,即 `document` 节点的 `class` 已经包含了 `'dark'` ,所以会执行 `setTheme('dark')`
97+
98+
接着依赖项为 `theme``useEffect`, 会执行 `docClassList.remove('dark')` , 将页面置为日间模式。
99+
100+
接着执行第二次渲染(由第一次渲染的 `setTheme('dark')` 触发),触发依赖项为 `theme``useEffect`
101+
102+
此时,`theme``'dark'` , `document` 节点也没有了 `'dark'` 类,所以将执行 `docClassList.add('dark')` , 将之前变为日间模式的页面重置为暗黑模式。那个日间模式的持续时间非常短暂,所以就有了动图上看到的闪烁。
103+
104+
很明显,问题就在依赖项为 `theme``useEffect` 里面将页面置为日间模式的代码。
105+
106+
### 修复
107+
108+
于是我不再将 `theme` 初始化为 `'light'` ,而是给它一个 `null` 值,让依赖值为空的那个 `useEffect` 根据 `document` 的类名来决定设置 `theme``'light'` 还是 `'dark'`
109+
110+
```tsx
111+
//...
112+
113+
type Theme = 'light' | 'dark' | null;
114+
115+
const ModeToggle = () => {
116+
const [theme, setTheme] = useState<Theme>(null);
117+
// ...
118+
};
119+
```
120+
121+
这样一来,主题闪烁消失了,暗黑模式下,组件的跳变也不见了,如图:
122+
123+
![主题闪烁修复](./blinking-fix.gif)
124+
125+
## 组件跳变问题
126+
127+
但是,又产生了新的问题,如下图,在日间模式下,刷新页面,右侧的 `ModeToggle` 组件会有一个跳变。
128+
129+
![组件跳变](./toggle-jump.gif)
130+
131+
组件代码如下:
132+
133+
```tsx
134+
type Theme = 'light' | 'dark' | null;
135+
136+
const ModeToggle = () => {
137+
const [theme, setTheme] = useState<Theme>(null);
138+
139+
useEffect(() => {
140+
const isDark = document.documentElement.classList.contains('dark');
141+
setTheme(isDark ? 'dark' : 'light');
142+
}, []);
143+
144+
useEffect(() => {
145+
const docClassList = document.documentElement.classList;
146+
if (theme === 'dark' && !docClassList.contains('dark')) {
147+
docClassList.add('dark');
148+
} else if (theme === 'light' && docClassList.contains('dark')) {
149+
docClassList.remove('dark');
150+
}
151+
}, [theme]);
152+
153+
const handleClick = () => {
154+
setTheme(theme === 'light' ? 'dark' : 'light');
155+
};
156+
157+
return (
158+
<Button size="icon" onClick={handleClick}>
159+
{theme === 'light' ? <Moon /> : <Sun />}
160+
</Button>
161+
);
162+
};
163+
```
164+
165+
日间模式下的渲染流程如下:
166+
167+
第一次渲染, `theme` 初始值为 `null`
168+
169+
依次执行两个 `useEffect` 。依赖项为空数组的 `useEffect` 执行 `setTheme('light')` , 这将触发第二次渲染。由于 `theme` 值为 `null` ,依赖项为 `theme``useEffect` 不会对主题产生影响。
170+
171+
在组件返回的 JSX 部分,可看到 `theme === 'light' ? <Moon /> : <Sun />` ,由于 `theme``null` , 此时将渲染 `Sun` 图标,而不是预期的 `Moon` 图标。问题就在这里。
172+
173+
第二次渲染, `theme` 值为 `'light'`
174+
175+
执行依赖项为 `theme``useEffect` , `document` 节点并没有 `'dark'` 类名,页面保持日间主题状态。
176+
177+
在组件返回的 JSX 部分,此时渲染了正确的 `Moon` 图标。
178+
179+
两次渲染了不同的图标,所以会有跳变。
180+
181+
### 修复
182+
183+
那么再添加逻辑判断修复吗?可行是可行。不过既然基于 tailwindcss 的 `'dark'` 类名控制亮暗模式,何不也通过它来控制图标渲染?更准确来说,是通过 CSS 的变形,来确定如何渲染图标。代码如下:
184+
185+
```tsx
186+
const ModeToggle = () => {
187+
// ...
188+
189+
return (
190+
<Button onClick={handleClick}>
191+
<Sun className="absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
192+
<Moon className="rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
193+
</Button>
194+
);
195+
};
196+
```
197+
198+
可以看到,为两个图标添加了一些类,来控制它们的样式。
199+
200+
`scale` 相关:通过缩放,来控制图标的”显隐“。在日间模式下, `Sun` 图标缩小为 0%,不可见; `Moon` 图标大小为 100%,即初始大小。夜间模式同理。
201+
202+
`absolute` : 让 `Sun` 图标脱离文档流,由 `Moon` 撑起宽高,使得两个图标只占一个图标的空间。由于没有给 `absoulute` 元素设置位置偏移量,所以它的位置参照原本的 `static` 定位。假如不给 `Sun` 设置 `absolute`, 就会产生两个图标大小的空间,如图:
203+
204+
![两倍占位](./double-space.png)
205+
206+
`rotate` 相关:在主题切换时,为图标提供旋转动画,优化体验。
207+
208+
修复效果如下:
209+
210+
日间模式:
211+
212+
![修复效果 - 日间模式](./light-fix.gif)
213+
214+
夜间模式:
215+
216+
![修复效果 - 夜间模式](./dark-fix.gif)
85.6 KB
Loading
64 KB
Loading

0 commit comments

Comments
 (0)