Plumbiu
banner
avatar
Plumbiu
这人很勤奋,啥都没留

Next.js 下主题切换最佳实践

在 CSR(客户端渲染)的情况下,主题切换往往不会出现问题,因为页面内容是 JS 动态渲染出来的,在渲染之前我们就能拿到用户系统或者 localStorage 中保存的主题,然而在 SSR/SSG 情况下,服务端返回的是一个完整的 HTML 页面,而 JS 执行的结果往往在 HTML 之后,如果 JS 设置的主题和 HTML/CSS 不一致,这时候便会出现闪烁的情况。

根据我的实践下来,最佳的解决方案还是在 <head> 中插入同步的 script 标签,很多人可能对同步 script避而远之,因为它会阻塞 HTML 的渲染,但是如果你的需求涉及以下需求的的话,可能不可避免(另外,一小段 script 标签对性能的影响可以忽略不计):

  • 用户首次打开页面,根据用户系统主题设置
  • 用户切换主题时,在 localStorage 保存当前主题
  • 用户第二次进入页面,根据 localStorage 设置主题

代码

准备 <script> 标签内容:

// <html data-theme='dark'>...</html> 和 localStorage.getItem('data-theme')const ThemeKey = 'data-theme'const Dark = 'dark'const Light = 'light'// 检测当前主题const media = window.matchMedia('(prefers-color-scheme: light)')// 获取 localStorage 保存的主题,如果没有,返回用户系统主题function getTheme() {  const localTheme = localStorage.getItem(ThemeKey)  const theme = localTheme ? localTheme : media.matches ? Light : Dark  return theme}// 获取 localStorage 保存的主题function getLocalTheme() {  return localStorage.getItem(ThemeKey)}// 设置 html 标签属性function setHtmlTheme(theme) {  document.documentElement.setAttribute(ThemeKey, theme)}// 根据传参设置主题function setTheme(theme) {  setHtmlTheme(theme)  localStorage.setItem(ThemeKey, theme)}// 应用当前主题const theme = getTheme()setTheme(theme)// 用户系统主题发生变化media.addEventListener('change', (e) => {  setTheme(e.matches ? Light : Dark)})// localStorage 主题发生变化window.addEventListener('storage', (e) => {  const theme = getLocalTheme()  setHtmlTheme(theme)})

配置 app/layout.tsx 文件:

function RootLayout({ children }) {  return (    <html lang="en" suppressHydrationWarning>      <head>        <script src="/theme.js"></script>      </head>      {/* .... */}    </html>  )}export default RootLayout

suppressHydrationWarning 是解决客户端水和时,和服务端 HTML 不一致的情况,因为我们的 JS 代码修改了 <html> 标签的属性。

一些踩坑的点

为什么不用 cookie?

cookie 也是一种解决方式,但是我并不推荐,因为你还要保持 localStorage 同步,会有非常大的心智负担。

更糟糕搞的是,我的网站采取的是 SSG,而 cookie 只能运行在服务端,使用它会破坏 SSG

为什么不用 css 媒体查询?

如果你的网站不需要根据 localStorage 来设置网页主题,那么这是一个好办法,如果你使用了 localStorage,那么也会出现闪烁问题

主题图标问题?

在我之前的博客版本中,如果用户当前是暗色主题,主题图标会是 <MoonIcon>,亮色主题则是 <SunIcon>,这里是通过 react 中的 useState + useLayoutEffect 设置的,但是这样图标也会出现闪烁,唯一解决方案就是等待 React 的虚拟 DOM 全部 mount 后(hydrate 水和完成)重新渲染,但是这样就失去了 ssr/ssg 的优势。

上述口述有误,应该是等到 HTML 加载完毕

const [theme, setTheme] = useState(null)useEffect(() => {  setTheme(Your_Thme)}, [])// 没有 theme 返回 null,页面会显示空白if (!theme) {  return null}return theme === 'light' ? <MoonIcon /> : <SunIcon />

我的解决方案是图标替换为下拉框,你可以在我的博客头部尝试,这里给出一个简单的版本:

export default function Selector() {  return (    <select      onChange={(e) => {        const value = e.target.value        // ... 设置主题 ...      }}    >      <option value="system">⚙️ system</option>      <option value="light">☀️ light</option>      <option value="dark">🌖 dark</option>    </select>  )}
评论区
CC BY-NC-SA 4.0 2024 © Plumbiu