一、文章系统架构
整体流程
content/articles/*.md 原始 Markdown 文件
│
▼
server/api/articles/index.get.ts 读取所有 .md → gray-matter 解析 frontmatter → 返回列表
server/api/articles/[slug].get.ts 读取单个 .md → markdown-it 渲染 HTML → 返回正文+TOC
│
▼
pages/articles/index.vue 列表页:网格/时间线双视图 + 筛选 + 排序 + 分页
pages/articles/[slug].vue 详情页:Markdown 正文 + 右侧目录导航为什么不用 @nuxt/content
@nuxt/content 依赖 better-sqlite3,在 Windows 上需要额外编译工具链,容易失败。项目改用轻量方案:
gray-matter— 解析 YAML frontmatter(标题、日期、分类、标签、封面等)markdown-it— 将 Markdown 渲染为 HTML- 手写 Server API —
server/api/articles/下两个端点
Server API 详情
两个 API 端点共享 server/utils/articles.ts 中的工具层,封装文件读写、缓存、封面提取等通用逻辑。
server/utils/articles.ts — 共享工具层
// 文件读写(当前 node:fs,以后可换任何存储驱动)
function getArticlesDir() {
if (process.dev) return resolve(process.cwd(), 'content/articles')
return resolve(process.cwd(), 'server/assets/content/articles')
}
async function listFiles() { /* readdir + 过滤 .md */ }
async function readFileRaw(filename) { /* readFile */ }
// 业务工具
export function extractCover(data, content) { /* frontmatter > 正文图片 */ }
export function formatDate(d) { /* Date 对象 → YYYY-MM-DD */ }
// 缓存层(当前内存,以后可接 Redis)
let _cacheList = null
export function getCachedList(fetcher) {
if (process.dev) _cacheList = null // 开发每次都读
if (_cacheList) return _cacheList // 生产缓存复用
return (_cacheList = await fetcher())
}GET /api/articles — 文章列表
export default defineEventHandler(async () => {
return getCachedList(async () => {
const files = await listFiles()
const list = await Promise.all(files.map(async (filename) => {
const raw = await readFileRaw(filename)
const { data, content } = matter(raw)
return {
slug: filename.replace(/\.md$/, ''),
title: data.title || slug,
date: formatDate(data.date),
cover: extractCover(data, content),
// ...
}
}))
return list.filter(Boolean).sort((a, b) => b.date.localeCompare(a.date))
})
})GET /api/articles/[slug] — 文章详情
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
const raw = await readFileRaw(`${slug}.md`)
if (!raw) throw createError({ statusCode: 404 })
const { data, content } = matter(raw)
let html = md.render(content)
// 给 h2/h3 添加 id,提取 TOC
// 图片懒加载:<img loading="lazy" decoding="async">
// 代码块包裹:语言标签 + 复制按钮
return { title, description, date, tags, html, toc }
})生产环境部署(最终方案)
content/articles/*.md 不会自动进入 .output。在 nuxt.config.ts 中用构建 hook 自动复制:
// nuxt.config.ts
hooks: {
'nitro:build:public-assets': (nitro) => {
const src = resolve('content/articles')
const dest = resolve(nitro.options.output.dir, 'content/articles')
const files = readdirSync(src).filter(f => f.endsWith('.md'))
if (files.length) {
mkdirSync(dest, { recursive: true })
for (const f of files) copyFileSync(join(src, f), join(dest, f))
}
}
}构建后 .output 自带文章:
.output/
├── content/
│ └── articles/
│ ├── hello-world.md
│ └── ...
├── server/
│ └── index.mjs
└── public/路径(server/utils/articles.ts)—— dev 和 prod 通用,无需探测:
// Dev: cwd = project root, content/articles/ 直接存在
// Prod: cwd = .output/, content/articles/ 由 build hook 复制
const ARTICLES_DIR = resolve(process.cwd(), 'content/articles')渲染器:用自写依赖 server/utils/markdown.ts 替代 markdown-it,避免子依赖 entities 的打包缺失问题。
Docker 部署
仅需挂载 .output,文章已内建其中:
docker run -d --name nuxt -v /data/nuxt/.output:/app -p 3000:3000 镜像名Nginx 反向代理
关键:/api/articles 用 ^~ 前缀匹配优先于正则,否则会被 ~ ^/(admin|api)/ 劫持到 Django:
location ^~ /api/articles {
proxy_pass http://127.0.0.1:3000;
}
location ~ ^/(admin|api)/ {
uwsgi_pass django;
}
location / {
proxy_pass http://127.0.0.1:3000;
}踩过的坑
| 方案 | 结果 |
|---|---|
nitro.serverAssets |
行为不稳定 |
useStorage('assets:content') |
utility 文件中不可用 |
process.cwd() 多多路径探测 |
过度设计,实际 dev/prod 的 cwd 天生都指向正确位置 |
import.meta.url 推算 |
Nitro 打包后文件深度不固定,不可控 |
entities + nitro.externals.inline |
@5.0 覆盖了 Vue 需要的更高版本,首页 500 |
单独挂载 content/ 卷 |
多一个挂载点,复杂度高 |
最终方案:构建 hook 复制 → .output 自包含 → 一行路径 process.cwd() + content/articles → 自研零依赖渲染器。
SSG / SSR 独立性
项目不绑死 SSR:
| 部署方式 | 命令 | 结果 |
|---|---|---|
| SSR(Node 运行时) | nuxt build |
需要 Node 服务器 |
| SSG(纯静态) | nuxt generate |
HTML + JS + CSS,Nginx/CDN 直出 |
所有用户交互功能(主题切换、风铃、背景轮换)都是客户端逻辑,useCookie 在静态模式下自动退化到默认值,不影响功能。
二、文章列表页功能详解
视图模式:网格 + 时间线
const viewMode = useCookie<'grid' | 'timeline'>('article-view-mode', { default: () => 'grid' })- 网格模式:4 列卡片矩阵,每卡含封面图、分类、标签、标题、简介、日期
- 时间线模式:左侧竖线 + 圆形节点 + 左侧日期 + 右侧卡片,每项带
slideUpIn入场动画 - 切换通过
contentKeycomputed 触发Transition mode="out-in"平滑过渡
筛选与排序
const sortKey = useCookie<SortKey>('article-sort-key', { default: () => 'date-desc' })
const activeCategory = useCookie('article-category', { default: () => '全部' })
const activeTag = useCookie('article-tag', { default: () => '全部' })- 分类和标签从文章列表动态聚合(
computed+Set) - 排序支持最新、最早、按标题(中文拼音
localeCompare('zh')) - 筛选切换时重置分页计数
分页
时间线模式使用 displayCount + PAGE_SIZE 实现"加载更多"按钮。hasMore computed 判断是否到达末尾。
状态持久化
| 方案 | 刷新保留 | SPA 保留 | SSR 安全 |
|---|---|---|---|
localStorage |
✅ | ❌ | ❌ 闪烁 |
useState |
❌ | ✅ | ✅ |
useCookie |
✅ | ✅ | ✅ |
选择 useCookie:4 个 cookie 总共 <100 字节,服务端即可读取,首屏渲染直接输出正确状态。
三、backdrop-filter 闪烁问题
问题表现
页面切换时背景模糊短暂消失,出现未模糊的原始图片。
根因
backdrop-filter: blur() 实时采样背后像素。浏览器合成层在页面过渡期间重建,触发重采样,瞬间显示未模糊背景。
CSS transform 会创建新的包含块。页面过渡的 translateY(6px) 把背景 fixed 元素纳入自己的合成上下文,进一步加剧冲突。
修复方案演变
第一步:把 backdrop-filter 从覆盖层移到背景图层本身,用 filter: blur() 替代:
/* 背景 Layer A / B */
:style="{
filter: `blur(${blurPx}px)`,
WebkitFilter: `blur(${blurPx}px)`
}"第二步:背景层过渡从 transition-all 改为 transition-[filter,opacity]。transition-all 在浏览器重算样式时任何属性波动都会触发过渡,导致模糊短暂归零。
第三步:卡片和按钮去除 backdrop-blur-md。背景已预模糊,半透明底色 bg-white/50 透过模糊背景即毛玻璃效果,无需二次模糊。
保留:导航栏的 backdrop-blur-md 保留。它固定在滚动内容上方,需要模糊穿透的文字。
深色模式遮罩闪烁
覆盖层的 overlayColor 原本用 JS computed 写 inline style:
// 问题代码
const overlayColor = computed(() => {
return isDark.value ? 'rgba(8, 12, 35, 0.48)' : 'rgba(255, 235, 210, 0.08)'
})SSR 时 useColorMode() 未拿到用户偏好,默认渲染亮色;客户端 hydration 后切换到暗色,产生闪烁。
修复:改用 Tailwind dark: CSS 类。@nuxtjs/color-mode 的注入脚本在页面首次绘制前就把 dark 类写到 <html> 上,CSS 选择器直接匹配正确颜色,不依赖 JS 执行顺序:
<div class="bg-[rgba(255,235,210,0.08)] dark:bg-[rgba(8,12,35,0.48)] transition-colors duration-[1500ms]"></div>四、页面过渡系统
问题
手动用 Vue <Transition mode="out-in"> 包裹 <NuxtPage> 会卡死——Vue 过渡系统不知道 Nuxt 内部的 <Suspense> 和异步数据加载生命周期。
修复
使用 Nuxt 内置的 pageTransition 配置:
// nuxt.config.ts
app: {
pageTransition: { name: 'page', mode: 'out-in' }
}/* main.css */
.page-enter-active { transition: opacity .3s ease-out, transform .3s ease-out; }
.page-leave-active { transition: opacity .15s ease-in; }
.page-enter-from { opacity: 0; transform: translateY(6px); }
.page-leave-to { opacity: 0; }Nuxt 在渲染管线内部处理过渡时序,和 Suspense 配合正确。
风铃组件与页面过渡的冲突
问题:页面过渡的 translateY(6px) 把风铃的 position: absolute 容器向下偏移 6px,导致绳子和球位置错误。
根因:CSS 规范规定,transform 属性值不为 none 的元素会成为 position: fixed/absolute 子元素的新包含块。
修复:用 <Teleport to="body"> 将风铃渲染到 <body> 下,完全脱离页面包装器的 transform 作用域。同时改为 position-mode="fixed" 确保相对视口定位。
五、风铃组件优化
加载跳动
问题 1:ballX、ballY 初始值为 ref(0),组件渲染时球先出现在 (0,0),onMounted 中 initPositions() 才把它挪到正确位置。
修复:容器 opacity: 0 + transition: opacity .3s ease-out .15s。initPositions() 算好位置后用 nextTick 设 opacity: 1,平滑淡入。
问题 2:初始平衡位置公式错误。
// 错误:gravity * 80 是多余的缩放因子
const eqLength = props.restRopeLength + (props.gravity * 80) / props.springConstant
// 结果:160 + 28/0.035 = 960 → 被 cap 到半屏高度
// 正确:弹簧力 == 重力时的平衡位置
const eqLength = props.restRopeLength + props.gravity / props.springConstant
// 结果:160 + 0.35/0.035 = 170(正确的力学平衡点)六、代码审查修复汇总
| # | 问题 | 文件 | 修复 |
|---|---|---|---|
| 1 | content.config.ts 引用未安装的 @nuxt/content |
— | 已删除 |
| 2 | titleTemplate 在 nuxt.config.ts 和 app.vue 重复定义 |
nuxt.config.ts, app.vue | 统一到 app.vue |
| 3 | 滚动监听无节流 | layouts/default.vue | requestAnimationFrame 节流 |
| 4 | 代码复制按钮事件未清理 | pages/articles/[slug].vue | WeakMap + onUnmounted |
| 5 | 暗色模式表格偶数行 rgba(255,255,255,.03) 几乎透明 |
main.css | 改为实色 #1e293b |
| 6 | 文章列表空状态在 Transition 外 | pages/articles/index.vue | 移入作为 v-else 分支 |
| 7 | v-if/v-else-if 分支 key 相同 | pages/articles/index.vue | 加前缀 'grid-'/'timeline-'/'empty-' |
七、最终效果
- ✅ 页面切换平滑(淡入淡出 + 滑动,
out-in模式) - ✅ 背景模糊稳定(
filter: blur(),永不闪烁) - ✅ 卡片/按钮无冗余模糊(半透明底色 + 预模糊背景 = 毛玻璃)
- ✅ 深色模式遮罩无闪烁(CSS
dark:类,JS 无关) - ✅ 风铃加载无跳动(延迟淡入 + 正确平衡点 + Teleport 隔离)
- ✅ 文章状态跨页面持久化(
useCookie) - ✅ 生产部署文章正常加载(
serverAssets+ 环境自适应路径)