Skip to content

谭振兴聊前端 - 技术架构详细方案

项目结构设计

tanzhenxing.com/
├── .vitepress/            # VitePress 配置
│   ├── config.ts          # 站点配置
│   ├── theme/             # 自定义主题
│   │   ├── index.ts       # 主题入口
│   │   ├── Layout.vue     # 布局组件
│   │   ├── components/    # 主题组件
│   │   └── styles/        # 主题样式
│   └── cache/             # 构建缓存
├── docs/                  # 文档内容
│   ├── index.md           # 首页
│   ├── blog/              # 博客文章
│   │   ├── index.md       # 博客首页
│   │   └── posts/         # 文章目录
│   ├── about/             # 关于页面
│   └── .vitepress/        # 页面配置
├── public/                # 静态资源
│   ├── images/            # 图片资源
│   ├── icons/             # 图标文件
│   └── favicon.ico        # 网站图标
├── src/                   # 源码目录
│   ├── components/        # Vue组件
│   │   ├── layout/        # 布局组件
│   │   ├── blog/          # 博客组件
│   │   └── common/        # 通用组件
│   ├── composables/       # Vue组合式函数
│   ├── stores/            # Pinia状态管理
│   ├── utils/             # 工具函数
│   ├── types/             # TypeScript类型
│   └── styles/            # 样式文件
├── vite.config.ts         # Vite配置
├── tsconfig.json          # TypeScript配置
└── package.json           # 项目依赖

核心技术实现

1. VitePress 配置

typescript
// .vitepress/config.ts
import { defineConfig } from 'vitepress'
import { resolve } from 'path'

export default defineConfig({
  title: '谭振兴聊前端',
  description: '专业前端技术博客与知识分享平台',
  lang: 'zh-CN',
  base: '/',
  
  // 主题配置
  themeConfig: {
    nav: [
      { text: '首页', link: '/' },
      { text: '博客', link: '/blog/' },
      { text: '关于', link: '/about/' }
    ],
    
    sidebar: {
      '/blog/': [
        {
          text: '最新文章',
          items: [
            // 动态生成文章列表
          ]
        }
      ]
    },
    
    socialLinks: [
      { icon: 'github', link: 'https://github.com/tanzhenxing' }
    ],
    
    footer: {
      message: '基于 VitePress 构建',
      copyright: 'Copyright © 2024 谭振兴'
    }
  },
  
  // Vite 配置
  vite: {
    resolve: {
      alias: {
        '@': resolve(__dirname, '../src')
      }
    },
    
    // 插件配置
    plugins: [
      // Element Plus 自动导入
    ]
  },
  
  // Markdown 配置
  markdown: {
    theme: 'github-dark',
    lineNumbers: true,
    
    // 代码高亮配置
    codeTransformers: [
      // 自定义代码转换器
    ]
  },
  
  // 构建配置
  buildEnd: async (siteConfig) => {
    // 生成 sitemap
    // 生成 RSS
  }
})

2. Element Plus 主题配置

typescript
// .vitepress/theme/index.ts
import DefaultTheme from 'vitepress/theme'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import './styles/custom.css'

export default {
  ...DefaultTheme,
  enhanceApp({ app }) {
    app.use(ElementPlus)
  }
}
css
/* .vitepress/theme/styles/custom.css */
:root {
  /* 主色调 */
  --el-color-primary: #2563eb;
  --el-color-primary-light-3: #60a5fa;
  --el-color-primary-light-5: #93c5fd;
  --el-color-primary-light-7: #bfdbfe;
  --el-color-primary-light-8: #dbeafe;
  --el-color-primary-light-9: #eff6ff;
  --el-color-primary-dark-2: #1d4ed8;
  
  /* 成功色 */
  --el-color-success: #10b981;
  
  /* 警告色 */
  --el-color-warning: #f59e0b;
  
  /* 错误色 */
  --el-color-danger: #ef4444;
  
  /* 信息色 */
  --el-color-info: #64748b;
  
  /* 文本颜色 */
  --el-text-color-primary: #1e293b;
  --el-text-color-regular: #475569;
  --el-text-color-secondary: #64748b;
  --el-text-color-placeholder: #94a3b8;
  
  /* 边框颜色 */
  --el-border-color: #e2e8f0;
  --el-border-color-light: #f1f5f9;
  --el-border-color-lighter: #f8fafc;
  
  /* 背景色 */
  --el-bg-color: #ffffff;
  --el-bg-color-page: #f8fafc;
  --el-bg-color-overlay: #ffffff;
}

/* 深色主题 */
.dark {
  --el-color-primary: #3b82f6;
  --el-text-color-primary: #f1f5f9;
  --el-text-color-regular: #cbd5e1;
  --el-text-color-secondary: #94a3b8;
  --el-text-color-placeholder: #64748b;
  --el-border-color: #334155;
  --el-border-color-light: #1e293b;
  --el-border-color-lighter: #0f172a;
  --el-bg-color: #0f172a;
  --el-bg-color-page: #020617;
  --el-bg-color-overlay: #1e293b;
}

/* 自定义样式 */
.blog-container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 24px;
}

.article-content {
  line-height: 1.7;
  font-size: 16px;
}

.article-content h1,
.article-content h2,
.article-content h3 {
  margin-top: 2rem;
  margin-bottom: 1rem;
  font-weight: 600;
}

.article-content p {
  margin-bottom: 1rem;
}

.article-content pre {
  margin: 1.5rem 0;
  border-radius: 8px;
  overflow-x: auto;
}

3. Markdown 内容处理

typescript
// src/utils/posts.ts
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import { globby } from 'globby'

const docsDirectory = path.join(process.cwd(), 'docs')

export interface BlogPost {
  slug: string
  title: string
  description: string
  date: string
  tags: string[]
  author: string
  readTime: number
  url: string
  excerpt?: string
}

export async function getBlogPosts(): Promise<BlogPost[]> {
  const postPaths = await globby(['docs/blog/posts/**/*.md'])
  
  const posts = await Promise.all(
    postPaths.map(async (filePath) => {
      const fileContent = fs.readFileSync(filePath, 'utf8')
      const { data, content } = matter(fileContent)
      
      const slug = path.basename(filePath, '.md')
      const url = filePath
        .replace('docs/', '/')
        .replace('.md', '.html')
      
      return {
        slug,
        title: data.title || slug,
        description: data.description || '',
        date: data.date || new Date().toISOString(),
        tags: data.tags || [],
        author: data.author || '谭振兴',
        readTime: calculateReadTime(content),
        url,
        excerpt: data.excerpt || content.slice(0, 200) + '...'
      }
    })
  )
  
  return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
}

function calculateReadTime(content: string): number {
  const wordsPerMinute = 200
  const words = content.split(/\s+/).length
  return Math.ceil(words / wordsPerMinute)
}

// 生成侧边栏配置
export async function generateSidebar() {
  const posts = await getBlogPosts()
  
  return {
    '/blog/': [
      {
        text: '最新文章',
        items: posts.slice(0, 10).map(post => ({
          text: post.title,
          link: post.url
        }))
      },
      {
        text: '按标签分类',
        items: getTagsFromPosts(posts).map(tag => ({
          text: tag,
          link: `/blog/tags/${tag}.html`
        }))
      }
    ]
  }
}

function getTagsFromPosts(posts: BlogPost[]): string[] {
  const tags = new Set<string>()
  posts.forEach(post => {
    post.tags.forEach(tag => tags.add(tag))
  })
  return Array.from(tags).sort()
}

4. 主题切换实现

typescript
// src/composables/useTheme.ts
import { ref, onMounted, watch } from 'vue'
import { useData } from 'vitepress'

export function useTheme() {
  const { isDark } = useData()
  const theme = ref<'light' | 'dark'>('light')
  
  onMounted(() => {
    const savedTheme = localStorage.getItem('theme') as 'light' | 'dark'
    const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
    const initialTheme = savedTheme || systemTheme
    
    theme.value = initialTheme
    updateTheme(initialTheme)
  })
  
  const toggleTheme = () => {
    const newTheme = theme.value === 'light' ? 'dark' : 'light'
    theme.value = newTheme
    localStorage.setItem('theme', newTheme)
    updateTheme(newTheme)
  }
  
  const updateTheme = (newTheme: 'light' | 'dark') => {
    document.documentElement.classList.toggle('dark', newTheme === 'dark')
    // 更新 Element Plus 主题
    document.documentElement.setAttribute('data-theme', newTheme)
  }
  
  // 监听系统主题变化
  watch(isDark, (newValue) => {
    if (!localStorage.getItem('theme')) {
      theme.value = newValue ? 'dark' : 'light'
      updateTheme(theme.value)
    }
  })
  
  return {
    theme,
    toggleTheme,
    isDark: computed(() => theme.value === 'dark')
  }
}

5. 搜索功能实现

typescript
// src/composables/useSearch.ts
import { ref, computed, watch } from 'vue'
import type { BlogPost } from '@/utils/posts'

export function useSearch(posts: Ref<BlogPost[]>) {
  const searchQuery = ref('')
  const searchResults = ref<BlogPost[]>([])
  const isSearching = ref(false)
  
  const filteredPosts = computed(() => {
    if (!searchQuery.value.trim()) return posts.value
    
    const query = searchQuery.value.toLowerCase()
    return posts.value.filter(post => 
      post.title.toLowerCase().includes(query) ||
      post.description.toLowerCase().includes(query) ||
      post.excerpt?.toLowerCase().includes(query) ||
      post.tags.some(tag => tag.toLowerCase().includes(query))
    )
  })
  
  // 防抖搜索
  const debouncedSearch = debounce((query: string) => {
    if (!query.trim()) {
      searchResults.value = []
      isSearching.value = false
      return
    }
    
    isSearching.value = true
    // 模拟异步搜索
    setTimeout(() => {
      searchResults.value = filteredPosts.value
      isSearching.value = false
    }, 300)
  }, 300)
  
  watch(searchQuery, (newQuery) => {
    debouncedSearch(newQuery)
  })
  
  const clearSearch = () => {
    searchQuery.value = ''
    searchResults.value = []
  }
  
  return {
    searchQuery,
    searchResults,
    filteredPosts,
    isSearching,
    clearSearch
  }
}

// 防抖工具函数
function debounce<T extends (...args: any[]) => any>(
  func: T,
  wait: number
): (...args: Parameters<T>) => void {
  let timeout: NodeJS.Timeout
  return (...args: Parameters<T>) => {
    clearTimeout(timeout)
    timeout = setTimeout(() => func(...args), wait)
  }
}

组件设计

1. 布局组件

vue
<!-- .vitepress/theme/components/Header.vue -->
<template>
  <el-header class="site-header">
    <div class="header-container">
      <div class="header-content">
        <router-link to="/" class="site-title">
          谭振兴聊前端
        </router-link>
        
        <nav class="nav-menu">
          <el-menu
            mode="horizontal"
            :default-active="activeIndex"
            class="nav-menu-items"
            @select="handleMenuSelect"
          >
            <el-menu-item index="/">首页</el-menu-item>
            <el-menu-item index="/blog/">博客</el-menu-item>
            <el-menu-item index="/about/">关于</el-menu-item>
          </el-menu>
          
          <div class="header-actions">
            <!-- 搜索框 -->
            <el-input
              v-model="searchQuery"
              placeholder="搜索文章..."
              class="search-input"
              clearable
              @input="handleSearch"
            >
              <template #prefix>
                <el-icon><Search /></el-icon>
              </template>
            </el-input>
            
            <!-- 主题切换 -->
            <el-button
              :icon="isDark ? Sunny : Moon"
              circle
              @click="toggleTheme"
              class="theme-toggle"
            />
            
            <!-- 移动端菜单 -->
            <el-button
              :icon="Menu"
              circle
              class="mobile-menu-btn"
              @click="showMobileMenu = true"
            />
          </div>
        </nav>
      </div>
    </div>
    
    <!-- 移动端抽屉菜单 -->
    <el-drawer
      v-model="showMobileMenu"
      title="菜单"
      direction="rtl"
      size="280px"
    >
      <el-menu
        :default-active="activeIndex"
        @select="handleMobileMenuSelect"
      >
        <el-menu-item index="/">首页</el-menu-item>
        <el-menu-item index="/blog/">博客</el-menu-item>
        <el-menu-item index="/about/">关于</el-menu-item>
      </el-menu>
    </el-drawer>
  </el-header>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useTheme } from '@/composables/useTheme'
import { Search, Moon, Sunny, Menu } from '@element-plus/icons-vue'

const router = useRouter()
const route = useRoute()
const { theme, toggleTheme, isDark } = useTheme()

const searchQuery = ref('')
const showMobileMenu = ref(false)

const activeIndex = computed(() => route.path)

const handleMenuSelect = (index: string) => {
  router.push(index)
}

const handleMobileMenuSelect = (index: string) => {
  router.push(index)
  showMobileMenu.value = false
}

const handleSearch = (value: string) => {
  if (value.trim()) {
    router.push(`/search?q=${encodeURIComponent(value)}`)
  }
}
</script>

<style scoped>
.site-header {
  position: sticky;
  top: 0;
  z-index: 1000;
  background: rgba(255, 255, 255, 0.8);
  backdrop-filter: blur(8px);
  border-bottom: 1px solid var(--el-border-color);
}

.dark .site-header {
  background: rgba(15, 23, 42, 0.8);
}

.header-container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 24px;
}

.header-content {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 64px;
}

.site-title {
  font-size: 1.25rem;
  font-weight: 700;
  color: var(--el-text-color-primary);
  text-decoration: none;
}

.nav-menu {
  display: flex;
  align-items: center;
  gap: 24px;
}

.nav-menu-items {
  border: none;
  background: transparent;
}

.header-actions {
  display: flex;
  align-items: center;
  gap: 12px;
}

.search-input {
  width: 200px;
}

.mobile-menu-btn {
  display: none;
}

@media (max-width: 768px) {
  .nav-menu-items {
    display: none;
  }
  
  .search-input {
    width: 150px;
  }
  
  .mobile-menu-btn {
    display: inline-flex;
  }
}
</style>

2. 博客组件

vue
<!-- src/components/blog/BlogList.vue -->
<template>
  <div class="blog-list">
    <!-- 搜索和筛选 -->
    <div class="search-filter-section">
      <div class="search-container">
        <el-input
          v-model="searchQuery"
          placeholder="搜索文章..."
          size="large"
          clearable
          class="search-input"
        >
          <template #prefix>
            <el-icon><Search /></el-icon>
          </template>
        </el-input>
      </div>
      
      <div class="filter-container">
        <el-select
          v-model="selectedTag"
          placeholder="选择标签"
          clearable
          size="large"
          class="tag-select"
        >
          <el-option
            v-for="tag in tags"
            :key="tag"
            :label="tag"
            :value="tag"
          />
        </el-select>
      </div>
    </div>

    <!-- 结果统计 -->
    <div class="result-info" v-if="searchQuery || selectedTag">
      <el-text type="info">
        找到 {{ filteredPosts.length }} 篇文章
        <span v-if="searchQuery">包含 "{{ searchQuery }}"</span>
        <span v-if="selectedTag">标签为 "{{ selectedTag }}"</span>
      </el-text>
    </div>

    <!-- 文章列表 -->
    <div class="posts-grid" v-if="filteredPosts.length > 0">
      <BlogCard
        v-for="post in filteredPosts"
        :key="post.url"
        :post="post"
      />
    </div>

    <!-- 空状态 -->
    <el-empty
      v-else
      description="没有找到匹配的文章"
      class="empty-state"
    >
      <template #image>
        <el-icon size="64" color="var(--el-color-info)">
          <Document />
        </el-icon>
      </template>
      <el-button type="primary" @click="clearFilters">
        清除筛选条件
      </el-button>
    </el-empty>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import BlogCard from './BlogCard.vue'
import type { BlogPost } from '@/utils/posts'
import { Search, Document } from '@element-plus/icons-vue'

interface Props {
  posts: BlogPost[]
  tags: string[]
}

const props = defineProps<Props>()

const searchQuery = ref('')
const selectedTag = ref<string | null>(null)

const filteredPosts = computed(() => {
  return props.posts.filter(post => {
    const matchesSearch = !searchQuery.value || 
      post.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
      (post.description || post.excerpt || '').toLowerCase().includes(searchQuery.value.toLowerCase())
    
    const matchesTag = !selectedTag.value || post.tags.includes(selectedTag.value)
    
    return matchesSearch && matchesTag
  })
})

const clearFilters = () => {
  searchQuery.value = ''
  selectedTag.value = null
}
</script>

<style scoped>
.blog-list {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 16px;
}

.search-filter-section {
  display: flex;
  gap: 16px;
  margin-bottom: 24px;
  flex-wrap: wrap;
}

.search-container {
  flex: 1;
  min-width: 280px;
}

.filter-container {
  min-width: 160px;
}

.tag-select {
  width: 100%;
}

.result-info {
  margin-bottom: 16px;
  padding: 12px 16px;
  background: var(--el-color-info-light-9);
  border-radius: 8px;
  border-left: 4px solid var(--el-color-info);
}

.posts-grid {
  display: grid;
  gap: 24px;
  grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
}

.empty-state {
  margin: 48px 0;
}

@media (max-width: 768px) {
  .search-filter-section {
    flex-direction: column;
  }
  
  .posts-grid {
    grid-template-columns: 1fr;
    gap: 16px;
  }
  
  .blog-list {
    padding: 0 12px;
  }
}

@media (max-width: 480px) {
  .search-container {
    min-width: auto;
  }
  
  .filter-container {
    min-width: auto;
  }
}
</style>

SEO 优化

1. VitePress SEO 配置

typescript
// .vitepress/config.ts
import { defineConfig } from 'vitepress'
import { generateSitemap } from './utils/sitemap'
import { generateRSSFeed } from './utils/rss'

export default defineConfig({
  // 基础 SEO 配置
  title: '谭振兴聊前端',
  description: '专注前端技术分享,探讨最新的前端开发趋势、技术实践和解决方案',
  lang: 'zh-CN',
  
  // Head 配置
  head: [
    // 基础 meta
    ['meta', { name: 'keywords', content: '前端开发,JavaScript,Vue,React,TypeScript,Web开发,技术博客' }],
    ['meta', { name: 'author', content: '谭振兴' }],
    ['meta', { name: 'viewport', content: 'width=device-width,initial-scale=1' }],
    
    // Open Graph
    ['meta', { property: 'og:type', content: 'website' }],
    ['meta', { property: 'og:locale', content: 'zh_CN' }],
    ['meta', { property: 'og:site_name', content: '谭振兴聊前端' }],
    ['meta', { property: 'og:image', content: 'https://tanzhenxing.com/og-image.jpg' }],
    ['meta', { property: 'og:image:width', content: '1200' }],
    ['meta', { property: 'og:image:height', content: '630' }],
    
    // Twitter Card
    ['meta', { name: 'twitter:card', content: 'summary_large_image' }],
    ['meta', { name: 'twitter:creator', content: '@tanzhenxing' }],
    ['meta', { name: 'twitter:image', content: 'https://tanzhenxing.com/og-image.jpg' }],
    
    // 网站图标
    ['link', { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
    ['link', { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' }],
    ['link', { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png' }],
    ['link', { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' }],
    
    // RSS 订阅
    ['link', { rel: 'alternate', type: 'application/rss+xml', title: 'RSS', href: '/feed.xml' }],
    
    // 结构化数据
    ['script', { type: 'application/ld+json' }, JSON.stringify({
      '@context': 'https://schema.org',
      '@type': 'WebSite',
      name: '谭振兴聊前端',
      description: '专注前端技术分享,探讨最新的前端开发趋势、技术实践和解决方案',
      url: 'https://tanzhenxing.com',
      author: {
        '@type': 'Person',
        name: '谭振兴',
        url: 'https://tanzhenxing.com/about'
      },
      publisher: {
        '@type': 'Organization',
        name: '谭振兴聊前端',
        logo: {
          '@type': 'ImageObject',
          url: 'https://tanzhenxing.com/logo.png'
        }
      }
    })],
    
    // Google Analytics
    ['script', { async: '', src: 'https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID' }],
    ['script', {}, `
      window.dataLayer = window.dataLayer || [];
      function gtag(){dataLayer.push(arguments);}
      gtag('js', new Date());
      gtag('config', 'GA_MEASUREMENT_ID');
    `]
  ],
  
  // 站点地图和 RSS
  buildEnd: async (config) => {
    await generateSitemap(config)
    await generateRSSFeed(config)
  },
  
  // 清理 URL
  cleanUrls: true,
  
  // 最后更新时间
  lastUpdated: true
})

2. 文章页面 SEO 配置

typescript
// src/utils/seo.ts
export interface ArticleSEO {
  title: string
  description: string
  keywords: string[]
  author: string
  publishedTime: string
  modifiedTime?: string
  tags: string[]
  image?: string
  url: string
}

export function generateArticleHead(article: ArticleSEO) {
  const fullTitle = `${article.title} | 谭振兴聊前端`
  const canonicalUrl = `https://tanzhenxing.com${article.url}`
  const imageUrl = article.image || 'https://tanzhenxing.com/og-image.jpg'
  
  return [
    // 基础 meta
    ['meta', { name: 'description', content: article.description }],
    ['meta', { name: 'keywords', content: article.keywords.join(',') }],
    ['meta', { name: 'author', content: article.author }],
    ['meta', { name: 'article:published_time', content: article.publishedTime }],
    ['meta', { name: 'article:modified_time', content: article.modifiedTime || article.publishedTime }],
    ['meta', { name: 'article:author', content: article.author }],
    ['meta', { name: 'article:tag', content: article.tags.join(',') }],
    
    // Open Graph
    ['meta', { property: 'og:title', content: fullTitle }],
    ['meta', { property: 'og:description', content: article.description }],
    ['meta', { property: 'og:url', content: canonicalUrl }],
    ['meta', { property: 'og:type', content: 'article' }],
    ['meta', { property: 'og:image', content: imageUrl }],
    ['meta', { property: 'article:published_time', content: article.publishedTime }],
    ['meta', { property: 'article:modified_time', content: article.modifiedTime || article.publishedTime }],
    ['meta', { property: 'article:author', content: article.author }],
    ...article.tags.map(tag => ['meta', { property: 'article:tag', content: tag }]),
    
    // Twitter Card
    ['meta', { name: 'twitter:title', content: fullTitle }],
    ['meta', { name: 'twitter:description', content: article.description }],
    ['meta', { name: 'twitter:image', content: imageUrl }],
    
    // Canonical URL
    ['link', { rel: 'canonical', href: canonicalUrl }],
    
    // 结构化数据
    ['script', { type: 'application/ld+json' }, JSON.stringify({
      '@context': 'https://schema.org',
      '@type': 'Article',
      headline: article.title,
      description: article.description,
      image: imageUrl,
      datePublished: article.publishedTime,
      dateModified: article.modifiedTime || article.publishedTime,
      author: {
        '@type': 'Person',
        name: article.author,
        url: 'https://tanzhenxing.com/about'
      },
      publisher: {
        '@type': 'Organization',
        name: '谭振兴聊前端',
        logo: {
          '@type': 'ImageObject',
          url: 'https://tanzhenxing.com/logo.png'
        }
      },
      mainEntityOfPage: {
        '@type': 'WebPage',
        '@id': canonicalUrl
      },
      keywords: article.keywords.join(','),
      articleSection: article.tags[0] || '前端开发'
    })]
  ]
}

性能优化策略

1. Vite 构建优化

typescript
// .vitepress/config.ts
import { defineConfig } from 'vitepress'
import { resolve } from 'path'

export default defineConfig({
  vite: {
    // 构建优化
    build: {
      rollupOptions: {
        output: {
          manualChunks: {
            // 将 Element Plus 单独打包
            'element-plus': ['element-plus'],
            // 将 Vue 相关库单独打包
            'vue-vendor': ['vue', 'vue-router'],
            // 将工具库单独打包
            'utils': ['lodash-es', 'dayjs']
          }
        }
      },
      // 压缩配置
      minify: 'terser',
      terserOptions: {
        compress: {
          drop_console: true,
          drop_debugger: true
        }
      },
      // 启用 CSS 代码分割
      cssCodeSplit: true,
      // 生成 source map
      sourcemap: false
    },
    
    // 开发服务器优化
    server: {
      fs: {
        allow: ['..'] // 允许访问上级目录
      }
    },
    
    // 依赖预构建
    optimizeDeps: {
      include: [
        'vue',
        'vue-router',
        'element-plus',
        '@element-plus/icons-vue'
      ]
    },
    
    // 路径别名
    resolve: {
      alias: {
        '@': resolve(__dirname, '../src'),
        '~': resolve(__dirname, '../')
      }
    }
  }
})

2. 图片优化组件

vue
<!-- src/components/ui/OptimizedImage.vue -->
<template>
  <div class="optimized-image" :class="className">
    <img
      ref="imageRef"
      :src="currentSrc"
      :alt="alt"
      :loading="priority ? 'eager' : 'lazy'"
      :width="width"
      :height="height"
      :class="imageClass"
      @load="handleLoad"
      @error="handleError"
    />
    
    <!-- 加载占位符 -->
    <div v-if="isLoading" class="image-placeholder">
      <el-skeleton-item variant="image" class="skeleton-image" />
    </div>
    
    <!-- 错误状态 -->
    <div v-if="hasError" class="image-error">
      <el-icon size="32" color="var(--el-color-info)">
        <Picture />
      </el-icon>
      <span>图片加载失败</span>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Picture } from '@element-plus/icons-vue'

interface Props {
  src: string
  alt: string
  width?: number
  height?: number
  className?: string
  priority?: boolean
  sizes?: string
  quality?: number
}

const props = withDefaults(defineProps<Props>(), {
  priority: false,
  quality: 80
})

const imageRef = ref<HTMLImageElement>()
const isLoading = ref(true)
const hasError = ref(false)

// 生成响应式图片 URL
const currentSrc = computed(() => {
  const { src, quality, width } = props
  
  // 如果是外部链接,直接返回
  if (src.startsWith('http')) {
    return src
  }
  
  // 本地图片优化
  const params = new URLSearchParams()
  if (quality) params.set('q', quality.toString())
  if (width) params.set('w', width.toString())
  
  const queryString = params.toString()
  return queryString ? `${src}?${queryString}` : src
})

const imageClass = computed(() => {
  return {
    'image-loaded': !isLoading.value && !hasError.value,
    'image-loading': isLoading.value,
    'image-error': hasError.value
  }
})

const handleLoad = () => {
  isLoading.value = false
  hasError.value = false
}

const handleError = () => {
  isLoading.value = false
  hasError.value = true
}

// 预加载关键图片
onMounted(() => {
  if (props.priority && imageRef.value) {
    const link = document.createElement('link')
    link.rel = 'preload'
    link.as = 'image'
    link.href = currentSrc.value
    document.head.appendChild(link)
  }
})
</script>

<style scoped>
.optimized-image {
  position: relative;
  overflow: hidden;
  display: inline-block;
}

.image-loaded {
  opacity: 1;
  transition: opacity 0.3s ease;
}

.image-loading {
  opacity: 0;
}

.image-placeholder {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--el-color-info-light-9);
}

.skeleton-image {
  width: 100%;
  height: 100%;
}

.image-error {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background: var(--el-color-info-light-9);
  color: var(--el-color-info);
  font-size: 14px;
  gap: 8px;
}
</style>

3. 代码分割与懒加载

typescript
// src/utils/lazyLoad.ts
import { defineAsyncComponent } from 'vue'
import { ElLoading } from 'element-plus'

// 懒加载组件工厂
export function createLazyComponent(loader: () => Promise<any>) {
  return defineAsyncComponent({
    loader,
    loadingComponent: {
      template: '<div class="loading-container"><el-skeleton :rows="3" animated /></div>'
    },
    errorComponent: {
      template: '<div class="error-container">组件加载失败</div>'
    },
    delay: 200,
    timeout: 3000
  })
}

// 路由懒加载
export const lazyRoutes = {
  BlogList: () => import('@/components/blog/BlogList.vue'),
  BlogDetail: () => import('@/components/blog/BlogDetail.vue'),
  SearchPage: () => import('@/components/search/SearchPage.vue')
}

4. 缓存策略

typescript
// src/utils/cache.ts
class CacheManager {
  private cache = new Map<string, { data: any; timestamp: number; ttl: number }>()
  
  set(key: string, data: any, ttl: number = 5 * 60 * 1000) {
    this.cache.set(key, {
      data,
      timestamp: Date.now(),
      ttl
    })
  }
  
  get(key: string) {
    const item = this.cache.get(key)
    if (!item) return null
    
    if (Date.now() - item.timestamp > item.ttl) {
      this.cache.delete(key)
      return null
    }
    
    return item.data
  }
  
  clear() {
    this.cache.clear()
  }
  
  // 清理过期缓存
  cleanup() {
    const now = Date.now()
    for (const [key, item] of this.cache.entries()) {
      if (now - item.timestamp > item.ttl) {
        this.cache.delete(key)
      }
    }
  }
}

export const cacheManager = new CacheManager()

// 定期清理过期缓存
setInterval(() => {
  cacheManager.cleanup()
}, 10 * 60 * 1000) // 每10分钟清理一次

5. 资源预加载

typescript
// src/utils/preload.ts
export class ResourcePreloader {
  private preloadedResources = new Set<string>()
  
  // 预加载图片
  preloadImage(src: string): Promise<void> {
    if (this.preloadedResources.has(src)) {
      return Promise.resolve()
    }
    
    return new Promise((resolve, reject) => {
      const img = new Image()
      img.onload = () => {
        this.preloadedResources.add(src)
        resolve()
      }
      img.onerror = reject
      img.src = src
    })
  }
  
  // 预加载 CSS
  preloadCSS(href: string): Promise<void> {
    if (this.preloadedResources.has(href)) {
      return Promise.resolve()
    }
    
    return new Promise((resolve, reject) => {
      const link = document.createElement('link')
      link.rel = 'preload'
      link.as = 'style'
      link.href = href
      link.onload = () => {
        this.preloadedResources.add(href)
        resolve()
      }
      link.onerror = reject
      document.head.appendChild(link)
    })
  }
  
  // 预加载 JavaScript
  preloadJS(src: string): Promise<void> {
    if (this.preloadedResources.has(src)) {
      return Promise.resolve()
    }
    
    return new Promise((resolve, reject) => {
      const link = document.createElement('link')
      link.rel = 'preload'
      link.as = 'script'
      link.href = src
      link.onload = () => {
        this.preloadedResources.add(src)
        resolve()
      }
      link.onerror = reject
      document.head.appendChild(link)
    })
  }
}

export const preloader = new ResourcePreloader()

部署配置

1. Netlify 部署

toml
# netlify.toml
[build]
  publish = ".vitepress/dist"
  command = "npm run build"

[build.environment]
  NODE_VERSION = "18"
  NPM_VERSION = "9"

# 重定向规则
[[redirects]]
  from = "/blog/*"
  to = "/posts/:splat"
  status = 301

[[redirects]]
  from = "/feed"
  to = "/feed.xml"
  status = 301

# SPA 回退
[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

# 安全头部
[[headers]]
  for = "/*"
  [headers.values]
    X-Frame-Options = "DENY"
    X-XSS-Protection = "1; mode=block"
    X-Content-Type-Options = "nosniff"
    Referrer-Policy = "strict-origin-when-cross-origin"
    Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:;"

# 静态资源缓存
[[headers]]
  for = "/assets/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

[[headers]]
  for = "*.js"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

[[headers]]
  for = "*.css"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

# HTML 缓存
[[headers]]
  for = "*.html"
  [headers.values]
    Cache-Control = "public, max-age=0, must-revalidate"

2. GitHub Actions 自动部署

yaml
# .github/workflows/deploy.yml
name: Deploy to Netlify

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout
      uses: actions/checkout@v4
      with:
        fetch-depth: 0 # 获取完整历史,用于生成最后更新时间
    
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: '18'
        cache: 'npm'
    
    - name: Install dependencies
      run: npm ci
    
    - name: Build
      run: npm run build
      env:
        NODE_ENV: production
    
    - name: Deploy to Netlify
      uses: nwtgck/actions-netlify@v2.0
      with:
        publish-dir: './.vitepress/dist'
        production-branch: main
        github-token: ${{ secrets.GITHUB_TOKEN }}
        deploy-message: "Deploy from GitHub Actions"
        enable-pull-request-comment: false
        enable-commit-comment: true
        overwrites-pull-request-comment: true
      env:
        NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
        NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
      timeout-minutes: 1

3. GitHub Pages 部署

yaml
# .github/workflows/deploy-pages.yml
name: Deploy to GitHub Pages

on:
  push:
    branches: [ main ]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: "pages"
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: Setup Pages
        uses: actions/configure-pages@v4
      
      - name: Install dependencies
        run: npm ci
      
      - name: Build with VitePress
        run: npm run build
      
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: .vitepress/dist
  
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

4. 腾讯云 CDN + COS 部署

yaml
# .github/workflows/deploy-tencent.yml
name: Deploy to Tencent Cloud

on:
  push:
    branches: [ main ]
  workflow_dispatch:

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout
      uses: actions/checkout@v4
      with:
        fetch-depth: 0
    
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: '18'
        cache: 'npm'
    
    - name: Install dependencies
      run: npm ci
    
    - name: Build
      run: npm run build
      env:
        NODE_ENV: production
    
    - name: Configure Tencent Cloud CLI
      run: |
        pip install tccli
        tccli configure set secretId ${{ secrets.TENCENT_SECRET_ID }}
        tccli configure set secretKey ${{ secrets.TENCENT_SECRET_KEY }}
        tccli configure set region ap-beijing
    
    - name: Upload to COS
      run: |
        # 安装 coscmd
        pip install coscmd
        
        # 配置 COS
        coscmd config -a ${{ secrets.TENCENT_SECRET_ID }} -s ${{ secrets.TENCENT_SECRET_KEY }} -b ${{ secrets.COS_BUCKET }} -r ap-beijing
        
        # 上传文件
        coscmd upload -r .vitepress/dist/ /
        
        # 设置缓存策略
        coscmd putobjectacl --grant-read uri="http://cam.qcloud.com/groups/global/AllUsers" /
    
    - name: Purge CDN Cache
      run: |
        tccli cdn PurgePathCache --cli-input-json '{
          "Paths": ["https://tanzhenxing.com/*"],
          "FlushType": "flush"
        }'

5. 腾讯云配置脚本

bash
#!/bin/bash
# scripts/setup-tencent-cloud.sh

# 配置变量
BUCKET_NAME="tanzhenxing-website"
REGION="ap-beijing"
DOMAIN="tanzhenxing.com"

echo "开始配置腾讯云资源..."

# 1. 创建 COS 存储桶
echo "创建 COS 存储桶..."
tccli cos CreateBucket --cli-input-json '{
  "Bucket": "'$BUCKET_NAME'-'$(date +%s)'",
  "CreateBucketConfiguration": {
    "BucketAclGrantRead": "uri=\"http://cam.qcloud.com/groups/global/AllUsers\""
  }
}'

# 2. 配置存储桶策略
echo "配置存储桶策略..."
tccli cos PutBucketPolicy --cli-input-json '{
  "Bucket": "'$BUCKET_NAME'",
  "Policy": {
    "version": "2.0",
    "Statement": [{
      "Effect": "Allow",
      "Principal": {"qcs": ["qcs::cam::anyone:anyone"]},
      "Action": ["cos:GetObject"],
      "Resource": ["qcs::cos:'$REGION':uid/*:'$BUCKET_NAME'/*"]
    }]
  }
}'

# 3. 配置 CDN 加速域名
echo "配置 CDN 加速域名..."
tccli cdn AddCdnDomain --cli-input-json '{
  "Domain": "'$DOMAIN'",
  "ServiceType": "web",
  "Origin": {
    "Origins": ["'$BUCKET_NAME'.cos.'$REGION'.myqcloud.com"],
    "OriginType": "cos",
    "ServerName": "'$BUCKET_NAME'.cos.'$REGION'.myqcloud.com"
  },
  "Cache": {
    "SimpleCache": {
      "CacheRules": [
        {
          "CacheType": "file",
          "CacheContents": ["jpg", "jpeg", "png", "gif", "webp", "css", "js"],
          "CacheTime": 31536000
        },
        {
          "CacheType": "file",
          "CacheContents": ["html"],
          "CacheTime": 0
        }
      ]
    }
  },
  "Https": {
    "Switch": "on",
    "Http2": "on",
    "CertInfo": {
      "CertId": "auto"
    }
  }
}'

echo "腾讯云资源配置完成!"

6. 本地部署脚本

bash
#!/bin/bash
# scripts/deploy.sh

set -e

echo "开始构建和部署..."

# 1. 安装依赖
echo "安装依赖..."
npm ci

# 2. 构建项目
echo "构建项目..."
npm run build

# 3. 上传到 COS
echo "上传文件到腾讯云 COS..."
coscmd upload -r .vitepress/dist/ /

# 4. 刷新 CDN 缓存
echo "刷新 CDN 缓存..."
tccli cdn PurgePathCache --cli-input-json '{
  "Paths": ["https://tanzhenxing.com/*"],
  "FlushType": "flush"
}'

echo "部署完成!"
echo "网站地址: https://tanzhenxing.com"

7. 环境变量配置

bash
# .env.example
# 腾讯云配置
TENCENT_SECRET_ID=your_secret_id
TENCENT_SECRET_KEY=your_secret_key
COS_BUCKET=tanzhenxing-website
COS_REGION=ap-beijing
CDN_DOMAIN=tanzhenxing.com

# 网站配置
SITE_URL=https://tanzhenxing.com
SITE_NAME=谭振兴聊前端

监控与分析

1. 性能监控

  • Core Web Vitals 跟踪
  • 页面加载时间监控
  • 错误日志收集

2. 用户分析

  • Google Analytics 4
  • 用户行为分析
  • 转化率跟踪

3. SEO 监控

  • 搜索排名跟踪
  • 索引状态监控
  • 链接分析

安全措施

1. 内容安全

  • CSP 头部配置
  • XSS 防护
  • CSRF 保护

2. 数据保护

  • HTTPS 强制
  • 敏感信息加密
  • 输入验证

3. 访问控制

  • 管理后台保护
  • API 限流
  • 恶意请求过滤

这个技术架构方案提供了完整的实现细节,确保网站的高性能、安全性和可维护性。

用代码构建未来,让技术改变世界 🚀 | 专注前端技术,分享开发经验