页面加载时,深色模式闪白的问题解决
现象:
网页应用内已经切换到了深色模式,正常点击页面、切换菜单,看起来都没什么问题。但是用户一刷新,页面会先变白一下,然后才变回深色模式。
后面我又发现,如果用了 React 的懒加载路由,切换到某些页面的时候,也可能出现类似的白一下。
这个问题不能简单理解成"深色模式没生效",更准确一点说,前一个问题是深色模式生效得不够早,后一个问题是中间某个加载阶段没有被深色样式兜住。
先看页面刷新时发生了什么
简单理解一下浏览器加载一个页面的过程:
- 用户访问地址。
- 浏览器请求
index.html。 - 浏览器开始解析 HTML,构造 DOM 树。
- 解析过程中发现 CSS、JS、图片、字体等资源,再去下载这些资源。
- CSS 下载和解析后,会形成样式树,也就是 CSSOM。
- DOM 和 CSSOM 合在一起,生成渲染树。
- 浏览器计算布局,然后绘制页面。
- React 入口 JS 执行。
- React 执行
createRoot(...).render(<App />),开始接管#root。
这里有一个点要注意:不是说浏览器一定要等第一次绘制之后才加载 JS。
浏览器在解析 HTML 的时候,看到资源标签就可能开始下载资源。只是不同的 script 写法,执行时机不一样。
比如普通的:
xml
<script src="/main.js"></script>
它会阻塞 HTML 解析,下载完就执行。
如果是:
xml
<script defer src="/main.js"></script>
它会先下载,但是不会马上执行,一般等 HTML 解析完成后再执行。
如果是 Vite 里常见的:
xml
<script type="module" src="/src/main.tsx"></script>
它的行为也更接近 defer,不会像普通 script 那样卡住 HTML 继续解析。
所以这里不能简单说"先绘制,再加载 JS"。更准确的说法是:浏览器一边解析 HTML,一边发现资源并下载;至于 JS 什么时候执行,要看 script 的类型和位置。
React 到底什么时候介入
React 真正介入页面,是入口文件执行到这一步:
javascript
createRoot(document.getElementById('root')!).render(<App />)
在这之前,页面上展示什么,其实和 React 没关系,主要靠 index.html、内联样式、外部 CSS,以及你有没有提前执行一段脚本去设置主题。
在这之后,React 才开始接管 #root,然后渲染自己的组件树。
所以如果深色模式只写在 React 组件里,比如页面加载后再用 useEffect 去读 localStorage,再给页面加 dark,那这个时间点就已经偏晚了。
因为 useEffect 是 React 渲染提交之后才执行的。浏览器有可能已经先按默认白色背景绘制过一帧了。等 React 再把深色模式补上,用户看到的就是:先白一下,再变黑。
为什么刷新会闪白
用户刷新页面时,不是简单地"页面恢复到之前状态"。
即使 HTML、CSS、JS 都命中了缓存,浏览器也还是会重新走一遍页面构建流程:
css
重新拿到 HTML
重新构造 DOM 树
重新构造 CSSOM
重新合成渲染树
重新执行 JS
重新挂载 React
缓存只能让资源获取更快,不能跳过 DOM 构建,也不能跳过 React 重新挂载。
所以如果最开始的 html、body、#root 没有深色背景,而深色模式又要等 React 启动后才设置,那刷新时就很容易闪白。
这个问题的关键就是一句话:
深色模式不能只存在于 React 里面,它还得在 React 启动前就让浏览器知道。
为什么懒加载路由也会闪白
懒加载路由大概是这样写的:
javascript
const UserPage = lazy(() => import('./pages/user'))
当用户进入这个页面时,React 不是立刻就能渲染 UserPage。它要先去加载这个页面对应的 JS chunk。
过程大概是这样:
swift
用户点击进入新路由
React Router 准备渲染新页面
React 发现这个页面是 lazy 组件
浏览器去请求对应的 JS chunk
chunk 还没下载完
React 先显示 Suspense fallback
chunk 下载并执行完成
React 再显示真正的页面
闪白通常就发生在 Suspense fallback 这一段。
如果 fallback 是空的,或者用了一个默认白底的 loading,或者根容器本身没有稳定的深色背景,那旧页面一卸载,新页面又还没回来,中间就会露出白色。
所以 index.html 里的初始 loading,只能解决 React 启动前的问题。React 启动后的懒路由 pending,还得靠 Suspense fallback 自己处理。
解决方法
可以在 head 里放一小段很早执行的脚本。它只做一件事:尽量在首次绘制前,把主题 class 挂到 html 上。
xml
<script>
(function () {
try {
var raw = localStorage.getItem('themeSettings')
var settings = raw ? JSON.parse(raw) : {}
var isDark = settings.darkMode === true || settings.theme === 'dark'
document.documentElement.classList.toggle('dark', isDark)
document.documentElement.style.colorScheme = isDark ? 'dark' : 'light'
} catch (e) {
document.documentElement.classList.remove('dark')
document.documentElement.style.colorScheme = 'light'
}
})()
</script>
然后根节点的背景也要兜住:
css
:root {
--app-bg: #f6f7fb;
--app-text: #111827;
--loading-color: #1677ff;
}
:root.dark {
--app-bg: #050505;
--app-text: #f5f5f5;
--loading-color: #69b1ff;
}
html,
body,
#root {
min-height: 100%;
margin: 0;
background: var(--app-bg);
color: var(--app-text);
}
这里的重点不是把某个页面组件背景改成黑色,而是让 html、body、#root 这些最外层节点一开始就有正确背景。
这样即使 React 还没启动,或者页面组件还没加载出来,也不会露出浏览器默认的白底。
React 启动后,切换主题的时候也继续维护同一个地方:
javascript
function applyTheme(isDark: boolean) {
document.documentElement.classList.toggle('dark', isDark)
document.documentElement.style.colorScheme = isDark ? 'dark' : 'light'
}
也就是说,这里不是写两套主题系统。更像是同一套主题状态,在两个时间点都要处理一下:
React 启动前:先让浏览器知道当前是什么主题
React 启动后:React 继续维护这个主题状态
懒路由的 loading 也要跟上
路由懒加载的时候,fallback 也不能随便写一个白底 loading。
可以单独做一个路由 loading,让它也吃同一套 CSS 变量:
javascript
function RouteLoading() {
return (
<div className="route-loading">
<div className="route-loading__spinner" />
<div className="route-loading__text">页面加载中...</div>
</div>
)
}
css
.route-loading {
min-height: 240px;
display: grid;
place-items: center;
background: var(--app-bg);
color: var(--app-text);
}
.route-loading__spinner {
width: 28px;
height: 28px;
border: 3px solid color-mix(in srgb, var(--loading-color) 24%, transparent);
border-top-color: var(--loading-color);
border-radius: 999px;
animation: route-loading-spin 0.8s linear infinite;
}
@keyframes route-loading-spin {
to {
transform: rotate(360deg);
}
}
这里还有个细节:Suspense 的位置也很重要。
如果把整个应用外壳都包进一个大的 Suspense,懒路由 pending 的时候,可能整个 layout 都被 fallback 替换掉,页面跳动会比较明显。
更好的方式是保留外层 Layout,只在页面内容区显示 loading。这样用户看到的是"内容区在加载",不是"整个应用突然没了"。
企业项目一般怎么做
更工程化一点的理解,不是"写两套深色模式",而是"一套主题状态,覆盖两个阶段"。
如果是 SPA,通常就是 head 里放一个很小的 no-flash 脚本,React 启动后继续同步 html.dark。
如果是 SSR 项目,那更好处理。服务端可以根据 cookie 直接把主题 class 渲染到 <html> 上,浏览器拿到 HTML 的时候就已经知道当前主题了。
如果项目只跟随系统主题,不让用户手动切换,那可以直接用 prefers-color-scheme,甚至不一定需要 JS。
但是只要支持用户手动选择深色/浅色模式,就基本绕不开"React 启动前先初始化主题"这件事。
最后怎么验证
可以这样测:
- 开启深色模式后刷新页面,看首屏会不会白一下。
- DevTools 里打开 Disable cache,再刷新一次。
- Network 调成 Slow 3G,切换懒加载路由。
- 看
html、body、#root有没有稳定背景。 - 看
Suspense fallback是不是也用了同一套背景变量。
总结一下:
刷新闪白,主要是 React 启动前主题没来得及生效。
懒路由闪白,主要是 chunk 加载时 fallback 没兜住深色背景。
所以解决这个问题,不是单纯改某一个 loading,而是把主题状态从最外层兜住,然后让 React 启动前、React 运行中、懒路由 pending 这几个阶段都保持一致。