第 12 章:样式美化与响应式
约 3819 字大约 13 分钟
2025-12-19
本章学习目标
- 理解 CSS 变量与设计系统
- 实现暗黑模式切换
- 掌握响应式设计原理
- 学习 CSS 动画与交互优化
- 🎉 成果:网站变得又美又好用!
功能都实现了,但博客看起来还是有点"程序员审美"?这一章我们来让它焕然一新!
本章结束后你会得到什么
一个现代化的博客——支持暗黑模式、在手机上完美显示、有流畅的动画效果!
12.1 CSS 变量与设计系统
12.1.1 什么是设计系统?
历史趣事:Google Material Design 的诞生
2014 年,Google 发布了 Material Design,这是一套完整的设计语言。
在此之前,Google 的各个产品(Gmail、YouTube、Maps)风格各异,用户体验不一致。Material Design 统一了:
- 颜色系统:主色、强调色、背景色
- 排版系统:字体大小、行高、字重
- 间距系统:4px 为基础单位
- 组件规范:按钮、卡片、对话框
这套设计系统影响了整个互联网,如今几乎所有大公司都有自己的设计系统:Apple 的 Human Interface Guidelines、Microsoft 的 Fluent Design、Ant Design 等。
设计系统的核心思想:统一管理,一处修改,处处生效。
12.1.2 CSS 自定义属性(变量)
CSS 变量让我们可以定义可复用的值:
/* 在 :root 中定义全局变量 */
:root {
--primary-color: #667eea;
--secondary-color: #764ba2;
--text-color: #1a202c;
--bg-color: #ffffff;
--card-bg: #f9fafb;
--border-color: #e5e7eb;
/* 间距系统(基于 4px) */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
/* 圆角 */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
--radius-full: 9999px;
/* 阴影 */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
}
/* 使用变量 */
.card {
background: var(--card-bg);
border-radius: var(--radius-md);
padding: var(--spacing-lg);
box-shadow: var(--shadow-md);
}12.1.3 创建主题配置文件
创建 frontend/app/assets/css/variables.css:
/* frontend/app/assets/css/variables.css */
/* ===== 亮色主题(默认) ===== */
:root {
/* 品牌色 */
--color-primary: #667eea;
--color-primary-dark: #5a67d8;
--color-secondary: #764ba2;
/* 文字颜色 */
--color-text-primary: #1a202c;
--color-text-secondary: #4a5568;
--color-text-muted: #718096;
/* 背景颜色 */
--color-bg-primary: #ffffff;
--color-bg-secondary: #f7fafc;
--color-bg-tertiary: #edf2f7;
/* 边框 */
--color-border: #e2e8f0;
--color-border-light: #edf2f7;
/* 状态颜色 */
--color-success: #48bb78;
--color-warning: #ed8936;
--color-error: #f56565;
--color-info: #4299e1;
/* 间距 */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
/* 圆角 */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 1rem;
--radius-xl: 1.5rem;
--radius-full: 9999px;
/* 阴影 */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
/* 过渡 */
--transition-fast: 150ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease;
}
/* ===== 暗色主题 ===== */
:root.dark {
--color-text-primary: #f7fafc;
--color-text-secondary: #e2e8f0;
--color-text-muted: #a0aec0;
--color-bg-primary: #1a202c;
--color-bg-secondary: #2d3748;
--color-bg-tertiary: #4a5568;
--color-border: #4a5568;
--color-border-light: #2d3748;
/* 暗色模式下调整阴影 */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
}12.1.4 在 Nuxt 中引入 CSS
修改 frontend/nuxt.config.ts:
export default defineNuxtConfig({
css: [
'~/assets/css/variables.css'
],
// ... 其他配置
})CSS 变量 vs Tailwind 配置
你可能会问:Tailwind 不是已经有颜色系统了吗?
两者可以结合使用!
- CSS 变量:用于动态主题切换(暗黑模式)
- Tailwind:用于快速编写样式
我们可以在 Tailwind 配置中引用 CSS 变量,实现最佳效果。
12.2 暗黑模式实现
12.2.1 为什么需要暗黑模式?
暗黑模式已经成为现代应用的标配:
- 护眼:在暗光环境下减少眼睛疲劳
- 省电:OLED 屏幕显示黑色时几乎不耗电
- 美观:很多用户就是喜欢暗色风格
- 专业:开发者、设计师普遍偏爱暗色界面
12.2.2 CSS 媒体查询检测系统偏好
CSS 可以检测用户的系统主题偏好:
/* 当系统设置为暗色模式时 */
@media (prefers-color-scheme: dark) {
:root {
--color-bg-primary: #1a202c;
--color-text-primary: #f7fafc;
}
}但这种方式不够灵活——用户无法手动切换。我们需要实现手动+自动结合的方案。
12.2.3 创建主题切换 composable
创建 frontend/app/composables/useTheme.ts:
// frontend/app/composables/useTheme.ts
type Theme = 'light' | 'dark' | 'system'
export const useTheme = () => {
// 当前主题设置(用户选择)
const themeSetting = useState<Theme>('theme_setting', () => 'system')
// 实际应用的主题(考虑系统偏好后的结果)
const actualTheme = useState<'light' | 'dark'>('actual_theme', () => 'light')
// 是否是暗色模式
const isDark = computed(() => actualTheme.value === 'dark')
// 获取系统主题偏好
const getSystemTheme = (): 'light' | 'dark' => {
if (process.server) return 'light'
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
// 应用主题到 DOM
const applyTheme = (theme: 'light' | 'dark') => {
if (process.server) return
actualTheme.value = theme
if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
// 更新主题
const updateTheme = () => {
let theme: 'light' | 'dark'
if (themeSetting.value === 'system') {
theme = getSystemTheme()
} else {
theme = themeSetting.value
}
applyTheme(theme)
}
// 设置主题
const setTheme = (theme: Theme) => {
themeSetting.value = theme
// 保存到 localStorage
if (process.client) {
localStorage.setItem('theme', theme)
}
updateTheme()
}
// 切换主题(在亮/暗之间切换)
const toggleTheme = () => {
const newTheme = isDark.value ? 'light' : 'dark'
setTheme(newTheme)
}
// 初始化主题
const initTheme = () => {
if (process.server) return
// 从 localStorage 读取用户设置
const savedTheme = localStorage.getItem('theme') as Theme | null
if (savedTheme) {
themeSetting.value = savedTheme
}
updateTheme()
// 监听系统主题变化
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (themeSetting.value === 'system') {
updateTheme()
}
})
}
return {
themeSetting,
actualTheme,
isDark,
setTheme,
toggleTheme,
initTheme
}
}12.2.4 创建主题切换按钮组件
创建 frontend/app/components/ThemeToggle.vue:
<!-- frontend/app/components/ThemeToggle.vue -->
<template>
<button
@click="toggleTheme"
class="theme-toggle"
:title="isDark ? '切换到亮色模式' : '切换到暗色模式'"
>
<span class="icon">{{ isDark ? '🌙' : '☀️' }}</span>
</button>
</template>
<script setup>
const { isDark, toggleTheme } = useTheme()
</script>
<style scoped>
.theme-toggle {
width: 40px;
height: 40px;
border-radius: var(--radius-full);
border: 2px solid var(--color-border);
background: var(--color-bg-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-normal);
}
.theme-toggle:hover {
border-color: var(--color-primary);
transform: rotate(15deg);
}
.icon {
font-size: 1.25rem;
}
</style>12.2.5 初始化主题
在 frontend/app/app.vue 中初始化:
<!-- frontend/app/app.vue -->
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<script setup>
const { initTheme } = useTheme()
const { loadFeatures } = useFeature()
const { initAuth } = useAuth()
onMounted(() => {
initTheme() // 初始化主题
loadFeatures() // 加载功能配置
initAuth() // 初始化认证状态
})
</script>12.2.6 更新全局样式
确保全局样式使用 CSS 变量:
/* 在 variables.css 中添加 */
body {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
transition: background-color var(--transition-normal),
color var(--transition-normal);
}
/* 暗色模式下的特殊调整 */
:root.dark {
color-scheme: dark;
}
:root.dark img {
opacity: 0.9; /* 图片稍微暗一点,减少刺眼 */
}
:root.dark ::selection {
background: var(--color-primary);
color: white;
}12.3 响应式设计基础
12.3.1 移动优先设计理念
历史趣事:iPhone 如何改变了网页设计
2007 年,乔布斯发布第一代 iPhone 时,展示了用 Safari 浏览完整网页的功能。但那时的网页都是为桌面设计的,在小屏幕上体验很差。
起初,网站开发者会专门做一个"移动版网站"(m.example.com)。但这意味着要维护两套代码,成本很高。
2010 年,Ethan Marcotte 在《A List Apart》上发表了著名文章《Responsive Web Design》,提出了响应式设计的概念:一套代码,适配所有设备。
他的核心技术是:
- 流式布局:用百分比代替固定宽度
- 弹性图片:
max-width: 100% - 媒体查询:根据屏幕宽度应用不同样式
如今,响应式设计已经是 Web 开发的基本功。
移动优先(Mobile First) 的核心理念:
- 先设计小屏幕的样式(基础样式)
- 再用媒体查询增强大屏幕的样式
- 这样可以确保移动端性能最优
12.3.2 CSS 媒体查询
/* 基础样式(移动端) */
.container {
padding: 1rem;
}
.card-grid {
display: grid;
grid-template-columns: 1fr; /* 单列 */
gap: 1rem;
}
/* 平板(768px 及以上) */
@media (min-width: 768px) {
.container {
padding: 2rem;
}
.card-grid {
grid-template-columns: repeat(2, 1fr); /* 两列 */
}
}
/* 桌面(1024px 及以上) */
@media (min-width: 1024px) {
.container {
max-width: 1200px;
margin: 0 auto;
}
.card-grid {
grid-template-columns: repeat(3, 1fr); /* 三列 */
}
}12.3.3 常用断点设置
| 断点名称 | 尺寸 | 设备 |
|---|---|---|
| sm | 640px | 大手机 |
| md | 768px | 平板 |
| lg | 1024px | 小桌面 |
| xl | 1280px | 大桌面 |
| 2xl | 1536px | 超宽屏 |
Tailwind 的响应式前缀
Tailwind 让响应式开发变得超级简单:
<!-- 移动端单列,平板两列,桌面三列 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- 卡片内容 -->
</div>前缀 md: 表示"在 md 断点及以上应用这个样式"。
12.3.4 响应式工具类
在 variables.css 中添加一些实用的响应式工具:
/* 响应式隐藏/显示 */
.hide-mobile {
display: none;
}
.hide-desktop {
display: block;
}
@media (min-width: 768px) {
.hide-mobile {
display: block;
}
.hide-desktop {
display: none;
}
}
/* 响应式容器 */
.container {
width: 100%;
max-width: 100%;
padding-left: var(--space-4);
padding-right: var(--space-4);
margin: 0 auto;
}
@media (min-width: 640px) {
.container { max-width: 640px; }
}
@media (min-width: 768px) {
.container { max-width: 768px; }
}
@media (min-width: 1024px) {
.container { max-width: 1024px; }
}
@media (min-width: 1280px) {
.container { max-width: 1280px; }
}12.4 博客页面响应式改造
12.4.1 响应式导航栏
桌面端显示完整导航,移动端显示汉堡菜单:
创建 frontend/app/components/NavBar.vue:
<!-- frontend/app/components/NavBar.vue -->
<template>
<nav class="navbar">
<div class="nav-container">
<!-- Logo -->
<NuxtLink to="/" class="nav-logo">
📝 我的博客
</NuxtLink>
<!-- 桌面端导航 -->
<div class="nav-links hide-mobile">
<NuxtLink to="/blog" class="nav-link">文章</NuxtLink>
<NuxtLink v-if="isLoggedIn" to="/user" class="nav-link">个人中心</NuxtLink>
<NuxtLink v-if="canPublish" to="/blog/publish" class="nav-link">写文章</NuxtLink>
<template v-if="isLoggedIn">
<span class="nav-user">{{ user?.username }}</span>
<button @click="logout" class="nav-btn-text">退出</button>
</template>
<template v-else>
<NuxtLink to="/auth/login" class="nav-btn">登录</NuxtLink>
</template>
<ThemeToggle />
</div>
<!-- 移动端菜单按钮 -->
<button class="menu-btn hide-desktop" @click="isMenuOpen = !isMenuOpen">
{{ isMenuOpen ? '✕' : '☰' }}
</button>
</div>
<!-- 移动端下拉菜单 -->
<div v-if="isMenuOpen" class="mobile-menu hide-desktop">
<NuxtLink to="/blog" class="mobile-link" @click="isMenuOpen = false">
📄 文章列表
</NuxtLink>
<NuxtLink v-if="isLoggedIn" to="/user" class="mobile-link" @click="isMenuOpen = false">
👤 个人中心
</NuxtLink>
<NuxtLink v-if="canPublish" to="/blog/publish" class="mobile-link" @click="isMenuOpen = false">
✍️ 写文章
</NuxtLink>
<div class="mobile-divider"></div>
<template v-if="isLoggedIn">
<span class="mobile-user">👋 {{ user?.username }}</span>
<button @click="handleLogout" class="mobile-link">🚪 退出登录</button>
</template>
<NuxtLink v-else to="/auth/login" class="mobile-link" @click="isMenuOpen = false">
🔐 登录
</NuxtLink>
<div class="mobile-divider"></div>
<div class="mobile-theme">
<span>主题</span>
<ThemeToggle />
</div>
</div>
</nav>
</template>
<script setup>
const { user, isLoggedIn, logout } = useAuth()
const { canPublish } = usePermission()
const isMenuOpen = ref(false)
function handleLogout() {
logout()
isMenuOpen.value = false
navigateTo('/')
}
// 路由变化时关闭菜单
const route = useRoute()
watch(() => route.path, () => {
isMenuOpen.value = false
})
</script>
<style scoped>
.navbar {
position: sticky;
top: 0;
z-index: 100;
background: var(--color-bg-primary);
border-bottom: 1px solid var(--color-border);
}
.nav-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
max-width: 1280px;
margin: 0 auto;
}
.nav-logo {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text-primary);
text-decoration: none;
}
.nav-links {
display: flex;
align-items: center;
gap: var(--space-6);
}
.nav-link {
color: var(--color-text-secondary);
text-decoration: none;
font-weight: 500;
transition: color var(--transition-fast);
}
.nav-link:hover {
color: var(--color-primary);
}
.nav-user {
color: var(--color-text-muted);
}
.nav-btn {
padding: var(--space-2) var(--space-4);
background: var(--color-primary);
color: white;
border-radius: var(--radius-md);
text-decoration: none;
font-weight: 500;
}
.nav-btn-text {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
}
.menu-btn {
font-size: 1.5rem;
background: none;
border: none;
cursor: pointer;
padding: var(--space-2);
}
/* 移动端菜单 */
.mobile-menu {
padding: var(--space-4);
border-top: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.mobile-link {
display: block;
padding: var(--space-3) 0;
color: var(--color-text-primary);
text-decoration: none;
background: none;
border: none;
width: 100%;
text-align: left;
font-size: 1rem;
cursor: pointer;
}
.mobile-user {
display: block;
padding: var(--space-3) 0;
color: var(--color-text-muted);
}
.mobile-divider {
height: 1px;
background: var(--color-border);
margin: var(--space-2) 0;
}
.mobile-theme {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) 0;
}
/* 响应式显示/隐藏 */
.hide-mobile { display: none; }
.hide-desktop { display: flex; }
@media (min-width: 768px) {
.hide-mobile { display: flex; }
.hide-desktop { display: none; }
}
</style>12.4.2 文章列表响应式
更新文章列表页面的样式:
<!-- 文章列表的响应式网格 -->
<template>
<div class="article-grid">
<ArticleCard
v-for="article in articles"
:key="article.id"
:article="article"
/>
</div>
</template>
<style scoped>
.article-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-6);
}
@media (min-width: 768px) {
.article-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.article-grid {
grid-template-columns: repeat(3, 1fr);
}
}
</style>12.4.3 文章详情页适配
/* 文章详情页响应式 */
.article-detail {
max-width: 100%;
padding: var(--space-4);
}
@media (min-width: 768px) {
.article-detail {
max-width: 720px;
margin: 0 auto;
padding: var(--space-8);
}
}
/* 文章内容中的图片 */
.article-content img {
max-width: 100%;
height: auto;
border-radius: var(--radius-md);
}
/* 代码块响应式 */
.article-content pre {
overflow-x: auto;
padding: var(--space-4);
border-radius: var(--radius-md);
font-size: 0.875rem;
}
@media (min-width: 768px) {
.article-content pre {
font-size: 1rem;
}
}12.5 动画与交互优化
12.5.1 CSS 过渡动画
给交互元素添加平滑的过渡效果:
/* 按钮悬停效果 */
.btn {
transition: all var(--transition-normal);
}
.btn:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.btn:active {
transform: translateY(0);
}
/* 卡片悬停效果 */
.card {
transition: all var(--transition-normal);
}
.card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
/* 链接下划线动画 */
.link-animated {
position: relative;
}
.link-animated::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background: var(--color-primary);
transition: width var(--transition-normal);
}
.link-animated:hover::after {
width: 100%;
}12.5.2 加载状态优化(骨架屏)
创建 frontend/app/components/SkeletonCard.vue:
<!-- frontend/app/components/SkeletonCard.vue -->
<template>
<div class="skeleton-card">
<div class="skeleton-image"></div>
<div class="skeleton-content">
<div class="skeleton-title"></div>
<div class="skeleton-text"></div>
<div class="skeleton-text short"></div>
</div>
</div>
</template>
<style scoped>
.skeleton-card {
background: var(--color-bg-primary);
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--color-border);
}
.skeleton-image {
height: 200px;
background: linear-gradient(
90deg,
var(--color-bg-tertiary) 25%,
var(--color-bg-secondary) 50%,
var(--color-bg-tertiary) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-content {
padding: var(--space-4);
}
.skeleton-title,
.skeleton-text {
height: 1rem;
border-radius: var(--radius-sm);
background: linear-gradient(
90deg,
var(--color-bg-tertiary) 25%,
var(--color-bg-secondary) 50%,
var(--color-bg-tertiary) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-title {
width: 70%;
height: 1.5rem;
margin-bottom: var(--space-3);
}
.skeleton-text {
margin-bottom: var(--space-2);
}
.skeleton-text.short {
width: 50%;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>使用骨架屏:
<template>
<div class="article-grid">
<!-- 加载中显示骨架屏 -->
<template v-if="pending">
<SkeletonCard v-for="i in 6" :key="i" />
</template>
<!-- 加载完成显示文章 -->
<template v-else>
<ArticleCard
v-for="article in articles"
:key="article.id"
:article="article"
/>
</template>
</div>
</template>12.5.3 页面切换动画
在 app.vue 中添加页面过渡:
<template>
<NuxtLayout>
<NuxtPage
:transition="{
name: 'page',
mode: 'out-in'
}"
/>
</NuxtLayout>
</template>
<style>
/* 页面过渡动画 */
.page-enter-active,
.page-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.page-enter-from {
opacity: 0;
transform: translateY(10px);
}
.page-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>12.5.4 滚动相关优化
/* 平滑滚动 */
html {
scroll-behavior: smooth;
}
/* 滚动条样式(Webkit浏览器) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}
/* 暗色模式下的滚动条 */
:root.dark ::-webkit-scrollbar-track {
background: var(--color-bg-tertiary);
}12.6 小结与练习
🎉 恭喜你完成了样式美化!
现在你的博客具备了:
- ✅ CSS 变量系统:统一管理颜色和间距
- ✅ 暗黑模式:支持亮色/暗色切换
- ✅ 响应式设计:手机、平板、桌面完美适配
- ✅ 流畅动画:过渡效果、骨架屏加载
你的博客看起来专业多了!
本章回顾
| 概念 | 说明 |
|---|---|
| CSS 变量 | 定义可复用的样式值,支持动态切换 |
| 暗黑模式 | 通过切换 CSS 类名实现主题切换 |
| 响应式设计 | 一套代码适配多种屏幕尺寸 |
| 移动优先 | 先设计小屏幕样式,再增强大屏幕 |
动手练习
练习 1:实现多主题色切换 ⭐⭐
除了亮/暗模式,实现多种主题色(蓝色、绿色、紫色)。
提示:
- 定义多组颜色变量
- 通过 CSS 类名切换(如
.theme-blue,.theme-green)
练习 2:完善骨架屏组件 ⭐⭐
为不同类型的内容创建骨架屏:
- 文章详情骨架屏
- 评论列表骨架屏
- 用户信息骨架屏
下一章预告
网站变好看了,但是它够快吗?代码够规范吗?
下一章我们将学习性能优化与代码规范,让你的代码更专业、网站更快!