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

博客:www.mintimate.cn

Mintimate's Blog,只为与你分享

深色模式

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

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

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

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

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

{"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/

哈哈,是不是有小伙伴有疑问,这个只是一个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实现很简单:

js 复制代码
// 使用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内也很简单实现:

js 复制代码
<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

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

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

添加暗色模式判断:

js 复制代码
// 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也需要改一下:

js 复制代码
<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?

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

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

Nuxt Color Mode

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

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

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

源码解析

观察客户端的插件:github.com/nuxt-module...

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

js 复制代码
// 监听系统主题变化  
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)

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

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

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

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

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

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

# 使用Nuxt3 Page自动装载
<script setup>
definePageMeta({
  colorMode: 'light',
})
</script>

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

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

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

js 复制代码
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内可以配置的内容:

ts 复制代码
{
  // 首选颜色模式,可以是 '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所以默认是不会执行的。

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

js 复制代码
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 && forcedColorMode !== 'system') {
      colorMode.value = htmlAttrs['data-color-mode-forced'] = forcedColorMode
      if (dataValue) {
        htmlAttrs[`data-${dataValue}`] = colorMode.value
      }
      colorMode.forced = true
    } else if (forcedColorMode === 'system') {
      // 如果强制的颜色模式是 'system',则输出警告信息
      // eslint-disable-next-line no-console
      console.warn('You cannot force the colorMode to system at the page level.')
    }
  })

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

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

js 复制代码
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的来源:

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

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

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

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

使用演示

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

首先是安装:

bash 复制代码
yarn add --dev @nuxtjs/color-mode

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

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

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

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

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

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

js 复制代码
// 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就不存在这个问题。

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

相关推荐
胡西风_foxww4 分钟前
【ES6复习笔记】数值扩展(16)
前端·笔记·es6·扩展·数值
mosen8686 分钟前
uniapp中uni.scss如何引入页面内或生效
前端·uni-app·scss
白云~️6 分钟前
uniappX 移动端单行/多行文字隐藏显示省略号
开发语言·前端·javascript
沙尘暴炒饭8 分钟前
uniapp 前端解决精度丢失的问题 (后端返回分布式id)
前端·uni-app
昙鱼22 分钟前
springboot创建web项目
java·前端·spring boot·后端·spring·maven
天天进步201528 分钟前
Vue项目重构实践:如何构建可维护的企业级应用
前端·vue.js·重构
2402_8575834928 分钟前
“协同过滤技术实战”:网上书城系统的设计与实现
java·开发语言·vue.js·科技·mfc
小华同学ai31 分钟前
vue-office:Star 4.2k,款支持多种Office文件预览的Vue组件库,一站式Office文件预览方案,真心不错
前端·javascript·vue.js·开源·github·office
APP 肖提莫32 分钟前
MyBatis-Plus分页拦截器,源码的重构(重构total总数的计算逻辑)
java·前端·算法
问道飞鱼44 分钟前
【前端知识】强大的js动画组件anime.js
开发语言·前端·javascript·anime.js