导言
今天逛掘金的时候分析了一下掘金主页的页面结构,看到掘金为暗黑模式 和白天模式 定义了两套不同样式,根据不同的环境,切换不同的样式(白天模式在body上添加
data-theme="light"
属性,暗黑模式在body上添加data-theme="dark"
属性),不加属性默认白天模式。太棒了,我要复刻!!!
- 这是掘金白天模式 的截图(采用的
.light-theme
主题)
- 这是掘金暗黑模式 的截图(采用的
.dark-theme
主题)
目标
构建一个完整的 Vue 3 博客demo实现,包含类似掘金的主题切换功能,使用 CSS 变量和 Vue 的组合式 API。
- 使用css变量定义两套主题
- 封装hook监听主题变换,自定义事件监听器,触发事件相应
- 封装主题切换按钮
项目结构
markdown
vue-blog/
├── src/
│ ├── assets/
│ │ └── styles/ 样式
│ │ ├── _variables.scss
│ │ └── main.scss
│ ├── components/
│ │ ├── ThemeToggle.vue
│ │ ├── Navbar.vue
│ │ └── PostCard.vue
│ ├── composables/ 自定义主题切换hook函数
│ │ └── useTheme.js
│ ├── views/
│ │ ├── HomeView.vue
│ │ └── PostView.vue
│ ├── App.vue
│ └── main.js
├── public/
│ └── index.html
└── package.json
主题样式定义
这里我都定义在一起了,实际开发中,根据不同效果,拆分不同的文件比较好一些
src/assets/styles/_variables.scss
scss
// 基础变量
:root {
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--border-radius: 8px;
--spacing-unit: 16px;
--transition-duration: 0.3s;
}
// 浅色主题 一般的主题内容会有很多建议拆分为单个文件
[data-theme="light"] {
--color-bg: #ffffff;
--color-text: #1a1a1a;
--color-primary: #1e80ff;
--color-secondary: #f2f3f5;
--color-border: #e4e6eb;
--color-hover: rgba(0, 0, 0, 0.05);
}
// 深色主题 一般的主题内容会有很多建议拆分为单个文件
[data-theme="dark"] {
--color-bg: #1a1a1a;
--color-text: #ffffff;
--color-primary: #4a9eff;
--color-secondary: #2d2d2d;
--color-border: #3a3a3a;
--color-hover: rgba(255, 255, 255, 0.05);
}
主文件导入所有样式
src/assets/styles/main.scss
scss
@import './variables';
* {
margin: 0;
padding: 0;
box-sizing: border-box;
transition: background-color var(--transition-duration),
color var(--transition-duration),
border-color var(--transition-duration);
}
body {
font-family: var(--font-family);
background-color: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
}
a {
color: var(--color-primary);
text-decoration: none;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--spacing-unit);
}
封装主题切换hook函数
封装成hook方便使用
src/composables/useTheme.js
javascript
import { ref, watchEffect, onMounted } from 'vue'
export default function useTheme() {
const theme = ref('light')
// 初始化主题
onMounted(() => {
const savedTheme = localStorage.getItem('theme')
// 用于检测用户操作系统或浏览器是否启用了深色主题的 JavaScript API。
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (savedTheme) {
theme.value = savedTheme
} else if (systemPrefersDark) {
theme.value = 'dark'
}
})
// 切换主题
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
// 监听主题变化并应用到DOM
watchEffect(() => {
// 往主元素身上设置主题属性
document.documentElement.setAttribute('data-theme', theme.value)
localStorage.setItem('theme', theme.value)
})
// 可选:其他监听该事件的模块,会收到事件参数
// 自定义事件,可以在需要的地方指定监听
const themeEvent = new CustomEvent('theme-change', { detail: theme.value });
window.dispatchEvent(themeEvent);
return {
theme,
toggleTheme
}
}
封装主题切换组件
src/components/ThemeToggle.vue
vue
<template>
<button class="theme-toggle" @click="toggleTheme">
<span v-if="theme === 'light'">🌙</span>
<span v-else>☀️</span>
</button>
</template>
<script setup>
import useTheme from '@/composables/useTheme'
const { theme, toggleTheme } = useTheme()
</script>
<style scoped>
.theme-toggle {
background: none;
border: none;
cursor: pointer;
font-size: 1.5rem;
padding: 0.5rem;
color: var(--color-text);
}
</style>
导航栏组件
将封装好按钮在这里用一下
src/components/Navbar.vue
vue
<template>
<nav class="navbar">
<div class="container">
<router-link to="/" class="logo">Vue Blog</router-link>
<div class="nav-links">
<router-link to="/">Home</router-link>
<router-link to="/about">About</router-link>
<ThemeToggle />
</div>
</div>
</nav>
</template>
<script setup>
import ThemeToggle from './ThemeToggle.vue'
</script>
<style scoped>
.navbar {
background-color: var(--color-secondary);
padding: var(--spacing-unit) 0;
border-bottom: 1px solid var(--color-border);
}
.container {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
}
.nav-links {
display: flex;
gap: var(--spacing-unit);
align-items: center;
}
.nav-links a {
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
}
.nav-links a:hover {
background-color: var(--color-hover);
}
</style>
博客文章卡片组件
src/components/PostCard.vue
vue
<template>
<article class="post-card">
<h3>{{ post.title }}</h3>
<p class="excerpt">{{ post.excerpt }}</p>
<div class="meta">
<span class="date">{{ post.date }}</span>
<span class="author">{{ post.author }}</span>
</div>
<router-link :to="`/post/${post.id}`" class="read-more">Read more</router-link>
</article>
</template>
<script setup>
defineProps({
post: {
type: Object,
required: true
}
})
</script>
<style scoped>
.post-card {
background-color: var(--color-secondary);
border-radius: var(--border-radius);
padding: var(--spacing-unit);
margin-bottom: var(--spacing-unit);
border: 1px solid var(--color-border);
}
.post-card:hover {
box-shadow: 0 2px 8px var(--color-hover);
}
h3 {
margin-bottom: calc(var(--spacing-unit) / 2);
color: var(--color-primary);
}
.excerpt {
margin-bottom: calc(var(--spacing-unit) / 2);
color: var(--color-text);
}
.meta {
display: flex;
gap: var(--spacing-unit);
font-size: 0.9rem;
color: var(--color-text);
opacity: 0.8;
margin-bottom: calc(var(--spacing-unit) / 2);
}
.read-more {
display: inline-block;
padding: 0.5rem 1rem;
background-color: var(--color-primary);
color: white;
border-radius: var(--border-radius);
font-size: 0.9rem;
}
.read-more:hover {
opacity: 0.9;
}
</style>
主应用组件
src/App.vue
vue
<template>
<div class="app">
<Navbar />
<main class="main-content">
<router-view />
</main>
</div>
</template>
<script setup>
import Navbar from '@/components/Navbar.vue'
</script>
<style>
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
padding: var(--spacing-unit) 0;
}
</style>
首页视图
src/views/HomeView.vue
vue
<template>
<div class="home">
<h1>Latest Posts</h1>
<div class="posts">
<PostCard
v-for="post in posts"
:key="post.id"
:post="post"
/>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import PostCard from '@/components/PostCard.vue'
const posts = ref([
{
id: 1,
title: 'Getting Started with Vue 3',
excerpt: 'Learn the basics of Vue 3 composition API and how to build your first application.',
date: '2023-05-15',
author: 'Jane Doe'
},
{
id: 2,
title: 'Vue Router Deep Dive',
excerpt: 'Explore advanced routing techniques in Vue applications.',
date: '2023-05-10',
author: 'John Smith'
},
{
id: 3,
title: 'State Management with Pinia',
excerpt: 'Learn how to manage global state in your Vue applications using Pinia.',
date: '2023-05-05',
author: 'Jane Doe'
}
])
</script>
<style scoped>
.home {
padding: var(--spacing-unit) 0;
}
h1 {
margin-bottom: var(--spacing-unit);
color: var(--color-primary);
}
.posts {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--spacing-unit);
}
</style>
文章详情视图
src/views/PostView.vue
vue
<template>
<div class="post">
<h1>{{ post.title }}</h1>
<div class="meta">
<span class="date">{{ post.date }}</span>
<span class="author">{{ post.author }}</span>
</div>
<div class="content">
<p v-for="(para, index) in post.content" :key="index">{{ para }}</p>
</div>
<router-link to="/" class="back-link">← Back to home</router-link>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const post = ref({
title: '',
date: '',
author: '',
content: []
})
onMounted(() => {
// 模拟从API获取文章数据
const posts = [
{
id: 1,
title: 'Getting Started with Vue 3',
date: '2023-05-15',
author: 'Jane Doe',
content: [
'Vue 3 introduces the Composition API, a new way to organize and reuse logic in your components.',
'The Composition API is built around reactive references and functions that encapsulate logic.',
'To get started, create a new Vue project using Vite or Vue CLI.'
]
},
{
id: 2,
title: 'Vue Router Deep Dive',
date: '2023-05-10',
author: 'John Smith',
content: [
'Vue Router is the official router for Vue.js applications.',
'It allows you to build Single Page Applications with client-side navigation.',
'Advanced features include route guards, lazy loading, and nested routes.'
]
}
]
const postId = parseInt(route.params.id)
const foundPost = posts.find(p => p.id === postId)
if (foundPost) {
post.value = foundPost
}
})
</script>
<style scoped>
.post {
max-width: 800px;
margin: 0 auto;
padding: var(--spacing-unit) 0;
}
h1 {
margin-bottom: calc(var(--spacing-unit) / 2);
color: var(--color-primary);
}
.meta {
display: flex;
gap: var(--spacing-unit);
margin-bottom: var(--spacing-unit);
color: var(--color-text);
opacity: 0.8;
}
.content {
line-height: 1.8;
margin-bottom: var(--spacing-unit);
}
.content p {
margin-bottom: calc(var(--spacing-unit) / 2);
}
.back-link {
display: inline-block;
padding: 0.5rem 1rem;
background-color: var(--color-secondary);
border-radius: var(--border-radius);
}
.back-link:hover {
background-color: var(--color-hover);
}
</style>
主入口文件
src/main.js
javascript
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
import HomeView from './views/HomeView.vue'
import PostView from './views/PostView.vue'
import '@/assets/styles/main.scss'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: HomeView },
{ path: '/post/:id', component: PostView }
]
})
const app = createApp(App)
app.use(router)
app.mount('#app')
安装依赖
确保你的 package.json
包含以下依赖:
json
{
"dependencies": {
"vue": "^3.3.0",
"vue-router": "^4.2.0",
"sass": "^1.63.6"
}
}
使用说明
-
安装依赖:
npm install
-
运行开发服务器:
arduinonpm run dev
-
构建生产版本:
arduinonpm run build
结语
有趣有趣,喵喵喵