封装hook,复刻掘金社区,暗黑白天主题切换功能

导言

今天逛掘金的时候分析了一下掘金主页的页面结构,看到掘金为暗黑模式白天模式 定义了两套不同样式,根据不同的环境,切换不同的样式(白天模式在body上添加data-theme="light"属性,暗黑模式在body上添加data-theme="dark"属性),不加属性默认白天模式。

太棒了,我要复刻!!!

  • 这是掘金白天模式 的截图(采用的.light-theme主题)
  • 这是掘金暗黑模式 的截图(采用的.dark-theme主题)

目标

构建一个完整的 Vue 3 博客demo实现,包含类似掘金的主题切换功能,使用 CSS 变量和 Vue 的组合式 API。

  1. 使用css变量定义两套主题
  2. 封装hook监听主题变换,自定义事件监听器,触发事件相应
  3. 封装主题切换按钮

项目结构

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"
  }
}

使用说明

  1. 安装依赖:

    复制代码
    npm install
  2. 运行开发服务器:

    arduino 复制代码
    npm run dev
  3. 构建生产版本:

    arduino 复制代码
    npm run build

结语

有趣有趣,喵喵喵

相关推荐
Ticnix7 分钟前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人11 分钟前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl14 分钟前
OpenClaw 深度技术解析
前端
崔庆才丨静觅18 分钟前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人26 分钟前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼29 分钟前
shadcn/ui,给你一个真正可控的UI组件库
前端
布列瑟农的星空33 分钟前
前端都能看懂的Rust入门教程(三)——控制流语句
前端·后端·rust
Mr Xu_38 分钟前
Vue 3 中计算属性的最佳实践:提升可读性、可维护性与性能
前端·javascript
jerrywus1 小时前
我写了个 Claude Code Skill,再也不用手动切图传 COS 了
前端·agent·claude
玖月晴空1 小时前
探索关于Spec 和Skills 的一些实战运用-Kiro篇
前端·aigc·代码规范