Nuxt 深浅色切换:不闪烁的图标,和真正的圆形揭开
右上角那个切换深浅色的按钮,看起来是全站最简单的组件,我前后改了四版。前三版各有各的毛病:页面报 hydration mismatch、每次打开页面按钮都要闪一下、太阳和月亮同时出现。第四版终于对了,然后我又把切换动画从「假的」换成了「真的」。
这篇按顺序讲这四版,最后给出完整代码。项目是 Nuxt 4 加 @nuxtjs/color-mode,不过问题的根源在 SSR,换成别的框架道理也一样。
根源:服务端不知道用户的主题
color-mode 的配置是这样的:
colorMode: {
classSuffix: '', // Tailwind 的 dark: 只认 dark 这个 class,默认的 -mode 后缀要去掉
preference: 'system',
fallback: 'light'
}
用户的主题可能来自系统设置(prefers-color-scheme),也可能是上次手动选过、存在 localStorage 里的值。这两样服务端都拿不到。所以服务端渲染的时候,useColorMode().value 永远是 fallback,也就是 light。
那用户看到的深色页面是怎么来的?color-mode 往 <head> 里插了一段内联脚本。浏览器解析到它时,页面还没开始绘制,脚本读出真实主题,给 <html> 加上 dark。所以首屏就是对的颜色,不会先白后黑。
记住这两个事实:服务端渲染出来的 HTML 永远按浅色写;<html> 上的 class 在首次绘制前就是对的。 后面所有问题都从这里来。
第一版:v-if,hydration mismatch
最直觉的写法:
<Icon v-if="colorMode.value === 'dark'" name="lucide:moon" />
<Icon v-else name="lucide:sun" />
深色用户打开页面:服务端按 light 渲染出太阳;客户端 hydration 时 colorMode.value 是 dark,要的是月亮。两边的 DOM 对不上,控制台报 hydration mismatch,Vue 只能丢掉服务端的结果重新渲染这一块。
第二版:ClientOnly,每次打开页面都闪一下
网上最常见的解法是把按钮包进 <ClientOnly>,服务端干脆不渲染它:
<ClientOnly>
<ThemeToggle />
<template #fallback>
<div class="size-8 animate-pulse rounded-full bg-muted" />
</template>
</ClientOnly>
mismatch 没了,代价是每次打开页面,右上角都先出现一个灰色的占位块,等 JS 加载完才换成真正的按钮。当时我给语言切换也这么包了一层,于是右上角永远是两个灰块闪一下。
ClientOnly 的本质是放弃 SSR:这块内容要等 JS 来了才有。对那些确实只能在客户端算的东西(比如读 localStorage 显示一个列表),它是对的。但主题图标不是:真实的主题早就写在 <html> 的 class 上了,根本不用等 JS。
第三版:两个图标都渲染,用 CSS 决定显示哪个
既然 <html> 上的 dark 在首次绘制前就对了,那就让 CSS 来选:
<Icon name="lucide:sun" class="dark:hidden" />
<Icon name="lucide:moon" class="hidden dark:block" />
服务端和客户端渲染的 DOM 完全一样(两个图标都在),没有 mismatch;显示哪个由 CSS 决定,首屏就对,不闪,也不需要 JS。
思路是对的,但上线后太阳和月亮同时显示了。
第四版:CSS 层叠的坑
原因在 CSS 的层(@layer)。Tailwind v4 的工具类都在 @layer utilities 里,而 @nuxt/icon 生成的图标样式是不在任何层里的。层叠规则是:不在层里的样式,永远赢过层里的样式,和选择器的优先级无关。
所以图标自己的 display 压过了我的 hidden,两个都显示出来。
解决方法是让显隐规则也不在层里,再靠选择器优先级取胜。写在组件的 scoped 样式里正好合适:
<style scoped>
.theme-icon-moon { display: none; }
.dark .theme-icon-sun { display: none; }
.dark .theme-icon-moon { display: block; }
</style>
scoped 样式会带上 [data-v-xxx] 属性选择器,优先级比图标那条 :where() 规则(优先级为 0)高,稳赢。
这个坑不只出现在图标上。只要某个库往页面里注入了不在层里的 CSS,你用 Tailwind 工具类去覆盖它,都会失败,而且没有任何报错。
切换动画:从假的到真的
图标解决之后,我又重做了切换动画。
原来的动画是假的:一个写死颜色的浅蓝渐变圆,从按钮处放大盖满屏幕,150ms 时在它底下偷偷换主题,再把圆淡出。你看到的其实是「一个蓝圈闪过去」,而不是新主题展开。
我想要的是:新主题从点击的地方以圆形向外揭开,圆里面就是真实的新页面。View Transitions API 正好能做:
document.startViewTransition(callback)先给当前页面拍一张快照- 在 callback 里切换主题
- 浏览器再拍一张新页面的快照,把两张叠起来
- 给新快照做一个从 0 扩大到铺满屏幕的
clip-path: circle()动画
现在 Chrome、Edge、Safari 和 Firefox(144 起)都支持同一文档内的 View Transitions,不支持的浏览器直接瞬间切换就行。
async function handleToggle(event: MouseEvent) {
const next = colorMode.value === 'dark' ? 'light' : 'dark'
const root = document.documentElement
const apply = async () => {
colorMode.preference = next
// color-mode 要到下一个 tick 才改 <html> 的 class,
// 而 view transition 在 callback 结束时就要拍新快照,所以这里同步改掉
root.classList.toggle('dark', next === 'dark')
root.classList.toggle('light', next === 'light')
await nextTick()
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!document.startViewTransition || reduce) {
await apply()
return
}
// 圆心是点击位置;键盘触发时没有坐标,退回按钮中心
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const x = event.clientX || rect.left + rect.width / 2
const y = event.clientY || rect.top + rect.height / 2
// 半径是圆心到最远那个角的距离,保证最后铺满屏幕
const radius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y))
root.classList.add('theme-switching')
const transition = document.startViewTransition(apply)
try {
await transition.ready
root.animate(
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`] },
{ duration: 480, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', pseudoElement: '::view-transition-new(root)' }
)
await transition.finished
} finally {
root.classList.remove('theme-switching')
}
}
配套的 CSS:
/* 关掉默认的交叉淡入:新旧快照都保持不透明,只靠 clip-path 揭开 */
html.theme-switching::view-transition-old(root),
html.theme-switching::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
/* 新快照叠在旧快照上面 */
html.theme-switching::view-transition-new(root) {
z-index: 1;
}
/* 切换期间停掉全站的 transition */
html.theme-switching *,
html.theme-switching *::before,
html.theme-switching *::after {
transition: none !important;
}
有三个细节,漏掉任何一个效果都会不对:
同步改 class。 color-mode 改 <html> 的 class 是异步的。如果只设 colorMode.preference,浏览器拍新快照时页面可能还是旧主题,揭开的就是一张和旧页面一样的图。
切换期间停掉 transition。 站上的按钮、链接都有 150ms 的颜色过渡。新快照是在切换那一瞬间拍的,这时颜色才过渡到一半,揭开时你会看到一个「还在变色」的新主题。所以切换期间要把所有 transition 关掉。
用 theme-switching 这个 class 把规则隔开。 Nuxt 开了 experimental.viewTransition 之后,页面切换也会走 View Transitions。上面那些关淡入、改层级的规则如果直接写在 ::view-transition-*(root) 上,页面切换的动画也会被改掉。挂在一个只在切换主题期间存在的 class 下面,两边互不影响。
另外照顾两类用户:系统开了「减弱动态效果」的,直接瞬间切换;用键盘触发的,event.clientX 是 0,圆心退回按钮中心。
回头看
四版走下来,其实是在学同一件事:先弄清楚每一个状态在什么时候、由谁决定。
- 主题由 color-mode 的内联脚本在首次绘制前决定,所以图标该交给 CSS,而不是等 JS
- 图标显不显示由 CSS 层叠决定,所以要知道哪些样式在层里、哪些不在
- 新快照在 callback 结束时拍,所以主题必须在那之前真正换好
ClientOnly 能让 mismatch 消失,但它是把问题藏起来,不是解决问题。遇到 hydration mismatch 的时候,先问一句:这个值在首次绘制前是不是已经确定了?如果是,大概率不需要等 JS。