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>
中内联一个脚本,这个脚本会在页面其他元素渲染前执行:
- 该脚本会立即读取本地存储和系统偏好的值
- 然后直接操作 document.documentElement 加入主题类名
- 这个时机早于页面元素的渲染
所以页面渲染时已经应用了正确的主题类名,避免了主题延迟导致的闪屏。同时配合前文说的客户端插件,实现本地的系统深色模式切换监听和更改的接口方法。
接下来就看看怎么使用吧。
使用演示
现在,我们就来看看如何使用。
首先是安装:
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就不存在这个问题。
至于,后续有优化,就等待各位吴彦祖们啦。