Nuxt3在使用Tailwindcss情况下,如何优雅实现深色模式切换?

博客:https://www.mintimate.cn Mintimate’s Blog,只为与你分享

封面不能少ヾ(≧≦)〃

深色模式

随着前端更新,网站设计中,深色模式也成为了一种备受欢迎的设计趋势。可以帮助用户减少眼睛的负担,同时也更加适合在光线较暗的环境下使用。

打个比方,日常下班坐地铁、公车回家,地铁还好,都有灯,公车…… 有时候在跨区站的时候,司机会关灯,这个时候,深色模式就太刚需了😭。

换一个角度,现在系统都有深色模式,浏览器也有深色模式,那么看着别人的网站也有深色,自己的网站怎么能少?开发网站,这个优先级必须提高呀。(当然,一些网站确实就没必要设计深色,比如图形和图表为主要内容的网站、颜色为品牌标识的网站)。

Github的深色模式

比较有趣的是,Github的深色模式,目前要么选择跟随系统,要么在用户设置里进行手动设置;藏的比较隐蔽,似乎是怕打破用户的日常习惯?

再提一下,Gthub使用的Cookies进行存储,加快页面渲染:

Github的深色规则

{"color_mode":"auto","light_theme":{"name":"light","color_mode":"light"},"dark_theme":{"name":"dark_colorblind","color_mode":"dark"}

而我们使用Nuxt进行操作。

Nuxt3&Tailwindcss

恩…… 翻看腾讯云开发者社区,似乎做运维和后端的人比较多,鲜有人接受前端的;难道前端真的已死么🤔。

哈哈,不开玩笑~ 为了照顾更多小白用户,这里简单介绍什么是Nuxt3~

简单地说,Nuxt3就是一套SSR的Vue3框架,与之对等的,就是React的Next3。不同于Vue3官方的SSR方案依赖于Vue SSR库,在使用上需要手动编写一些服务器端渲染的代码,比如借助ExpressJS实现;Nuxt3则提供了更加简单、易用的服务器端渲染功能框架,可以轻松地实现服务器端渲染和预渲染,并且支持自动装载和静态生成。此外,Nuxt3还提供了一些额外的特性,比如自动生成路由、模块化开发、静态资源优化等,可以帮助我们更加高效地进行开发和部署。

当然,把Nuxt3直接和Next3画约等于,基本可以,即: Nuxt3 ≈ Next3

有利也有弊,Nuxt3把Vue3的生命周期钩子函数进行扩充。一些组件,在Vue3上可以使用,在Nuxt3上的Server端,可能就会出现问题。比如:目前arco-design: https://github.com/arco-design/arco-design-vue目前就和Nuxt3有严重冲突问题。

目前比较好的组件样式,我个人还是推荐: Tailwindcss: https://tailwindcss.com/

tailwindcss

哈哈,是不是有小伙伴有疑问,这个只是一个CSS组件库,和ElementUI那样的组件,不是一个概念?

Tailwindcss好在,就是有大量给予它开发的组件,比如我用的: NuxtLabs UI: https://ui.nuxtlabs.com/getting-started

深色模式实现

现在,我们确定了使用的技术框架和使用的样式,再来分析一下深色模式的实现思路,并且对比Tailwindcss是如何操作。

思考思路

样式叠加

老生常谈的方法,深色模式使用样式叠加来实现。举个例子,我们当前有一个DOM结构:

代码语言:html
复制
<!DOCTYPE html>
<html lang="zh">
  <head>
    <meta charset="UTF-8">
    <title>Dark Mode Example</title>
    <link rel="stylesheet" href="styles.css">
  </head>
  <body>
    <header>
      <h1>My Website</h1>
      <nav>
        <ul>
          <li><a href="#">Home</a></li>
          <li><a href="#">About</a></li>
          <li><a href="#">Contact</a></li>
        </ul>
      </nav>
    </header>
    <main>
      <h2>Welcome to my website</h2>
      <p>This is a paragraph of text.</p>
      <button>Click me</button>
    </main>
  </body>
</html>

那么,如何做到深色模式呢?

很简单,利用CSS的样式叠加:

代码语言:css
复制
# 限定含有dark类时候的main
.dark main{
    background-color: #1a1a1a;
    color: #ffffff;
}

并且,在展示页面时候;在<html>上,加上class="dark"

而Tailwindcss,官方实现的方法,就是我们这样:

代码语言:html
复制
<!-- 未激活Dark模式 -->
<html>
<body>
  <!-- 这里显示白色 -->
  <div class="bg-white dark:bg-black">
    <!-- ... -->
  </div>
</body>
</html>

<!-- 激活Dark模式 -->
<html class="dark">
<body>
<!-- 这里将是黑色 -->
<div class="bg-white dark:bg-black">
<!-- ... -->
</div>
</body>
</html>

不同的是,官方使用dark:来控制深色模式特定显示的样式,这样更有益于原子级操作,实现的效果:

亮色模式下
深色模式下

CSS变量

与此同时,如果页面上有很多的元素,一个一个设置颜色数值也不是办法,过多的颜色,也容易让人冲昏头脑。

我们使用CSS变量定义颜色:

代码语言:css
复制
:root {
--primary-color: #1a1a1a; /* 定义一个名为primary-color的自定义属性 */
}

.dark main {
background-color: var(--primary-color); /* 使用名为primary-color的自定义属性 */
color: white;
padding: 8px 16px;
}

再来看看Tailwindcss,其实它的方法就在上文已经明示,使用bg:进行亮色模式的区分。

切换模式

上述的思路已经完成,我们切换亮色和深色的方法,就是在<html>标签上,加上class="dark"即可。

使用JavaScript实现很简单:

代码语言:javascript
复制
// 使用localstorge存储深色和亮色模式
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { // 媒体查询系统模式
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}

切换按钮,在Vue3内也很简单实现:

代码语言:javascript
复制
<script setup>
import { ref, onMounted } from 'vue';

const dark = ref(false);

// 设置初始主题
onMounted(() => {
const localStorageTheme = localStorage.getItem('tool-theme-mode');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

if (localStorageTheme === 'dark' || (!localStorageTheme && prefersDark)) {
document.documentElement.classList.add('dark');
dark.value = true;
}
});

// 切换主题
const handleToggleTheme = () => {
dark.value = !dark.value;

if (dark.value) {
localStorage.setItem('tool-theme-mode', 'dark');
document.documentElement.classList.add('dark');
} else {
localStorage.removeItem('tool-theme-mode');
document.documentElement.classList.remove('dark');
}
};
</script>

存在问题

好的,我们看起来都已经完成了一切操作。

完成啦?

但是实际上,有一个问题: 刷新加载闪烁问题。

刷新时候,加载闪烁

造成这个原因,主要有:

  • 因为Nuxt3存在一个服务器Server端;所以,在深色模式渲染时候,存在重复渲染问题。
  • 既是使用<ClientOnly>进行限制,页面加载是自上而下,但是onMounted的生命周期,发生在DOM元素加载完毕;所以也会造成闪烁问题。
  • localstorge的加载存在滞后问题,本身就有延时;使用Cookie就不存在这个问题;但是这不是主要原因,因为我Hexo博客也是用localstorge存储~

解决上述问题,最直接的方法就是把主题的判断提前。

如何提前,最好把主题模式的判断,提升到<head>里呢?

其实Nuxt3官方就有保留扩展入口:Nuxt head

Nuxt head

这个配置其实是用来辅助SEO的,我们这里来穿插一个深色模式判断:

代码语言:javascript
复制
app:{
// 生成的静态资源根目录
buildAssetsDir:"/_toolStatic/",
rootId:"contentId",
head: {
// 深色模式判断
script: ["/darkVerify.js"],
},
},

添加暗色模式判断:

代码语言:javascript
复制
// darkVerify.js
if (
localStorage.getItem('tool-theme-mode') === "dark" ||
(!localStorage.getItem('tool-theme-mode') &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
) {
document.querySelector('html').classList.add('dark');
document.querySelector('html').classList.remove('light');
} else {
document.querySelector('html').classList.add('light');
document.querySelector('html').classList.remove('dark');
}

当然,刚刚的onMounted也需要改一下:

代码语言:javascript
复制
<script setup>
import { ref, onMounted } from 'vue';

const dark = ref(false);

// 设置初始主题
onMounted(() => {
const localStorageTheme = localStorage.getItem('tool-theme-mode');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// 监听
prefersDark.addEventListener('change', handleToggleTheme);
});

// 切换主题
const handleToggleTheme = () => {
dark.value = !dark.value;

if (dark.value) {
localStorage.setItem('tool-theme-mode', 'dark');
document.documentElement.classList.add('dark');
} else {
localStorage.removeItem('tool-theme-mode');
document.documentElement.classList.remove('dark');
}
};
</script>

穿插深色模式判断

之后,网页刷新,就可以看到效果:

页面刷新效果

后来又发现,怎么会存在两个key?

两个key

这个时候,才发现,我使用的NuxtLabs UI存在Nuxt Color Mode,这个好用而优雅的插件。

接下来,我们就使用Nuxt Color Mode来进一步优雅。

Nuxt Color Mode

注意⚠️,接下来的内容,需要对Nuxt3有一定了解。

其实原理和我们的head: {script: ["/darkVerify.js"]}是一样的。

我们进行简单的源码解析。

源码解析

观察客户端的插件:https://github.com/nuxt-modules/color-mode/blob/master/src/runtime/plugin.client.ts

我们从后往前看,先是默认情况下的模式判断,并创建媒体监听:

代码语言:javascript
复制
// 监听系统主题变化
let darkWatcher: MediaQueryList

function watchMedia() {
// 已经监听或不支持则返回
if (darkWatcher || !window.matchMedia) { return }

darkWatcher = window.matchMedia('(prefers-color-scheme: dark)')
darkWatcher.addEventListener('change', () => {
// 如果没强制指定模式并且默认是系统模式,设置系统模式
if (!colorMode.forced && colorMode.preference === 'system') {
colorMode.value = helper.getColorScheme()
}
})
}

// 首选项变化时处理
watch(() => colorMode.preference, (preference) => {

// 强制指定模式时返回
if (colorMode.forced) {
return
}

// 设置对应的值
if (preference === 'system') {
colorMode.value = helper.getColorScheme()
watchMedia()
} else {
colorMode.value = preference
}

// 保存在localStorage中
window.localStorage?.setItem(storageKey, preference)

}, { immediate: true })

// 值变化时添加删除类
watch(() => colorMode.value, (newValue, oldValue) => {
helper.removeColorScheme(oldValue)
helper.addColorScheme(newValue)
})

// 如果是系统模式,开始监听
if (colorMode.preference === 'system') {
watchMedia()
}

// mounted时初始化
nuxtApp.hook('app:mounted', () => {
if (colorMode.unknown) {
colorMode.preference = helper.preference
colorMode.value = helper.value
colorMode.unknown = false
}
})

// 提供colorMode
nuxtApp.provide('colorMode', colorMode)

那么代码中的强制指定模式,是怎么判断的呢? 其实在上面的路由判断里:

代码语言:javascript
复制
useRouter().afterEach((to) => {
const forcedColorMode = isVue2
? (to.matched[0]?.components.default as any)?.options.colorMode
: to.meta.colorMode

if (forcedColorMode &amp;&amp; forcedColorMode !== &#39;system&#39;) {
  colorMode.value = forcedColorMode
  colorMode.forced = true
} else {
  if (forcedColorMode === &#39;system&#39;) {
    // eslint-disable-next-line no-console
    console.warn(&#39;You cannot force the colorMode to system at the page level.&#39;)
  }
  colorMode.forced = false
  colorMode.value = colorMode.preference === &#39;system&#39;
    ? helper.getColorScheme()
    : colorMode.preference
}

})

通过上述的源码判断,我们就可以知道;它会在路由的访问过程中,读取Meta信息,进行强制模式切换。

所以,我们在定义路由或者页面时候,就可以添加强制选项:

代码语言:javascript
复制
# 使用路由配置的话
{
// 简体字、繁体字 互相转换
path: '/zhConvertTradSimp',
name: 'zhConvertTradSimp',
meta: {
colorMode: 'light',
},
component: () => import('@/pages/characterTool/zhConvertTradSimp.vue'),
},

使用Nuxt3 Page自动装载

<script setup>
definePageMeta({
colorMode: 'light',
})
</script>

强制页面使用亮色模式

这个时候,进入这个路由或者在这个页面进行刷新,就会发现默认会强制使用亮色模式:

强制效果

实际上,上述代码就是实现官网的这个功能:

对应功能

再往上看,为什么会有这段代码呢?

代码语言:javascript
复制
import { globalName, storageKey, dataValue } from '#color-mode-options'
if (dataValue) {
if (isVue3) {
useHead({
htmlAttrs: { [data-${dataValue}]: computed(() => colorMode.value) }
})
} else {
const app = nuxtApp.nuxt2Context.app
const originalHead = app.head
app.head = function () {
const head = (typeof originalHead === 'function' ? originalHead.call(this) : originalHead) || {}
head.htmlAttrs = head.htmlAttrs || {}
head.htmlAttrs[data-${dataValue}] = colorMode.value
return head
}
}
}

很明显,首先要弄清dataValue是什么?

有趣😯

在检查了其他地方源码和官方文档,可以知道nuxt.config.ts内可以配置的内容:

代码语言:typescript
复制
{
// 首选颜色模式,可以是 'light'、'dark' 或 'system'
// 如果设置为 'system',则会根据用户的系统设置自动选择颜色模式
// 默认值为 'system'
preference: 'system',

// 回退颜色模式,可以是 'light' 或 'dark'
// 如果首选颜色模式无法使用,则会使用回退颜色模式
// 默认值为 'light'
fallback: 'light',

// 存储颜色模式的键名,用于在本地存储中存储颜色模式的值
// 默认值为 'nuxt-color-mode'
storageKey: 'nuxt-color-mode',

// 自定义数据属性的名称,用于在 HTML 标签上添加颜色模式的值
// 如果设置为 undefined,则不会添加自定义数据属性
// 默认值为 undefined
dataValue: undefined
}

而我们的dataValue就是配置文件中的dataValue,默认为underfined所以默认是不会执行的。

再之后,我们就可以看看服务端代码了,服务端代码相对更简单,精减一下贴源码了:

代码语言:javascript
复制
import { reactive } from 'vue'

import type { ColorModeInstance } from './types'
import { defineNuxtPlugin, isVue2, isVue3, useHead, useState, useRouter } from '#imports'
import { preference, hid, script, dataValue } from '#color-mode-options'

// 重点ヾ(≧≦)〃 添加脚本到 head 中
const addScript = (head) => {
head.script = head.script || []
head.script.push({
hid,
innerHTML: script
})
const serializeProp = '__dangerouslyDisableSanitizersByTagID'
head[serializeProp] = head[serializeProp] || {}
head[serializeProp][hid] = ['innerHTML']
}

// 在路由切换后处理颜色模式的变化
useRouter().afterEach((to) => {
// 获取强制的颜色模式
const forcedColorMode = isVue2
? (to.matched[0]?.components.default as any)?.options?.colorMode
: to.meta.colorMode

// 如果存在强制的颜色模式,则更新颜色模式状态,并添加对应的自定义属性到 htmlAttrs 中
if (forcedColorMode &amp;&amp; forcedColorMode !== &#39;system&#39;) {
  colorMode.value = htmlAttrs[&#39;data-color-mode-forced&#39;] = forcedColorMode
  if (dataValue) {
    htmlAttrs[`data-${dataValue}`] = colorMode.value
  }
  colorMode.forced = true
} else if (forcedColorMode === &#39;system&#39;) {
  // 如果强制的颜色模式是 &#39;system&#39;,则输出警告信息
  // eslint-disable-next-line no-console
  console.warn(&#39;You cannot force the colorMode to system at the page level.&#39;)
}

})

// 将颜色模式状态对象作为 provide 提供给子组件
nuxtApp.provide('colorMode', colorMode)
})

没错,大部分和服务端的效果差不多,主要是这段,很重要:

代码语言:javascript
复制
const addScript = (head) => {
head.script = head.script || []
head.script.push({
hid,
innerHTML: script
})
const serializeProp = '__dangerouslyDisableSanitizersByTagID'
head[serializeProp] = head[serializeProp] || {}
head[serializeProp][hid] = ['innerHTML']
}

在服务器响应给客户端的数据中,在头部插入script代码,也就是基于浏览器存储的深色模式判断,我们追溯import { preference, hid, script, dataValue } from '#color-mode-options',紧接着,查看项目的module.ts,便可以找到script的来源:

根据module.ts往上找到script来源

最后,我们可以知道:它通过直接在<head>中内联一个脚本,这个脚本会在页面其他元素渲染前执行:

  1. 该脚本会立即读取本地存储和系统偏好的值
  2. 然后直接操作 document.documentElement 加入主题类名
  3. 这个时机早于页面元素的渲染
head内联脚本

所以页面渲染时已经应用了正确的主题类名,避免了主题延迟导致的闪屏。同时配合前文说的客户端插件,实现本地的系统深色模式切换监听和更改的接口方法。

真不错😁

接下来就看看怎么使用吧。

使用演示

现在,我们就来看看如何使用。

首先是安装:

代码语言:shell
复制
yarn add --dev @nuxtjs/color-mode

我使用的是NuxtLabs UI,在查看NuxtLabs UI的依赖包发现,它已经自带了@nuxtjs/color-mode

已经自带

因为使用了tailwindcss,所以,我们在tailwind.config.js上,添加:

代码语言:javascript
复制
module.exports = {
// 使用class进行暗色模式判断,而非媒体查询自动判断
darkMode: 'class'
}

然后呢? 我们还需要在项目nuxt.config.ts配置文件内激活配置:

代码语言:javascript
复制
colorMode: {
classSuffix: '', // 在 dark 或 light 类名后面添加 -mode 后缀
storageKey: 'tool-theme-mode' // 存储颜色模式的键名,用于在本地存储中存储颜色模式的值
},

最后,我们定义一个组件按钮,用于切换深色模式:

代码语言:javascript
复制
// components/ColorModeButtom.vue
<script setup>
let colorMode;
colorMode = useColorMode();

const isDark = computed({
get() {
return colorMode.value === 'dark';
},
set() {
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark';
},
});
</script>

<template>
<ClientOnly>
<UButton
:icon="
isDark
? 'i-heroicons-moon-20-solid'
: 'i-heroicons-sun-20-solid'
"
color="gray"
variant="ghost"
aria-label="Theme"
@click="isDark = !isDark"
/>
<template #fallback>
<div class="w-8 h-8 focus:outline-none focus-visible:outline-0 disabled:cursor-not-allowed disabled:opacity-75 flex-shrink-0 font-medium rounded-md text-sm gap-x-1.5 p-1.5 text-gray-700 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus-visible:ring-inset focus-visible:ring-2 focus-visible:ring-primary-500 dark:focus-visible:ring-primary-400 inline-flex items-center">
<span class="i-heroicons-cog-20-solid h-5 w-5"></span>
</div>
</template>
</ClientOnly>
</template>

效果还不错:

最终效果

是不是很优雅呢?

写在最后

好啦,本次“如何优雅实现深色模式切换?”的分享,就到这里啦。其实现在细想,还是存在优化的地方,比如: 如果想提高效率,localstorge的渲染还是存在延时读取问题,相对的Cookie就不存在这个问题。

至于,后续有优化,就等待各位吴彦祖们啦。

嘿嘿