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>
)
}