Y
YIBI

网站深度优化:文章系统、模糊闪烁与页面过渡全记录

2026-06-11NuxtCSSVue性能优化架构

一、文章系统架构

整体流程

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 — 共享工具层

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 — 文章列表

ts
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] — 文章详情

ts
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 自动复制:

ts
// 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 通用,无需探测:

ts
// 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,文章已内建其中:

bash
docker run -d --name nuxt -v /data/nuxt/.output:/app -p 3000:3000 镜像名

Nginx 反向代理

关键/api/articles^~ 前缀匹配优先于正则,否则会被 ~ ^/(admin|api)/ 劫持到 Django:

nginx
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 在静态模式下自动退化到默认值,不影响功能。


二、文章列表页功能详解

视图模式:网格 + 时间线

ts
const viewMode = useCookie<'grid' | 'timeline'>('article-view-mode', { default: () => 'grid' })
  • 网格模式:4 列卡片矩阵,每卡含封面图、分类、标签、标题、简介、日期
  • 时间线模式:左侧竖线 + 圆形节点 + 左侧日期 + 右侧卡片,每项带 slideUpIn 入场动画
  • 切换通过 contentKey computed 触发 Transition mode="out-in" 平滑过渡

筛选与排序

ts
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() 替代:

css
/* 背景 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:

js
// 问题代码
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 执行顺序:

html
<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 配置:

ts
// nuxt.config.ts
app: {
  pageTransition: { name: 'page', mode: 'out-in' }
}
css
/* 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" 确保相对视口定位。


五、风铃组件优化

加载跳动

问题 1ballXballY 初始值为 ref(0),组件渲染时球先出现在 (0,0),onMountedinitPositions() 才把它挪到正确位置。

修复:容器 opacity: 0 + transition: opacity .3s ease-out .15sinitPositions() 算好位置后用 nextTickopacity: 1,平滑淡入。

问题 2:初始平衡位置公式错误。

js
// 错误: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 + 环境自适应路径)