本文介绍这个个人网站的整体技术架构、选型理由和设计思路。
一次改版说明:本站最初是「静态导出 + GitHub Pages」的纯静态站点。后来为了支持「往服务器丢一个 Markdown 文件就立刻生效」以及双站文章分发,整体改成了 Next.js standalone + ISR 的独立部署架构。本文描述的是改版之后的实际形态,不是最初的样子。
技术栈
| 技术 | 用途 |
|---|---|
| Next.js 16(App Router) | 框架;output: "standalone" 服务端产物,ISR 渲染 |
| React 19 | UI 组件库 |
| TypeScript 5 | 类型安全 |
| Tailwind CSS v4 | 原子化样式;正文排版靠 @tailwindcss/typography |
| gray-matter | Markdown frontmatter 解析 |
@ww028/blog-kit | 自研内容包:正文渲染、标题提取、目录、样式(本地 tgz 依赖) |
| rehype-highlight | 代码块语法高亮(highlight.js) |
| geist | 字体,sans 走包自带,mono 自托管并关掉 preload |
渲染模式:为什么最终不是静态导出
最初的做法是 output: "export",构建时把所有文章渲染成 HTML,产物丢给 GitHub Pages。
它在「写文章」这件事上暴露得很彻底:
- 每发一篇文章都要本地重新构建整个站点(
next build峰值内存 1GB 以上),再全量上传 - 产物里带着构建那一刻的内容快照——改一个错别字,也要走完一遍完整发布流程
- 运行时读不到文件系统,内容变更只能靠重新构建进入产物
现在改成 ISR(增量静态再生成)+ standalone 产物:
- 页面首次访问时生成并缓存,之后 1 小时内直接命中缓存
- 内容在运行时从文件系统读取,所以新增文章不必重新构建
- 需要立刻生效时,调一次
/api/revalidate主动失效缓存
// src/app/page.tsx
export const revalidate = 3600;
关键在于缓存失效有明确入口:POST /api/revalidate 会执行 revalidatePath("/", "layout"),一次性刷掉首页、文章列表和所有文章详情页。这比等 1 小时自然过期可靠得多——发布脚本的最后一步就是调它。
代价是:站上现在有服务端代码在跑,安全模型从「无服务端攻击面」变成了「需要自己守好那一个接口」。这一点在下面的安全章节展开。
内容层:两种文章,两套来源
content/
├── .article-sync-manifest.json ← 共享文章清单(路径 + sha256)
├── articles/
│ └── project-architecture.md ← 个人站专属(本文)
└── shared/articles/
├── building-modern-web-apps.md
└── ... ← 与另一个站点共享
content/articles/:只属于本站的文章,直接读目录content/shared/articles/:多站共享的文章,由清单文件授权——.article-sync-manifest.json里逐条记录路径和 sha256,读取时校验,对不上直接抛错
之所以要做清单 + 哈希:共享文章由另一个仓库生成、同步过来,不能假设同步过来的内容一定合法。这等于给「外部写入的内容」加了一道准入检查,而不是无条件信任目录里的任何 .md。同一个清单还兼作 slug 的权威来源,避免同步残留的旧副本被重复收录。
内容根目录由环境变量 CONTENT_DIR 指定(线上是 /opt/website/content),所以内容与代码是分离的:更新文章不需要重新部署应用。
frontmatter 结构:
---
title: 文章标题
summary: 文章摘要
date: 2026-10-08
pinned: true # 可选,置顶
tags: [Next.js, 架构设计] # 可选
publishTo: [personal] # 发布目标(供同步工具读取)
---
文件名即 slug,例如 project-architecture.md 对应 /articles/project-architecture。
排序逻辑
pinned: true的文章始终排在最前- 同级别内按日期倒序
双站文章的 canonical 分流
共享文章同时存在于本站和另一个站点(wwblog.cn)。为了避免两个域名上出现重复内容,本站对共享文章输出指向另一站的 canonical:
function getCanonicalUrl(article) {
const siteUrl = article.source === "shared" ? blogSiteUrl : personalSiteUrl;
return `${siteUrl}/articles/${article.slug}`;
}
sitemap.ts 也只收录 source === "local" 的文章。
这是有意的取舍:把共享文章的排名权重让给另一站,换两站互不消耗。代价是本站可参与排名的页面变少了——如果目标变成「让主站尽可能被搜到」,这个策略就值得重新评估,因为它和「被搜到」在方向上是相反的。
部署:低配服务器上的一次完整发布
托管在腾讯云轻量(2 核 2G,和另外几个服务共用),nginx 反代 + systemd 托管。
本地 服务器
────────────────────── ──────────────────────────────
npm run build nginx (443)
↓ output: standalone ↓ 反代 http://127.0.0.1:3000
补齐 public/ 与 .next/static/ systemd: website.service
↓ 打包 tar.gz └─ node server.js (PORT=3000)
scp 上传 ─────────────────────────▶ 解压到 /opt/website/app
rsync content/ ───────────────────▶ /opt/website/content
↓ 重启服务
POST /api/revalidate ─────────────▶ 刷新 ISR 缓存
推送给百度(仅新增/变更 URL)
为什么构建一定要放在本地做
next build 在这台机器上会 OOM——2G 内存要同时养着 nginx、数据库和另外两个后端服务,构建峰值 1GB 以上根本放不下。所以流程是本地构建、只上传产物,服务器只负责 node server.js。
output: "standalone" 的价值就在这里:产物自带裁剪过的依赖和运行所需文件(约 272MB),服务器上不需要 npm install,也不需要装构建工具链。
一个容易踩的坑:standalone 产物默认不含 public/ 和 .next/static/,必须在打包前手动拷进去,否则线上会丢静态资源和图片(表现是页面完全没样式)。
发布脚本做了六件事
deploy/publish.sh 一次跑完:
- 本地构建并打包 standalone 产物
scp上传,解压到/opt/website/app(解压前先清掉旧public/——tar 是叠加而不是镜像,源码里删掉的文件会一直残留在服务器上)rsync同步content/→/opt/website/contentsystemctl restart websitePOST /api/revalidate刷新缓存- 把新增/变更的 URL 推送给百度(见 SEO 章节)
只改文章时用 --content-only,跳过构建和重启,只做第 3、5、6 步——秒级完成,这也正是当初放弃静态导出的直接原因。
同步文章默认不删服务器上的多余文件(要删得显式加 --prune)。因为对外承诺的用法就是「丢一个 .md 上去就生效」,如果默认做镜像删除,那些只存在于服务器上的文章会在下次发布时被静默清掉。
运行时的内存约束
服务单元里对内存做了两层限制:
Environment="NODE_OPTIONS=--max-old-space-size=256" # V8 堆上限
MemoryMax=512M # cgroup 硬上限
这是被共享服务器逼出来的:不加限制的话,一次异常的 SSR 内存峰值就能把整台机器拖垮,连带影响同机上的其他服务。堆上限(256M)刻意低于 cgroup 上限(512M)——Node 的 RSS 总是高于 V8 堆,两个数字设成一样,反而会先被 cgroup 杀掉。
安全防护
改成服务端渲染之后,攻击面确实比静态站大了。目前守住的是这几处。
路径穿越
文章 slug 直接来自 URL,所以对它做了两道校验:
const VALID_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
function resolveInside(root: string, relativePath: string) {
const resolved = path.resolve(root, relativePath);
const relative = path.relative(root, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`文章路径越界:${relativePath}`);
}
return resolved;
}
先过白名单正则(只允许小写字母、数字、连字符),再要求解析后的绝对路径必须落在 content/ 内。单靠正则容易漏,单靠路径检查依赖实现正确——两道一起才有意义。
同步内容的完整性校验
.article-sync-manifest.json 的每一条都带 sha256,读取时逐个核对,对不上就抛错。等于给「从另一个仓库同步进来的文件」加了一次准入检查,而不是无条件信任。
缓存失效接口的访问控制
/api/revalidate 能强制全站重新渲染,绝不能对外公开。做了两层:
location = /api/revalidate {
allow 127.0.0.1;
deny all;
}
if (request.headers.get("x-revalidate-token") !== process.env.REVALIDATE_TOKEN) {
return Response.json({ revalidated: false, error: "forbidden" }, { status: 403 });
}
nginx 层只放本机访问,应用层再校验 token(token 存在权限 600 的环境变量文件里)。两层都留着,是为了万一 nginx 配置被改错,接口也不会直接暴露出去。
XSS
react-markdown 默认不渲染 Markdown 里的原始 HTML,所以文章内容中即使出现 <script> 也不会执行。这也是共享文章能够安全渲染的前提——那部分内容的来源并不完全可控。
nginx 侧
- 屏蔽
.git、.ssh、node_modules等敏感路径 - 除
/api/revalidate外全部反代到127.0.0.1:3000 - 由 nginx 统一加 HSTS 与安全响应头
SEO 方案
- Metadata:首页/列表页用静态
metadata,文章页用generateMetadata动态生成 - Open Graph / Twitter Card:
summary_large_image - 动态 OG 图:
opengraph-image.tsx用next/og在服务端生成 1200×630 的分享图,带标题、摘要、日期和标签——每篇文章都有自己的分享图,不需要手工做图 - JSON-LD:首页输出
Person,文章页输出BlogPosting - Sitemap:
sitemap.ts动态生成,与页面同为 ISR(1 小时);发文章时由/api/revalidate一并刷新 - Robots.txt:静态文件,单独给 Baiduspider 写了规则
- Canonical:
metadataBase兜底 + 按文章来源分流(见上) - 备案:页脚展示 ICP 与公安备案号及图标
sitemap 必须是动态的
这里踩过一次:最初写的是 export const dynamic = "force-static",sitemap 会被固化在构建产物里——新增文章之后它永远不变,搜索引擎也就发现不了新页面。改成普通的 ISR 缓存才修好。
同样地,lastModified 取的是「最新一篇文章的日期」而不是 new Date()。用 new Date() 等于每次生成都告诉搜索引擎「我刚刚改过」,会误导抓取频率判断。
备案号为什么必须硬编码
Next 会在构建时把非 NEXT_PUBLIC_ 的 process.env 内联进产物,而 export const metadata 是静态对象、只在构建时求值一次。结果是:把备案号/验证码只放在服务器环境变量里,本地构建时读不到 → 内联成空 → 预渲染 HTML 里永远不会出现;而且它不是 generateMetadata 函数,运行时 revalidate 也不会重新求值。
麻烦在于,微信和监管爬虫不执行 JavaScript,看到的就是那份额外的静态 HTML——页面上没有备案号,就会被提示「未备案」。
所以这类「必须出现在 HTML 里」的站点常量(备案号、搜索引擎验证码)现在一律在代码里硬编码兜底,环境变量只作为临时覆盖手段。
百度主动推送
发布脚本最后会把新增/变更的 URL 通过百度站长平台的「普通收录 API」推过去,比等爬虫自己发现快一个数量级。两个细节:
- 只推变化过的 URL。每条 URL 的指纹(md5)存在本地,没变就不推。百度给新站的配额是每天 10 条,无脑重复推送会白耗光。
- 只有响应里
success数等于投递数才记录指纹。配额用尽或部分失败时保持原状,下次发布自动重试;推送失败也绝不影响发布本身的结果。
功能特性
标签与搜索
- 列表页顶部提供搜索框,匹配标题、摘要与标签,实时过滤
- 标签筛选按钮与搜索可组合使用,纯客户端实现,不请求后端
- 标签按使用次数倒序显示
主题切换
- 导航栏提供明亮/暗色切换,首次访问跟随系统偏好
- 选择保存到
localStorage,刷新不丢失 - 在
<head>里用一段内联脚本在首帧渲染前打上.dark类,避免主题闪烁(FOUC) - Tailwind CSS v4 通过
@custom-variant dark (&:where(.dark, .dark *))实现类名控制的暗色模式
文章目录(TOC)
- xl 及以上屏宽在右侧固定显示
- 从 Markdown 内容中提取 h2、h3 标题生成
- 用
IntersectionObserver实时追踪滚动位置并高亮当前章节 - 点击平滑滚动到对应位置,h3 缩进体现层级
文章侧边栏
- xl 及以上屏宽在左侧列出全部文章,当前文章高亮,置顶文章带标记
代码块语法高亮
rehype-highlight(基于 highlight.js)实现,Markdown 里标注语言标识即可生效- 语法配色由内容包统一定义(深色底
#1a1a1e),不需要额外引入主题 CSS
自定义 404 与错误页
not-found.tsx:渐变色大字 + 返回首页按钮 + 入场动画,与整体设计语言一致error.tsx:路由级错误兜底
性能上的几处取舍
- 字体:
GeistSans走 geist 包自带的next/font;但 Geist Mono 特意改成next/font/local并关掉preload——它只用在文章正文的代码块里,首页和列表页完全用不到,默认预加载等于每个访客首屏白下 68KB - 不用
next/font/google:构建时要访问 Google Fonts,内网或 CI 环境下会直接构建失败 - 中文回退链:Geist 只覆盖拉丁字符,中文显式指定
PingFang SC→Microsoft YaHei→Noto Sans SC逐级回退 - 首页 HTML 原始 33KB,gzip 后约 6.7KB,TTFB 在 50ms 量级
UI 设计风格
整体采用活泼、充满活力的配色方案:
- 渐变主色调:紫色 → 玫红 → 橙色(
#6c63ff→#e91e8c→#ff6b35) - 暗色模式:深蓝紫背景(
#0f0f1a),淡紫/粉/橙渐变 - 磨砂玻璃效果:Header、TOC、侧边栏使用
backdrop-blur+ 半透明背景 - 去边框化:卡片用阴影替代边框,hover 时浮起 + 加深阴影
- 大字体层次:首页标题
text-7xl,渐变色文字 - 微交互动画:
fade-in-up入场动画、hover 缩放、按钮放大 - 圆角设计:
rounded-2xl卡片、rounded-full按钮和标签
样式方案
Tailwind CSS v4 + @tailwindcss/typography:
- 通过 CSS 变量(
--accent、--surface、--gradient-start等)统一管理配色,暗色模式只需覆盖这组变量 - 暗色模式通过
@custom-variant dark配合.dark类实现手动切换 - 正文排版由内容包提供的 CSS 统一处理(标题、段落、列表、代码块、表格、引用)
- 响应式:xl 以上显示侧边栏与目录,以下自适应
为什么不用 CMS
对个人技术博客来说,Markdown 文件方案的优势:
- 无外部依赖:不需要数据库或第三方服务
- 版本控制友好:文章可以用 Git 管理,改动有历史
- 编辑灵活:任何文本编辑器都能写作
- 迁移简单:纯文本文件,随时可以搬走
- 部署简单:不需要配置数据库连接
可能的优化方向
- 文章详情页的标签链接目前只跳转到列表页,没有接上按标签筛选(列表页的筛选按钮是好的)
- 添加 RSS 订阅
- 评论系统集成(Giscus / Utterances)
- 全文搜索索引(FlexSearch / Fuse.js)
- 图片优化与文章分页
- 重新评估共享文章的 canonical 分流策略