掌握React SSR:暗黑模式下的最新文档学习指南

发表时间: 2022-02-10 12:57

react 最新版本的文档已经上线很久了,采用了 Next.js 开发。支持在线编辑,暗黑等相关功能。

笔者最近在使用 react, remix 和 tailwindcss 开发成语类的 wordle 游戏。遇到了一个问题是在 ssr 场景下切换暗黑模式会导致页面闪动。

那么如何在 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 渲染的,就会存在闪动的问题。不妨把这个组件分成两个阶段来看:

  • ssr 阶段,isDark 为 false,因此页面呈现白色的底色。
  • csr 阶段,isDark 可能为 true,因此页面呈现暗色的底色。

因为存在这两个阶段,因此如果页面已经被设置为暗黑模式了,那么页面将会发生服务端渲染返回的白色底色 -> 客户端渲染返回的暗黑底色这样的转变。

那么如何解决这个闪动的问题呢?

因为主题变量是存储在 localStorage 中的,所以在服务端是无法使用 localStorage 变量,只能在客户端使用。如果在客户端使用应该在哪个地方放置设置暗黑变量的逻辑呢?

  1. useEffect 中使用。众所周知 useEffect 的执行时机是比较晚的,是在页面渲染完成之后。因此如果代码逻辑放在 useEffect 中执行是不行的,也会导致页面闪动。
  2. useLayoutEffect 中使用。useLayoutEffect 的执行时机比较早,是在页面渲染 DOM 之前。但是代码的执行最终都要依赖 react 的代码先加载完成,因此这个时机也是比较滞后的,也会导致页面闪动。

既然 useLayoutEffectuseEffect 都不行,那是不是暗黑模式在 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 模式下使用暗黑模式主要通过以下两点

  1. 尽可能早的执行脚本,通过注入的脚本实现暗黑模式。
  2. icon 使用 css 的方式实现显示和隐藏。

扩展,js 快速实现媒体查询

在 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。同时通过注册监听器达到实时获取返回值的效果。