一切的起因,真的就只是因为那个平平无奇的周日下午,我盯着自己的博客发呆。
那个博客跑在 Hexo 上,用的是我魔改过无数次的 Nexus 主题。在过去的几年里,它就像一个忠实的老伙计,稳定、快速,生成的静态 HTML 扔到 GitHub Pages 上就能跑,零成本,低维护。
但是,作为一个前端开发者,"稳定"有时候就是"无聊"的代名词。
当你习惯了 React 19 的 Server Components,习惯了 Next.js App Router 的优雅架构,再回过头去看 Hexo 的 EJS 模板,那种感觉就像是开惯了特斯拉,突然让你回去开手扶拖拉机。我想给文章加个动态的目录高亮?得写原生 JS。我想做一个平滑的页面过渡?Hexo 是多页应用(MPA),每次点击都是一次浏览器的硬刷新,那种白屏一闪而过的感觉,简直是在各种现代化 SPA (单页应用) 横行的今天对用户体验的犯罪。
"受够了,真的。"
我看着屏幕上那个虽然加载很快但交互僵硬的页面,心里那个名为"折腾"的小火苗开始疯狂乱窜。我想要 View Transitions 的丝滑,我想要 Tailwind CSS 那种原子化的掌控力,我想要 TypeScript 的类型安全,我想要 SSR (服务端渲染) 和 SSG (静态生成) 的完美结合。
于是,在理智还没来得及阻止我之前,手指已经敲下了那个危险的命令:
rm -rf Nexus/看着终端里的一行行删除提示,我深吸了一口气。好了,没有退路了。
目标:倒计时48小时,从零构建 Chiway Blog 2.0。 技术栈:Next.js 16 + React 19 + next-mdx-remote + Tailwind CSS v4。
这是一场在激进技术边缘行走的旅程,也是我和 Bug 们肉搏的两天两夜。
很多人可能会问,Hexo 不好吗?Hugo 不香吗?甚至 Astro 这种性能怪兽不才是现在的版本答案吗?
是的,它们都很好,但它们大多是"生成器"。而 Next.js,是一个"框架"。
选择 Next.js 16 是一个拥抱前沿的决定。它基于 React 19,带来了 Server Components 和 App Router 的全新体验。但它的诱惑力在于"极简"与"全栈"。
当我运行 npx create-next-app@latest 初始化项目时,看着那个清晰的目录结构,我意识到 Web 开发终于回归了本质。没有繁琐的 Webpack 配置,路由就是文件系统。
app 目录下新建一个 [slug]/page.tsx,它就自动变成了能够处理所有文章路径的动态路由。'use client' 就行。如果说 Next.js 是骨架,那么 next-mdx-remote + gray-matter 就是这次重构的灵魂。
以前在 Hexo 里,Frontmatter(文章头部的元数据)怎么写全凭自觉。有时候拼写错误写成了 dta: 2025,编译的时候不会报错,直到上线了才发现文章没按时间排序。
但在 Next.js 生态里,gray-matter 让一切都变得优雅而可控。这简直是强迫症患者的福音。
看看我在 lib/posts.ts 里的配置,多么优雅:
import matter from 'gray-matter'
import fs from 'fs'
import path from 'path'
const postsDirectory = path.join(process.cwd(), 'content/posts')
export function getPostBySlug(slug: string, fields: string[] = []) {
const fullPath = path.join(postsDirectory, `${slug}.md`)
const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data, content } = matter(fileContents)
const items: Record<string, any> = {}
// 确保只返回需要的字段
fields.forEach((field) => {
if (field === 'slug') {
items[field] = slug
}
if (field === 'content') {
items[field] = content
}
if (data[field]) {
items[field] = data[field]
}
})
return items
}这不仅是配置,这是契约。现在,TypeScript 的类型系统会帮我检查每一篇文章的元数据。如果日期格式不对,或者漏了标题,TypeScript 编译器会直接警告我。
而且,gray-matter 的解析极其高效。以前实现"上一篇/下一篇"功能,我需要在生成时遍历整个文章数组,效率低且逻辑丑陋。
现在?直接用 JavaScript 数组操作:
export function getAllPosts(fields: string[] = []) {
const slugs = getPostSlugs()
const posts = slugs
.map((slug) => getPostBySlug(slug, fields))
.filter((post): post is NonNullable<typeof post> => post !== null)
.sort((post1, post2) =>
new Date(post1.date) > new Date(post2.date) ? -1 : 1,
)
return posts
}这种数据化的思维转变,让我能像操作后端 API 一样操作我的 Markdown 文件。配合 next-mdx-remote 的 MDX 渲染能力,我可以在 Markdown 里直接使用 React 组件!虽然中间经历了一次 MDX 插件配置的折腾,但在看到数据流畅地注入组件那一刻,那种掌控感让人着迷。
如果说第一天是架构的搭建,充满了宏观的快乐;那么第二天就是与像素的肉搏,充满了微观的痛苦。
前端开发哪怕到了 2025 年,依然逃不过 CSS 的诅咒。而这场肉搏的中心战场,在 代码块 和 深色模式。
作为一个技术博客,Code Block 就是门面。如果代码块长得丑,或者高亮不准,那跟咸鱼有什么区别?
起初,我使用了 highlight.js 自带的样式。它很好,集成了代码高亮,开箱即用。但是,它不够"我"。
默认的样式过于通用,缺乏个性。我想要那种 Mac 窗口风格的精致感——左上角要有红黄绿三个拟物化的小点点,中间要隐约可见文件名,右上角要有一个低调又灵动的复制按钮,点击时会有微小的反馈动画。
为了这个执念,我决定手写一个 Pre.tsx 组件来替换默认实现。
第一次渲染,灾难发生了。
我发现我的 Mac 窗口代码块里,每一行代码都被加上了一个灰色的背景色,而且字体间距变得极其诡异。原本深色的沉浸式代码背景上,文字被强制加上了浅色底色,看起来就像是满屏的补丁。
排查了半小时,我发现罪魁祸首竟然是我自己写的一段全局 CSS:
/* 我本来是为了给行内代码加样式的 */
.prose code {
background-color: #f3f4f6;
padding: 4px;
}这段 CSS 的选择器优先级太低,而且太宽泛了。它不仅匹配了行内的 const a = 1,也匹配了 <pre><code>...</code></pre> 里的代码!
因为在 HTML 规范里,代码块确实是包裹在 pre 里的 code 标签。于是,我的全局样式无差别地攻击了所有代码。
最后,我不得不深入 main.css,复习了一遍 CSS 的伪类选择器,用 :not() 实现了手术刀级别的样式隔离:
/* 只有非 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 风格的红灰配色作为行内代码样式。现在,行内代码清晰醒目,而代码块则保持了深邃的沉浸感,互不打扰。
这个 Bug 真的绝了,它让我差点怀疑人生。
我想给博客加个深色模式。在 Tailwind CSS 的世界里,这不就是加个 darkMode: 'class' 然后一把梭 dark:bg-black dark:text-white 的事吗?
我信心满满地配好了 tailwind.config.ts,也在 app/layout.tsx 里用 next-themes 包裹了根布局。
结果一切换,好家伙,背景黑了,文章正文变白了,但是——我的文章标题(H1)消失了。
仔细检查后发现,它还在那里,只是它是黑色的。
黑色的背景,黑色的文字。 这就是传说中的“五彩斑斓的黑”?
我开始疯狂 Debug:
dark: 类名没生效?检查 DOM,html 标签上明明有 class="dark"。dark:text-white 加了 !important,没用。最诡异的是,当你刷新页面时,它有时候会闪一下白色,然后又变回黑色。这说明可能存在水合(Hydration)不匹配的问题,或者是某些 CSS 变量的加载顺序问题。
在经历了两个小时的试错后,我悟了:永远不要完全依赖框架的魔法,尤其是当多个框架(Next.js, Tailwind, next-themes)混用的时候。CSS 变量才是构建稳健主题系统的基石。
我重构了整个配色系统,不再在 JSX 模板里写死 text-gray-900 dark:text-white 这种原子类,而是抽象出了语义化的 CSS 变量:
/* app/globals.css */
:root {
--article-title-color: #000000;
}
/* 同时支持标准的 .dark 和 next-themes 的类名,双重保险 */
.dark {
--article-title-color: #ffffff;
}
/* 在组件里只用这个语义类名 */
.article-title {
color: var(--article-title-color) !important;
transition: color 0.3s ease; /* 顺便送一个过渡动画 */
}这一改动,不仅彻底解决了标题消失的问题,还顺带修复了底部导航链接在切换主题时的颜色闪烁。现在的颜色切换,是逻辑确定的、稳健的。无论框架层怎么变,只要 CSS 变量在,颜色就是对的。
在解决了所有功能性 Bug 后,我看着能够在深浅模式间正确切换的博客,觉得还缺了点什么。
它是对的,但不够“惊艳”。现在的 Web 已经不是那个只能点点点的时代了,我想要那种原生 App 般的“流体感”。
我想起了 Chrome 新推出的 View Transitions API。
通常的主题切换就是全屏一闪,从白变黑。但为什么不能让主题切换变成一次“扩散”?就像你在水面上点了一下,波纹荡漾开来,新的世界吞没了旧的世界。
我决定挑战一下。
在 AppHeader.tsx 里,我重写了 toggleTheme 函数。不再是简单的 setTheme(!isDark),而是引入了数学计算。
首先,我要捕获鼠标点击的坐标 (x, y):
const x = (event as React.MouseEvent).clientX
const y = (event as React.MouseEvent).clientY然后,计算出这个点到屏幕最远角落的距离,作为圆的半径。这需要勾股定理:
const endRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
)最后,调用 document.startViewTransition,并用 Web Animation API 构造一个圆形的 clip-path 动画:
const transition = document.startViewTransition(async () => {
setTheme(!isDark)
await new Promise((resolve) => setTimeout(resolve, 0)) // 等待 React 状态更新
})
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
? '::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 一键建站,但这就像是买了一盆塑料花。而用 Next.js 从零开始构建,就像是亲手撒下种子,施肥,修剪枝叶。
在这个过程中,我重新审视了 Next.js 16 的工程化能力,体会了 Tailwind 原子化样式的双刃剑特性,也深刻理解了"鲁棒性"在前端开发中的重要性——哪怕是一个小小的颜色切换,背后都藏着无数的工程细节。
48 小时结束了,博客上线了。但这并不是终点。
Next step? 也许是引入 AI 辅助阅读总结,也许是用 Three.js 做一个 3D 的 Hero 页面,又或者是研究一下怎么把这个静态网站部署到边缘节点上。
谁知道呢?在这个属于 Next.js 和 React 的新世界里,一切皆有可能。
现在,我要去补觉了。晚安,世界。