封装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

结语

有趣有趣,喵喵喵

相关推荐
Angel_girl31919 分钟前
vue项目使用svg图标
前端·vue.js
難釋懷24 分钟前
vue 项目中常用的 2 个 Ajax 库
前端·vue.js·ajax
Qian Xiaoo25 分钟前
Ajax入门
前端·ajax·okhttp
爱生活的苏苏1 小时前
vue生成二维码图片+文字说明
前端·vue.js
拉不动的猪1 小时前
安卓和ios小程序开发中的兼容性问题举例
前端·javascript·面试
炫彩@之星1 小时前
Chrome书签的导出与导入:步骤图
前端·chrome
贩卖纯净水.1 小时前
浏览器兼容-polyfill-本地服务-优化
开发语言·前端·javascript
前端百草阁1 小时前
从npm库 Vue 组件到独立SDK:打包与 CDN 引入的最佳实践
前端·vue.js·npm
夏日米米茶1 小时前
Windows系统下npm报错node-gyp configure got “gyp ERR“解决方法
前端·windows·npm
且白2 小时前
vsCode使用本地低版本node启动配置文件
前端·vue.js·vscode·编辑器