Appearance
谭振兴聊前端 - 技术架构详细方案
项目结构设计
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 限流
- 恶意请求过滤
这个技术架构方案提供了完整的实现细节,确保网站的高性能、安全性和可维护性。