技术 · 已精选

用 Nuxt 4 + Nuxt Content 搭一个纯静态个人站

站点骨架的取舍:内容目录怎么定、SSG 怎么预渲染、设计令牌怎么从原型抽成一套 CSS 变量,以及首屏主题闪烁的解法。

目录
  1. 为什么不做传统 CMS
  2. 目录结构
  3. 内容管线:Markdown 即数据库
  4. 集合定义
  5. frontmatter 字段约定
  6. SSG:把整站预渲染成静态文件
  7. 设计令牌:一套变量管两套主题
  8. 主题切换不闪白
  9. 踩坑清单
  10. 小结

这篇文章记录的是「雨墨」这个站本身的搭建过程:从一张手写原型图,到一份能直接丢给 Nginx 的静态产物。

为什么不做传统 CMS

个人技术站的内容更新频率很低,但每次更新都希望是「写一个 Markdown 文件、提交、部署」这么简单。数据库 + 后台管理界面这套组合,对一个只有作者一人的站点来说是纯粹的运维负担。

于是目标定得很清楚:

  • 内容以文件形式存在 Git 里,可 diff、可回溯;
  • 构建产物是纯静态文件,不需要 Node 常驻进程;
  • 站内没有任何用户输入入口(无评论、无注册、无搜索后端)。

目录结构

最终落地的结构遵循 Nuxt 4 的目录约定,app/ 目录承载全部前端代码:

site/
├─ app/
│  ├─ assets/css/main.css        # 设计令牌 + 全站样式
│  ├─ assets/images/hero-ink.svg # 首屏水墨底图
│  ├─ components/                # 页头/页脚/卡片/目录/回到顶部
│  ├─ composables/               # 主题、文章索引、目录高亮
│  ├─ data/site.ts               # 站点名、导航、备案号等常量
│  ├─ layouts/default.vue        # 页头 + 内容 + 页脚 + 回到顶部
│  └─ pages/                     # 首页 / 栏目 / 文章 / 标签 / 关于
├─ content/                      # Markdown 内容
├─ public/                       # 静态资源(favicon 等)
└─ nuxt.config.ts

内容管线:Markdown 即数据库

Nuxt Content 负责把 content/ 下的 Markdown 变成可查询的集合。schema 用 Zod 声明,写错 frontmatter 会在构建期直接报错,而不是等到渲染时才发现字段缺失。

集合定义

export default defineContentConfig({
  collections: {
    content: defineCollection({
      type: 'page',
      source: '**/*.md',
      schema: z.object({
        title: z.string(),
        date: z.string().optional(),
        category: z.enum(['tech', 'life', 'project']).optional(),
        tags: z.array(z.string()).optional(),
        pinned: z.boolean().optional(),
      }),
    }),
  },
})

frontmatter 字段约定

字段用途是否必填
title列表卡标题与文章页标题是
description摘要,用于卡片与 SEO否
date排序依据否
category决定文章归属栏目否
tags标签云与相关阅读的关联依据否
pinned标记精选,卡片上加印刷编号样式否

SSG:把整站预渲染成静态文件

nitro.prerender 里显式列出栏目路由,其余文章页交给链接爬取:

nitro: {
  preset: 'static',
  prerender: {
    crawlLinks: true,
    routes: ['/', '/tech', '/life', '/projects', '/about', '/tags', '/rss.xml', '/sitemap.xml', '/robots.txt'],
  },
}

构建命令是一条:

pnpm generate

产物落在 .output/public/,直接同步到服务器目录即可,不需要在服务器上装任何运行时。

设计令牌:一套变量管两套主题

样式层不引入 UI 框架,令牌集中写在 :root 与 html[data-theme="dark"] 两个作用域里,组件样式只引用变量:

:root {
  --bg: #F7F4EE;
  --surface: #FFFDF8;
  --text: #1A1A1A;
  --text-muted: #6B6558;
  --accent: #C8492F;
  --line: #E2DCCF;
  --radius: 2px;
}

html[data-theme="dark"] {
  --bg: #131313;
  --surface: #1C1C1C;
  --text: #E8E4DC;
  --text-muted: #9A948A;
  --accent: #E4694E;
  --line: #2E2E2E;
}

字体走系统栈,不加载任何网络字体 —— 首屏少一次往返,也避免了字体闪烁。

主题切换不闪白

深色偏好在 localStorage 里,若只靠客户端 hydration 后切换,首屏一定闪一下浅色。解法是把一段极短的判断脚本内联进 <head>,在 CSS 生效前就把 data-theme 写到 <html> 上:

(function () {
  try {
    var saved = localStorage.getItem('rainink-theme')
    var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
    document.documentElement.setAttribute('data-theme', saved || (dark ? 'dark' : 'light'))
  } catch (e) {
    document.documentElement.setAttribute('data-theme', 'light')
  }
})()

try/catch 是为了无痕模式下 localStorage 抛异常时仍能兜底成浅色。

踩坑清单

  • string[] 与 string 一字之差:项目陈列的 stack 一开始声明成字符串,写数组时构建直接失败——这是好事,schema 就是用来挡这类错误的。
  • ~/ 别名指向 app/:Nuxt 4 里 ~/assets/... 实际解析到 app/assets/...,public/ 例外。
  • 首屏底图要内联:SVG 用 ?raw 导入后内联渲染,才能跟着 data-theme 换墨色;换成 <img> 就失去主题联动能力。

小结

对一个内容更新不快、样式要求却很具体的个人站来说,「Markdown 文件 + 静态产物」是维护成本最低的组合。真正花时间的不是框架接入,而是设计令牌和交互细节的打磨 —— 那部分下一篇再说。