Skip to content
VitePress 主题深度定制:集成 VitePress 原生不支持的功能

VitePress 主题深度定制:集成 VitePress 原生不支持的功能

VitePress

基于 GABA Blog 实际项目,深入讲解如何深度定制 VitePress 主题,集成打字机效果、光标动画、双主题切换等原生不支持的功能

标签:
Tailwind CSS DaisyUI 动画效果
发布于 2025年12月19日
更新于 2026年3月16日

主题定制概述

VitePress 提供了良好的默认主题,但在实际项目中,我们经常需要集成一些原生不支持的功能。通过深度定制,GABA Blog 实现了以下高级功能:

  1. 打字机效果:首页每日一言的动态显示
  2. 光标动画:自定义光标样式和动画
  3. 运行时间计数器:显示博客运行时间
  4. 双主题系统:light/dracula 主题切换
  5. 知识侧边栏:文章知识体系导航

Tailwind CSS 深度配置

tailwind.config.js 详细解析

javascript
module.exports = {
  content: ['./src/**/*.{vue,js,ts,jsx,tsx,md}', './.vitepress/**/*.{vue,js,ts,jsx,tsx}'],
  theme: {
    extend: {
      colors: {
        primary: {
          50: '#eff6ff',
          100: '#dbeafe',
          500: '#3b82f6',
          600: '#2563eb',
          700: '#1d4ed8',
        },
      },
      fontFamily: {
        sans: ['Inter', 'ui-sans-serif', 'system-ui'],
      },
      gridColumn: {
        'span-13': 'span 13 / span 13',
        'span-14': 'span 14 / span 14',
      },
      gridRow: {
        'span-13': 'span 13 / span 13',
      },
      gridColumnStart: {
        13: '13',
        14: '14',
        15: '15',
      },
    },
  },
  plugins: [require('@tailwindcss/typography'), require('daisyui')],
  daisyui: {
    themes: ['light', 'dracula'],
    darkTheme: 'dracula',
    base: true,
    styled: true,
    utils: true,
    prefix: '',
    logs: false,
    themeRoot: ':root',
  },
}

关键配置说明

  1. 颜色系统扩展:自定义 primary 颜色调色板
  2. 字体配置:使用 Inter 字体作为默认 sans 字体
  3. 网格系统扩展:支持 13-15 列网格系统
  4. DaisyUI 集成:配置 light/dracula 双主题系统

打字机效果实现

HomeHero.vue 组件核心代码

vue
<template>
  <div class="typewriter-container min-h-[120px] flex items-center justify-center">
    <div class="typewriter-content text-center max-w-3xl mx-auto">
      <div class="text-4xl md:text-5xl text-primary/30 mb-4">❝</div>
      <div
        class="typewriter-text text-xl md:text-2xl font-medium text-base-content leading-relaxed mb-4"
      >
        <span class="typed-text">{{ typedText }}</span>
        <span class="cursor" :class="{ blinking: isTypingComplete }">|</span>
      </div>
      <div
        v-if="quoteAuthor"
        class="author-info text-base-content/70 text-sm md:text-base italic mt-6"
      >
        —— {{ quoteAuthor }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
  import { ref, onMounted } from 'vue'
  import { getDailyQuote } from '@utils/dailyQuote.mts'

  const typedText = ref<string>('')
  const isTypingComplete = ref<boolean>(false)
  const quoteAuthor = ref<string>('')
  const typingSpeed = 100
  const pauseAfterTyping = 1000
  let typingInterval: ReturnType<typeof setTimeout> | null = null

  const startTypingAnimation = (quote: string, author: string) => {
    typedText.value = ''
    isTypingComplete.value = false
    quoteAuthor.value = author

    let charIndex = 0

    if (typingInterval) {
      clearTimeout(typingInterval)
      typingInterval = null
    }

    const typeNextChar = () => {
      if (charIndex < quote.length) {
        typedText.value += quote.charAt(charIndex)
        charIndex++
        typingInterval = setTimeout(typeNextChar, typingSpeed)
      } else {
        typingInterval = null
        setTimeout(() => {
          isTypingComplete.value = true
        }, pauseAfterTyping)
      }
    }

    typingInterval = setTimeout(typeNextChar, typingSpeed)
  }

  onMounted(async () => {
    const quote = await getDailyQuote()
    const [text, author] = quote.split(' —— ')
    startTypingAnimation(text, author || '')
  })
</script>

样式实现

css
<style scoped>
.typewriter-container {
  position: relative;
}

.typewriter-content {
  position: relative;
  z-index: 1;
}

.typewriter-text {
  position: relative;
  min-height: 1.5em;
}

.typed-text {
  display: inline-block;
  white-space: pre-wrap;
  word-break: break-word;
}

.cursor {
  display: inline-block;
  margin-left: 2px;
  color: var(--primary);
  font-weight: bold;
  animation: cursor-blink 1s infinite;
}

.cursor.blinking {
  animation: cursor-blink 0.7s infinite;
}

@keyframes cursor-blink {
  0%, 50% { opacity: 1; }
  51%, 100% { opacity: 0; }
}
</style>

光标动画系统

cursor.mts 实现

typescript
export const cursor = () => {
  const initCursor = () => {
    const cursor = document.createElement('div')
    cursor.id = 'custom-cursor'
    cursor.style.cssText = `
      position: fixed;
      width: 20px;
      height: 20px;
      border: 2px solid var(--primary);
      border-radius: 50%;
      pointer-events: none;
      z-index: 9999;
      transform: translate(-50%, -50%);
      transition: transform 0.1s ease;
      mix-blend-mode: difference;
    `
    document.body.appendChild(cursor)

    document.addEventListener('mousemove', (e) => {
      cursor.style.left = `${e.clientX}px`
      cursor.style.top = `${e.clientY}px`
    })

    document.addEventListener('mousedown', () => {
      cursor.style.transform = 'translate(-50%, -50%) scale(0.8)'
    })

    document.addEventListener('mouseup', () => {
      cursor.style.transform = 'translate(-50%, -50%) scale(1)'
    })

    const interactiveElements = document.querySelectorAll('a, button, [role="button"]')
    interactiveElements.forEach((el) => {
      el.addEventListener('mouseenter', () => {
        cursor.style.transform = 'translate(-50%, -50%) scale(1.5)'
        cursor.style.backgroundColor = 'var(--primary)'
      })
      el.addEventListener('mouseleave', () => {
        cursor.style.transform = 'translate(-50%, -50%) scale(1)'
        cursor.style.backgroundColor = 'transparent'
      })
    })
  }

  return { initCursor }
}

在主题中启用

typescript
// .vitepress/theme/index.ts
import { cursor } from '@hooks/cursor.mts'

export default {
  enhanceApp({ app }) {
    app.mixin({
      mounted() {
        if (this.$root === this) {
          const { initCursor } = cursor()
          initCursor()
        }
      },
    })
  },
}

运行时间计数器

duration.mts 实现

typescript
export const createRuntimeCounter = (
  startDate: Date,
  dateElementId: string,
  timeElementId: string
) => {
  const calculateRuntime = () => {
    const now = new Date()
    const diff = now.getTime() - startDate.getTime()

    const years = Math.floor(diff / (1000 * 60 * 60 * 24 * 365))
    const months = Math.floor((diff % (1000 * 60 * 60 * 24 * 365)) / (1000 * 60 * 60 * 24 * 30))
    const days = Math.floor((diff % (1000 * 60 * 60 * 24 * 30)) / (1000 * 60 * 60 * 24))
    const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
    const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
    const seconds = Math.floor((diff % (1000 * 60)) / 1000)

    const dateElement = document.getElementById(dateElementId)
    if (dateElement) {
      dateElement.textContent = `${years}年${months}月${days}天`
    }

    const timeElement = document.getElementById(timeElementId)
    if (timeElement) {
      timeElement.textContent = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
    }
  }

  calculateRuntime()
  setInterval(calculateRuntime, 1000)
}

知识侧边栏功能

useKnowledgeSidebar.mts 实现

typescript
import { ref, computed, provide, inject } from 'vue'

export const KnowledgeSidebarKey = Symbol('knowledge-sidebar')

interface Article {
  url: string
  title: string
  description?: string
  tags?: string[]
  categories?: string[]
  date?: string
}

const articles = ref<Article[]>([])

export const setArticles = (data: Article[]) => {
  articles.value = data
}

export const useKnowledgeSidebar = () => {
  const activeCategory = ref<string>('')
  const expandedCategories = ref<Set<string>>(new Set())

  const categories = computed(() => {
    const cats = new Map<string, Article[]>()
    articles.value.forEach((post) => {
      post.categories?.forEach((cat) => {
        if (!cats.has(cat)) {
          cats.set(cat, [])
        }
        cats.get(cat)!.push(post)
      })
    })
    return cats
  })

  const toggleCategory = (category: string) => {
    if (expandedCategories.value.has(category)) {
      expandedCategories.value.delete(category)
    } else {
      expandedCategories.value.add(category)
    }
  }

  return {
    articles,
    activeCategory,
    categories,
    expandedCategories,
    toggleCategory,
    setArticles,
  }
}

KnowledgeSidebar.vue 组件

vue
<template>
  <aside class="knowledge-sidebar">
    <div class="sidebar-header">
      <h3>知识体系</h3>
    </div>
    <nav class="sidebar-nav">
      <div v-for="[category, posts] in categories" :key="category" class="category-item">
        <button class="category-title" @click="toggleCategory(category)">
          {{ category }}
        </button>
        <ul v-if="expandedCategories.has(category)" class="category-posts">
          <li v-for="post in posts" :key="post.url">
            <a :href="post.url">{{ post.title }}</a>
          </li>
        </ul>
      </div>
    </nav>
  </aside>
</template>

<script setup lang="ts">
  import { useKnowledgeSidebar } from '@hooks/useKnowledgeSidebar.mts'

  const { categories, expandedCategories, toggleCategory } = useKnowledgeSidebar()
</script>

每日一言功能

dailyQuote.mts 实现

typescript
import quotes from '../../src/assets/json/quotes.json'

export const getDailyQuote = async (useOnline: boolean = true): Promise<string> => {
  try {
    if (useOnline) {
      const response = await fetch('https://api.quotable.io/random')
      if (response.ok) {
        const data = await response.json()
        return `${data.content} —— ${data.author}`
      }
    }

    const today = new Date()
    const dayOfYear = Math.floor(
      (today.getTime() - new Date(today.getFullYear(), 0, 0).getTime()) / (1000 * 60 * 60 * 24)
    )
    const index = dayOfYear % quotes.length
    return quotes[index]
  } catch {
    const randomIndex = Math.floor(Math.random() * quotes.length)
    return quotes[randomIndex]
  }
}

工具函数

cn.mts - class 合并

typescript
import { type ClassValue, clsx } from 'clsx'

export const cn = (...inputs: ClassValue[]) => {
  return clsx(inputs)
}

contentComplexity.mts - 内容复杂度

typescript
export const calculateComplexity = (content: string): number => {
  const words = content.split(/\s+/).length
  const chars = content.length
  const codeBlocks = (content.match(/```/g) || []).length

  return Math.floor((words * 0.3 + chars * 0.5 + codeBlocks * 10) / 100)
}

export const getComplexityLabel = (complexity: number): string => {
  if (complexity < 5) return '简单'
  if (complexity < 10) return '中等'
  return '复杂'
}

总结

通过深度定制 VitePress 主题,GABA Blog 实现了许多原生不支持的高级功能:

  1. 视觉效果增强:打字机效果、光标动画提升了用户体验
  2. 交互功能丰富:运行时间计数器、每日一言增加了博客的趣味性
  3. 主题系统完善:双主题切换支持用户个性化偏好
  4. 导航功能增强:知识侧边栏帮助读者更好地浏览内容
  5. 布局系统强大:扩展的网格系统支持复杂布局设计

这些定制不仅提升了博客的功能性,也展示了 VitePress 主题系统的强大扩展能力。通过合理的架构设计和代码组织,我们可以轻松集成各种自定义功能,打造独特的博客体验。

Last updated: