react 最新版本的文档已经上线很久了,采用了 Next.js 开发。支持在线编辑,暗黑等相关功能。
笔者最近在使用 react, remix 和 tailwindcss 开发成语类的 wordle 游戏。遇到了一个问题是在 ssr 场景下切换暗黑模式会导致页面闪动。
那么如何在 SSR 下使用暗黑主题,使页面不闪动?
暗黑主题的实现,通常是把主题变量存在到 localStorage 中。当页面渲染的时候从 localStorage 中读取暗黑主题的变量同时设置对应的 class 为 dark。
当前游戏是采用 remix + tailwindcss 开发的。因此暗黑模式也是参考 tailwindcss 的暗黑来设置的。比如:
// On page load or when changing themes, best to add inline in `head` to avoid FOUCif ( localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { document.documentElement.classList.add('dark')} else { document.documentElement.classList.remove('dark')}// Whenever the user explicitly chooses light modelocalStorage.theme = 'light'// Whenever the user explicitly chooses dark modelocalStorage.theme = 'dark'// Whenever the user explicitly chooses to respect the OS preferencelocalStorage.removeItem('theme')
通过读取 localStorage 的 theme 变量来判断是否是暗黑模式,如果是暗黑模式则调用
document.documentElement.classList.add('dark')
否则调用
document.documentElement.classList.remove('dark')
因此组件可能是这样的(假设点击切换暗黑和亮色的 icon 是在 Header 组件中)
import { useState, useEffect } from 'react'export default function Header() { const [isDark, setIsDark] = useState(() => { if (typeof document === 'undefined') return false return document.documentElement.classList.contains('dark') }) useEffect(() => { const theme = localStorage.getItem('theme') if (theme === 'dark') { // 添加 dark class document.documentElement.classList.add('dark') } }, []) return <header>{isDark ? <Sun /> : <Moon />}</header>}
如果页面是 CSR 模式那么这里是没有问题的,页面也不会有闪动。但是如果页面是 SSR 渲染的,就会存在闪动的问题。不妨把这个组件分成两个阶段来看:
因为存在这两个阶段,因此如果页面已经被设置为暗黑模式了,那么页面将会发生服务端渲染返回的白色底色 -> 客户端渲染返回的暗黑底色这样的转变。
那么如何解决这个闪动的问题呢?
因为主题变量是存储在 localStorage 中的,所以在服务端是无法使用 localStorage 变量,只能在客户端使用。如果在客户端使用应该在哪个地方放置设置暗黑变量的逻辑呢?
既然 useLayoutEffect 和 useEffect 都不行,那是不是暗黑模式在 SSR 中就无法实现了呢?答案是可以的。
我们只需要尽可能早的执行脚本,就可以避免视觉中出现闪动的现象。因为 SSR 返回的是 html 的字符串(或流式渲染的 buffer),最终会被浏览器解析成页面。那么只要保证脚本尽可能早的执行,是否就可以避免出现闪动的现象呢?不妨看看 react 文档中是如何在 SSR 下实现暗黑模式避免页面闪动的。
因为 react 文档是使用 Next.js 开发的,因此在 _document 文件中增加以下脚本
<script dangerouslySetInnerHTML={{ // 增加一个自执行的函数 __html: ` (function () { function setTheme(newTheme) { window.__theme = newTheme; if (newTheme === 'dark') { document.documentElement.classList.add('dark'); } else if (newTheme === 'light') { document.documentElement.classList.remove('dark'); } } var preferredTheme; try { preferredTheme = localStorage.getItem('theme'); } catch (err) { } window.__setPreferredTheme = function(newTheme) { preferredTheme = newTheme; setTheme(newTheme); try { localStorage.setItem('theme', newTheme); } catch (err) { } }; var initialTheme = preferredTheme; var darkQuery = window.matchMedia('(prefers-color-scheme: dark)'); if (!initialTheme) { initialTheme = darkQuery.matches ? 'dark' : 'light'; } setTheme(initialTheme); darkQuery.addEventListener('change', function (e) { if (!preferredTheme) { setTheme(e.matches ? 'dark' : 'light'); } }); })(); ` }}/>
因为这个脚本是在 body 下的第一个元素,因此也会被优先解析。脚本内部是一个自执行的函数,因此在服务端返回后,客户端解析的过程中就会获取主题信息,并设置主题信息。
主题的闪动已经解决了,那么 icon 的切换是如何避免闪动的呢?可以通过 css 来实现,tailwindcss 支持了暗黑模式下的各种展示形式。
<div className="block dark:hidden"> <button type="button" aria-label="Use Dark Mode" onClick={() => { window.__setPreferredTheme('dark'); }} className="hidden lg:flex items-center h-full pr-2"> {darkIcon} </button></div><div className="hidden dark:block"> <button type="button" aria-label="Use Light Mode" onClick={() => { window.__setPreferredTheme('light'); }} className="hidden lg:flex items-center h-full pr-2"> {lightIcon} </button></div>
在 darkIcon 上增加 block dark:hidden,在 lightIcon 上增加 hidden dark:block 来实现对应 icon 的显隐。
总结:
在 SSR 模式下使用暗黑模式主要通过以下两点
在 react 文档的脚本中有这样一处代码
var darkQuery = window.matchMedia('(prefers-color-scheme: dark)')if (!initialTheme) { initialTheme = darkQuery.matches ? 'dark' : 'light'}setTheme(initialTheme)darkQuery.addEventListener('change', function (e) { if (!preferredTheme) { setTheme(e.matches ? 'dark' : 'light') }})
通过 window.matchMedia 获取 css 中媒体查询的返回值,如果满足返回 true,否则返回 false。同时通过注册监听器达到实时获取返回值的效果。