页面加载时,深色模式闪白的问题解决

页面加载时,深色模式闪白的问题解决

现象:

网页应用内已经切换到了深色模式,正常点击页面、切换菜单,看起来都没什么问题。但是用户一刷新,页面会先变白一下,然后才变回深色模式。

后面我又发现,如果用了 React 的懒加载路由,切换到某些页面的时候,也可能出现类似的白一下。

这个问题不能简单理解成"深色模式没生效",更准确一点说,前一个问题是深色模式生效得不够早,后一个问题是中间某个加载阶段没有被深色样式兜住。

先看页面刷新时发生了什么

简单理解一下浏览器加载一个页面的过程:

  1. 用户访问地址。
  2. 浏览器请求 index.html
  3. 浏览器开始解析 HTML,构造 DOM 树。
  4. 解析过程中发现 CSS、JS、图片、字体等资源,再去下载这些资源。
  5. CSS 下载和解析后,会形成样式树,也就是 CSSOM。
  6. DOM 和 CSSOM 合在一起,生成渲染树。
  7. 浏览器计算布局,然后绘制页面。
  8. React 入口 JS 执行。
  9. 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 重新挂载。

所以如果最开始的 htmlbody#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);
}

这里的重点不是把某个页面组件背景改成黑色,而是让 htmlbody#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 启动前先初始化主题"这件事。

最后怎么验证

可以这样测:

  1. 开启深色模式后刷新页面,看首屏会不会白一下。
  2. DevTools 里打开 Disable cache,再刷新一次。
  3. Network 调成 Slow 3G,切换懒加载路由。
  4. htmlbody#root 有没有稳定背景。
  5. Suspense fallback 是不是也用了同一套背景变量。

总结一下:

刷新闪白,主要是 React 启动前主题没来得及生效。

懒路由闪白,主要是 chunk 加载时 fallback 没兜住深色背景。

所以解决这个问题,不是单纯改某一个 loading,而是把主题状态从最外层兜住,然后让 React 启动前、React 运行中、懒路由 pending 这几个阶段都保持一致。

相关推荐
IT_陈寒2 小时前
Java 并行流把我坑惨了,这6小时加班值了
前端·人工智能·后端
anOnion11 小时前
构建无障碍组件之Menu Button pattern
前端·html·交互设计
用户479492835691511 小时前
claude Fable用不了?把Gpt 5.5pro接到你的claude code里
前端·后端
zhangxingchao14 小时前
Kotlin常用的Flow 操作符整理
前端
IT_陈寒16 小时前
React的useState居然还有这种坑?我差点删库跑路
前端·人工智能·后端
Pedantic17 小时前
SwiftUI 手势笔记
前端·后端
橙子家17 小时前
浏览器缓存之【结构化数据库与缓存】: IndexedDB、Cache storage 和 Storage buckets
前端
user205855615181317 小时前
X6 中边悬浮置顶,规避 `mouseleave` 事件丢失问题
前端
李明卫杭州17 小时前
CSS aspect-ratio 属性完全指南
前端