文章

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 正好能做:

  1. document.startViewTransition(callback) 先给当前页面拍一张快照
  2. 在 callback 里切换主题
  3. 浏览器再拍一张新页面的快照,把两张叠起来
  4. 给新快照做一个从 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。

更多