Back to Articles

48小时极限重构:我把博客从 Hexo 换到了 Nuxt 4

#Nuxt#Vue#TailwindCSS#深度好文#折腾

当厌倦成为第一生产力

一切的起因,真的就只是因为那个平平无奇的周日下午,我盯着自己的博客发呆。

那个博客跑在 Hexo 上,用的是我魔改过无数次的 Nexus 主题。在过去的几年里,它就像一个忠实的老伙计,稳定、快速,生成的静态 HTML 扔到 GitHub Pages 上就能跑,零成本,低维护。

但是,作为一个前端开发者,"稳定"有时候就是"无聊"的代名词。

当你习惯了 Vue 3 的 Composition API,习惯了 React Hooks 的逻辑复用,再回过头去看 Hexo 的 EJS 模板,那种感觉就像是开惯了特斯拉,突然让你回去开手扶拖拉机。我想给文章加个动态的目录高亮?得写原生 JS。我想做一个平滑的页面过渡?Hexo 是多页应用(MPA),每次点击都是一次浏览器的硬刷新,那种白屏一闪而过的感觉,简直是在各种现代化 SPA (单页应用) 横行的今天对用户体验的犯罪。

"受够了,真的。"

我看着屏幕上那个虽然加载很快但交互僵硬的页面,心里那个名为"折腾"的小火苗开始疯狂乱窜。我想要 View Transitions 的丝滑,我想要 Tailwind CSS 那种原子化的掌控力,我想要 TypeScript 的类型安全,我想要 SSR (服务端渲染) 和 SSG (静态生成) 的完美结合。

于是,在理智还没来得及阻止我之前,手指已经敲下了那个危险的命令:

hljs bash
rm -rf Nexus/

看着终端里的一行行删除提示,我深吸了一口气。好了,没有退路了。

目标:倒计时48小时,从零构建 Chiway Blog 2.0。 技术栈:Nuxt 4 (Preview) + Nuxt Content v3 + Tailwind CSS v4

这是一场在激进技术边缘行走的旅程,也是我和 Bug 们肉搏的两天两夜。


架构的禅意与 Nuxt 4 的野心

为什么是 Nuxt 4?

很多人可能会问,Hexo 不好吗?Hugo 不香吗?甚至 Astro 这种性能怪兽不才是现在的版本答案吗?

是的,它们都很好,但它们大多是"生成器"。而 Nuxt,是一个"框架"。

选择 Nuxt 4 是一个冒险的决定。它还处于预览阶段,文档有一半是 Pending 状态,生态里的模块有的还停留在 Nuxt 2 时代。但它的诱惑力在于"极简"与"全栈"。

当我运行 npx nuxi init 初始化项目时,看着那个几乎为空的 nuxt.config.ts,我意识到 Web 开发终于回归了本质。没有繁琐的 Webpack 配置,没有复杂的路由表。

  • 文件即路由:我在 pages 目录下新建一个 [...slug].vue,它就自动变成了能够处理所有文章路径的通配符路由。
  • 自动导入ref, computed, useRoute,这些在 Vue 项目里写到吐的 import 语句,在 Nuxt 里统统不需要。它们就在那里,随叫随到。

内容引擎的代际升级:Nuxt Content v3

如果说 Nuxt 是骨架,那么 Nuxt Content v3 就是这次重构的灵魂。

以前在 Hexo 里,Frontmatter(文章头部的元数据)怎么写全凭自觉。有时候拼写错误写成了 dta: 2025,编译的时候不会报错,直到上线了才发现文章没按时间排序。

但在 Nuxt Content v3 里,一切都变了。它引入了基于 Zod 的 Schema 验证。这简直是强迫症患者的福音。

看看我在 content.config.ts 里的配置,多么优雅:

hljs typescript
import { defineContentConfig, defineCollection, z } from '@nuxt/content'

export default defineContentConfig({
  collections: {
    content: defineCollection({
      type: 'page',
      source: '**/*.md',
      schema: z.object({
        title: z.string(),
        date: z.date(),
        description: z.string(),
        tags: z.array(z.string()).optional() // 甚至可以定义可选类型
      })
    })
  }
})

这不仅是配置,这是契约。现在,如果我哪篇文章的日期格式不对,或者漏了标题,开发服务器会直接红屏警告我。

而且,Content v3 本质上是一个运行在浏览器和服务器端的微型数据库(基于 SQLite)。以前实现"上一篇/下一篇"功能,我需要在生成时遍历整个文章数组,效率低且逻辑丑陋。

现在?直接写 SQL(或者是类 SQL):

hljs typescript
const [prev, next] = await Promise.all([
  queryCollection('content')
    .where('date', '<', currentDoc.date)
    .order('date', 'DESC')
    .first(),
  queryCollection('content')
    .where('date', '>', currentDoc.date)
    .order('date', 'ASC')
    .first()
])

这种数据化的思维转变,让我能像操作后端 API 一样操作我的 Markdown 文件。虽然中间经历了一次 better-sqlite3 绑定的编译报错风波(不得不手动 rebuild 依赖),但在看到数据流畅地注入组件那一刻,那种掌控感让人着迷。


与像素的拉锯战——那些让我掉头发的 Bug

如果说第一天是架构的搭建,充满了宏观的快乐;那么第二天就是与像素的肉搏,充满了微观的痛苦。

前端开发哪怕到了 2025 年,依然逃不过 CSS 的诅咒。而这场肉搏的中心战场,在 代码块深色模式

1. 代码块的“面子工程”与样式污染

作为一个技术博客,Code Block 就是门面。如果代码块长得丑,或者高亮不准,那跟咸鱼有什么区别?

起初,我使用了 Nuxt UI 自带的 ProsePre 组件。它很好,集成了 Shiki 高亮,开箱即用。但是,它不够“我”。

默认的样式过于通用,缺乏个性。我想要那种 Mac 窗口风格的精致感——左上角要有红黄绿三个拟物化的小点点,中间要隐约可见文件名,右上角要有一个低调又灵动的复制按钮,点击时会有微小的反馈动画。

为了这个执念,我决定手写一个 ProsePre.vue 组件来替换默认实现。

第一次渲染,灾难发生了。

我发现我的 Mac 窗口代码块里,每一行代码都被加上了一个灰色的背景色,而且字体间距变得极其诡异。原本深色的沉浸式代码背景上,文字被强制加上了浅色底色,看起来就像是满屏的补丁。

排查了半小时,我发现罪魁祸首竟然是我自己写的一段全局 CSS:

hljs css
/* 我本来是为了给行内代码加样式的 */
.prose code {
  background-color: #f3f4f6;
  padding: 4px;
}

这段 CSS 的选择器优先级太低,而且太宽泛了。它不仅匹配了行内的 const a = 1,也匹配了 <pre><code>...</code></pre> 里的代码!

因为在 HTML 规范里,代码块确实是包裹在 pre 里的 code 标签。于是,我的全局样式无差别地攻击了所有代码。

最后,我不得不深入 main.css,复习了一遍 CSS 的伪类选择器,用 :not() 实现了手术刀级别的样式隔离:

hljs css
/* 只有非 pre 标签内的 code 才会应用此样式! */
.prose :not(pre) > code {
  font-family: var(--font-mono);
  background-color: rgba(135, 131, 120, 0.15); /* Notion 风格的淡灰 */
  color: #EB5757; /* 醒目的绯红 */
  border-radius: 4px;
}

为了达到极致的视觉效果,我反复调整了 padding (0.2em 0.4em) 和 font-size (0.875em),最终选择了 Notion 风格的红灰配色作为行内代码样式。现在,行内代码清晰醒目,而代码块则保持了深邃的沉浸感,互不打扰。

2. 深色模式下的“幽灵标题”事件

这个 Bug 真的绝了,它让我差点怀疑人生。

我想给博客加个深色模式。在 Tailwind CSS 的世界里,这不就是加个 darkMode: 'class' 然后一把梭 dark:bg-black dark:text-white 的事吗?

我信心满满地配好了 tailwind.config.ts,也在 nuxt.config.ts 里配好了 @nuxtjs/color-mode

结果一切换,好家伙,背景黑了,文章正文变白了,但是——我的文章标题(H1)消失了。

仔细检查后发现,它还在那里,只是它是黑色的。

黑色的背景,黑色的文字。 这就是传说中的“五彩斑斓的黑”?

我开始疯狂 Debug:

  • 是不是 dark: 类名没生效?检查 DOM,html 标签上明明有 class="dark"
  • 是不是权重不够?我给 dark:text-white 加了 !important,没用。
  • 是不是缓存?重启服务器,没用。

最诡异的是,当你刷新页面时,它有时候会闪一下白色,然后又变回黑色。这说明可能存在水合(Hydration)不匹配的问题,或者是某些 CSS 变量的加载顺序问题。

在经历了两个小时的试错后,我悟了:永远不要完全依赖框架的魔法,尤其是当多个框架(Nuxt UI, Tailwind, Color Mode)混用的时候。CSS 变量才是构建稳健主题系统的基石。

我重构了整个配色系统,不再在 Vue 模板里写死 text-gray-900 dark:text-white 这种原子类,而是抽象出了语义化的 CSS 变量:

hljs css
/* main.css */
:root {
  --article-title-color: #000000;
}

/* 同时支持标准的 .dark 和 Nuxt 的 .dark-mode 类名,双重保险 */
.dark, .dark-mode {
  --article-title-color: #ffffff;
}

/* 在组件里只用这个语义类名 */
.article-title {
  color: var(--article-title-color) !important;
  transition: color 0.3s ease; /* 顺便送一个过渡动画 */
}

这一改动,不仅彻底解决了标题消失的问题,还顺带修复了底部导航链接在切换主题时的颜色闪烁。现在的颜色切换,是逻辑确定的、稳健的。无论框架层怎么变,只要 CSS 变量在,颜色就是对的。


高光时刻——View Transitions 的魔法

在解决了所有功能性 Bug 后,我看着能够在深浅模式间正确切换的博客,觉得还缺了点什么。

它是对的,但不够“惊艳”。现在的 Web 已经不是那个只能点点点的时代了,我想要那种原生 App 般的“流体感”。

我想起了 Chrome 新推出的 View Transitions API

通常的主题切换就是全屏一闪,从白变黑。但为什么不能让主题切换变成一次“扩散”?就像你在水面上点了一下,波纹荡漾开来,新的世界吞没了旧的世界。

我决定挑战一下。

AppHeader.vue 里,我重写了 toggleTheme 函数。不再是简单的 isDark.value = !isDark.value,而是引入了数学计算。

首先,我要捕获鼠标点击的坐标 (x, y)

hljs typescript
const x = event.clientX
const y = event.clientY

然后,计算出这个点到屏幕最远角落的距离,作为圆的半径。这需要勾股定理:

hljs typescript
const endRadius = Math.hypot(
  Math.max(x, innerWidth - x),
  Math.max(y, innerHeight - y)
)

最后,调用 document.startViewTransition,并用 Web Animation API 构造一个圆形的 clip-path 动画:

hljs typescript
const transition = document.startViewTransition(async () => {
  isDark.value = !isDark.value
  await nextTick()
})

transition.ready.then(() => {
  document.documentElement.animate(
    {
      clipPath: [
        `circle(0px at ${x}px ${y}px)`,
        `circle(${endRadius}px at ${x}px ${y}px)`
      ]
    },
    {
      duration: 400,
      easing: 'ease-out',
      // 这里有个小技巧,通过伪元素控制新旧视图的层级
      pseudoElement: isDark.value ? '::view-transition-old(root)' : '::view-transition-new(root)'
    }
  )
})

配合 CSS 中对 ::view-transition-old(root)::view-transition-new(root)z-index 控制,奇迹发生了。

当你点击右上角的月亮图标时,黑暗并不是凭空降临,而是从你的指尖流淌出来。那一刻,这 48 小时的所有调试、查文档、回滚代码的焦虑,都化为了纯粹的愉悦。

这可能就是编程的禅意吧——为了那一瞬间的丝滑,我们可以跟几行代码死磕到底。


结语——数字花园的守望者

看着最终上线的 Chiway Wang 博客,看着那个我自己手绘的 SVG Logo(虽然画得不咋地,但好歹是独一无二的),看着滚动文章时顶部那条随着阅读进度增长的蓝色进度条,我意识到这就是折腾的意义。

在这个 Medium、掘金、知乎等平台高度发达的时代,为什么我们还要费劲巴力地自己建站?

因为这是我们在互联网上唯一真正拥有的自留地。这里没有算法推荐,没有广告弹窗,没有敏感词审核。只有我的代码,和我的文字。

它是程序员的“数字花园”。

我们本可以使用 WordPress 一键建站,但这就像是买了一盆塑料花。而用 Nuxt 从零开始构建,就像是亲手撒下种子,施肥,修剪枝叶。

在这个过程中,我重新审视了 Nuxt 4 的工程化能力,体会了 Tailwind 原子化样式的双刃剑特性,也深刻理解了“鲁棒性”在前端开发中的重要性——哪怕是一个小小的颜色切换,背后都藏着无数的工程细节。

48 小时结束了,博客上线了。但这并不是终点。

Next step? 也许是引入 AI 辅助阅读总结,也许是用 Three.js 做一个 3D 的 Hero 页面,又或者是研究一下怎么把这个静态网站部署到边缘节点上。

谁知道呢?在这个属于 Nuxt 和 Vue 的新世界里,一切皆有可能。

现在,我要去补觉了。晚安,世界。


CC BY-NC 4.02025 © Chiway Wang
RSS