博客开发日记
从零搭建 Astro 博客的完整记录:技术选型、后台管理系统、友链页面设计、站内自助申请表单、构建系统防卡死优化、域名绑定与 nginx 静态托管、CSRF 移除决策,以及 debug 才发现的坑。
很早之前就想做个博客,但是一直没费功夫 一直在考虑是wordpress,typecho还是别的。但是最后还是选了astro。本站大部分部件是照搬了明崽大佬的魔改firefly模板,同时增加了一些自己的灵感 再次感谢。()
选 Astro 的理由很简单:Islands Architecture(岛屿架构) 默认零 JS,只在需要交互的组件注水,对博客这种内容为主的站点特别友好。再加上原生支持 Svelte/Vue/React 组件,挺灵活的。
这篇笔记把从零搭站过程中踩过的坑、做过的设计决策都记下来,既是对自己折腾过程的复盘,也希望能给同样想入坑 Astro 的朋友一点参考。(同时本篇写作手法通过了豆包同志 因为懒得做md内容排班格式)
涉及的技术栈:Astro + Svelte 5 + TypeScript 的前台,Fastify + SQLite 的后台管理服务。
一、后台管理系统:双 SQLite 驱动降维打击 🛠️
博客前台是静态的,但后台管理总得有个服务端。我选了 Fastify 起一个轻量 API 服务,数据库用 SQLite 单文件,部署简单。
问题来了——better-sqlite3 这个原生模块,在不同环境下各有各的坑:
- Windows 本地开发:装完 Visual Studio Build Tools,依然可能缺 MSVC VC++ 工具集,
better-sqlite3编译直接失败 - Linux 生产服务器:GLIBC 版本太老(比如找不到
GLIBC_2.29),prebuild-install 直接跪了
双驱动降级策略
解决方案是在数据库访问层做了一层抽象,优先用原生驱动吃性能,挂了就回退到内置模块保可用:
let driver: 'better-sqlite3' | 'node:sqlite' = 'better-sqlite3';
try { const BetterSqlite = require('better-sqlite3'); const probe = new BetterSqlite(':memory:'); probe.close(); // 探测成功,使用 better-sqlite3(性能更好)} catch { driver = 'node:sqlite'; // 回退到 Node 22+ 内置的 node:sqlite}Node 22+ 已经内置了 node:sqlite(实验性),作为开发环境的兜底完全够用。这样一来,本地 Windows 开发不用装编译工具链,服务器 GLIBC 老也不怕,两个环境都能跑起来。
Fastify 插件超时问题
部署时还遇到一个隐蔽的坑:Fastify 默认插件超时是 10 秒,而 tsx 实时转译 TypeScript 在首次加载时比较慢,直接触发 AVV_ERR_PLUGIN_EXEC_TIMEOUT。
解决方案是在 Fastify 构造里关掉超时:
const app = Fastify({ pluginTimeout: 0, // tsx 实时转译较慢,关闭避免误杀 // ...});同时把所有同步风格的路由注册器用 asPlugin 包成 async 插件,避免 Fastify 把同步函数误判成回调风格导致 Promise 永不 resolve。这个坑真的很阴间,表现是服务挂起但不出错,debug 半天才定位到。
静默退出问题
最诡异的是服务偶尔会以 exit code 0 静默退出,没有任何错误日志。最后的保命组合拳是:
process.on('uncaughtException', (e) => console.error('[boot] uncaughtException:', e));process.on('unhandledRejection', (e) => console.error('[boot] unhandledRejection:', e));const keepAlive = setInterval(() => {}, 1000); // 防止事件循环空转退出setInterval(() => {}, 1000) 看起来很蠢,但它确实能让事件循环保持活跃,避免 Node 判断「没有待处理的任务」后主动退出。
二、一键启动脚本:Win & Linux 双平台 🚀
为了让部署更省心,写了两套一键启动脚本:
| 脚本 | 平台 | 用途 |
|---|---|---|
start.ps1 | Windows PowerShell | 本地开发 + 生产部署 |
start.sh | Linux Bash | 服务器部署 |
核心功能
deploy子命令:环境检查 → 依赖安装 → 构建 → 启动,一条龙prod模式:自动检测构建产物是否存在,缺失则先构建- 前端
astro preview默认只绑localhost,必须加--host 0.0.0.0才能外部访问,这是个老坑了
部署到服务器后,一条命令就能把前台、后台、管理界面全拉起来,不用再手敲三串命令。
三、友链页面设计:卡片网格 + 实时筛选 🎨
友链页面是最早动手做的功能页之一。设计目标很明确:简洁、好用、移动端友好。
布局选择
最终选了卡片网格布局,用 CSS Grid 的 auto-fill 自适应列数:
.grid-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); gap: 1rem;}每张卡片最小 16rem,空间够了就自动多排一列,省去媒体查询的麻烦。
交互细节
- 搜索框:实时过滤站点名、描述、标签,输入即筛选
- 标签筛选:点击标签快速过滤,激活态用反色按钮
- 悬停效果:卡片上浮 + 头像从灰度变彩色
.friend-card:hover { border-color: var(--grid-ink); transform: translateY(-4px); box-shadow: 0 8px 24px color-mix(in oklab, var(--grid-ink) 12%, transparent);}头像兜底
友链头像经常失效(毕竟别人的图床你管不了),所以做了 fallback:
<img src={friend.imgurl} onerror={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; (e.currentTarget.nextElementSibling as HTMLElement).style.display = 'flex';}} /><span class="avatar-fallback">{initialOf(friend.title)}</span>图片挂了就显示首字符占位,不会出现裂图。这种细节虽然不起眼,但用户体感差别很大。
响应式
移动端自动切换为 2 列,超小屏(<400px)切 1 列。筛选标签横向滚动,避免换行导致布局错乱。
四、站内自助友链申请表单 📝
友链申请这个功能花的时间最多。一开始想的是跳转 GitHub Issue 模板提交,但这对不会用 GitHub 的访客太不友好了。最终方案是前台直接提交表单 → 后台审核队列 → 一键通过自动写入配置。
后端架构
后台 API 基于 Fastify,分了两组路由:
- 公开路由组:只有
POST /friends/applications(提交申请),无需登录 - 受保护路由组:友链 CRUD、审核通过/拒绝等,需要 JWT 鉴权
这里有个设计要点:公开接口必须从受保护路由组里单独拎出来注册,否则会被鉴权中间件拦掉。同时公开接口要加入 CSRF 豁免白名单:
const CSRF_EXEMPT = new Set<string>([ `${API_PREFIX}/auth/login`, `${API_PREFIX}/auth/refresh`, `${API_PREFIX}/friends/applications`, // 公开提交接口]);全局限流 200 次/分钟依然生效,防止有人拿脚本刷申请。
前端表单组件
用 Svelte 5 的 runes 语法写了表单组件,字段包括:
- 站点名称(必填)
- 站点链接(必填,自动校验 URL)
- 头像链接(选填,留空自动抓取 favicon)
- 站点描述
- 分类标签
- 联系邮箱(选填,校验格式)
几个细节优化:
- URL 失焦自动抓取 favicon:用 Google 的 favicon 服务
https://www.google.com/s2/favicons?sz=64&domain=xxx - 三态反馈:
idle/success/error,提交结果即时可见 - 懒加载:用
client:visible而不是client:load,滚到才加载 JS
工作流程
访客填写表单 → POST 到后台公开接口 → 写入 SQLite friend_applications 表(status='pending') → 管理员登录后台「友链申请」页面 → 点击「通过」→ 自动写入 friendsConfig.ts审核通过后,友链会以默认权重 5、启用状态自动追加到 friendsConfig.ts 的数组里,下次构建即可见。这样访客提交完直接走人,站长后台一键通过,整个流程闭环。
五、细节调优:那些容易被忽略的小事 🔧
1. 关闭 Astro Dev Toolbar
开发时页面右下角那个黑色的 Astro logo 按钮虽然功能丰富,但有时候确实碍眼。一行配置搞定:
export default defineConfig({ devToolbar: { enabled: false, }, // ...});2. Footer 精简
底部只保留「隐私政策」和「用户协议」两个入口,RSS 按钮去掉了。友链申请入口直接做成页面下方的表单,比塞在 Footer 里更合理。
3. 友链申请指南弹窗
做了一个 <dialog> 元素的申请指南弹窗,展示本站信息(可一键复制)、申请流程和注意事项。之前还在底部放了「自助申请友链」和「前往评论区」两个跳转按钮,后来有了站内表单,这两个跳转就多余了,直接删掉,UI 更干净。
六、后台一键构建 + 文章日期序列化踩坑 🔄
这是开发过程中花时间最多的一个功能,也是踩坑踩得最狠的。背景很简单:Astro 是静态站点生成器(SSG),写完文章必须重新 pnpm build 才能在前台看到。本地开发还好,SSH 上服务器敲命令也能忍,但既然有后台管理系统了,为什么不直接在后台点一下就构建呢?
需求拆解
- 手动触发:Dashboard 上放个「重新构建」按钮,点一下就跑
pnpm build - 自动触发:写/改/删文章后自动构建,省得忘
- 实时日志:构建过程中把 stdout/stderr 实时展示出来,方便排查
构建管理器设计
核心是一个单例的 BuildManager,挂在 Fastify 进程里:
// 同一时刻只允许一个构建任务,避免 dist 写入冲突if (state.status === 'running') { pushLog('[skip] 已有构建任务在运行,本次触发已跳过'); return;}用 child_process.spawn 跑 pnpm run build,stdout/stderr 都 pipe 进来收集日志,保留最近 500 行供前端轮询。构建完成时把结果(触发方式、状态、耗时、退出码)持久化到 SQLite 的 build_history 表,方便后续做统计。
去抖机制很关键——后台编辑文章时经常会连续保存(写一段 Ctrl+S 一下),如果每次保存都触发构建,磁盘和 CPU 都扛不住。所以自动触发加了 3 秒去抖:
export function triggerBuildDebounced(delayMs = 3000): void { if (state.status === 'running') return; // 运行中不叠加 if (pendingTimer) clearTimeout(pendingTimer); pendingTimer = setTimeout(() => { pendingTimer = null; runBuild('auto'); }, delayMs);}Dashboard 集成
前端在 Dashboard 底部加了个「前端构建」卡片,包含:
- 状态标签(空闲 / 构建中 / 成功 / 失败)
- 「重新构建」按钮(构建中自动 loading + 禁用)
- 上次构建用时和完成时间
- 构建日志框(每 5 秒轮询一次,实时刷新)
这里踩了个小坑:一开始用 pnpm --dir <path> run build 传项目路径,结果 Windows 下路径含空格(C:\Users\William Chih\...)被截断成 C:\Users\William,构建直接报 ENOENT。解决方案是不用 --dir 参数,直接用 spawn 的 cwd 选项:
const child = spawn('pnpm', ['run', 'build'], { cwd: config.blogRoot, // 工作目录交给 cwd,不用命令行参数 shell: process.platform === 'win32', // Windows 需要 shell 解析 pnpm.cmd});Date 序列化:最阴间的坑
功能写完测试时发现一个诡异问题:后台保存文章后,自动构建成功了,但前台首页死活不显示新文章。去 dev server 里一看,整个 Content Collections 直接崩了,所有文章(旧的新的)全不见了。
排查发现是新文章的 frontmatter 里 published 字段被序列化成了带引号的字符串:
# 实际生成的(错误)published: '2026-08-12T17:30:02'
# Astro schema 期望的(正确)published: 2026-08-12T17:30:02Astro 7 的 Content Collections schema 把 published 定义为 z.date(),YAML 里加引号会被解析成 string 而非 date,schema 校验失败 → 整个 Collections 初始化崩掉 → 所有文章不显示。
根因是后台保存文章时,published 字段存的是字符串(new Date().toISOString()),gray-matter 库在 stringify 时把它当成普通字符串序列化,YAML 自动加了引号。
修复方案是把字符串转成 Date 对象再交给 gray-matter,YAML 序列化 Date 时会输出原生日期格式(不带引号):
// 修改前:存的是字符串,会被加引号published: data.published ?? new Date().toISOString()
// 修改后:存 Date 对象,YAML 原生日期格式published: new Date(data.published ?? Date.now())创建和编辑两个接口都要改,凡是涉及 published 和 updated 的赋值都包一层 new Date()。这个坑真的很隐蔽——本地 dev 模式下文章能正常显示(dev server 对类型校验相对宽松),但 astro build 时会严格执行 schema,构建失败。如果没开自动构建,可能要等到部署时才发现。
💡 教训:Content Collections 的 schema 类型不是摆设。YAML 的类型系统和 TypeScript 的类型系统是两套,
gray-matter.stringify不会自动把字符串转成 YAML 的 date 类型,必须传入Date对象。凡是 schema 里定义为z.date()的字段,写入时一律用new Date()包一层。
slug 自动生成
顺手解决了一个 UX 问题:之前后台新建文章时 slug 字段是必填的,对不熟悉技术的人来说很懵——“slug 是啥?“。改成留空自动生成:
let slug = (data.slug ?? '').trim();if (!slug) { slug = `post-${Date.now().toString(36)}`; // base36 时间戳,短且唯一 let suffix = 1; while (existsSync(slugToPath(slug))) { slug = `post-${Date.now().toString(36)}-${suffix++}`; }}生成的 slug 形如 post-msqd7cgi,够短、够唯一、够语义化。当然用户想自定义也完全可以,字段只是从必填改成了可选。
七、错误总结 💡
| 问题 | 根因 | 解决方案 |
|---|---|---|
better-sqlite3 编译失败 | 缺 MSVC 工具集 / GLIBC 版本低 | 双驱动降级到 node:sqlite |
| Fastify 插件超时 | tsx 转译慢 + 默认 10s 超时 | pluginTimeout: 0 |
| 进程静默退出 | 事件循环空转 / 未捕获异常 | 全局错误处理 + keepAlive |
| Astro preview 外部访问失败 | 默认绑 localhost | --host 0.0.0.0 |
src/api.ts 与 src/api/ 命名冲突 | Vite/Rollup 模块解析 | 重命名为 src/http.ts |
| 图片加载失败出现裂图 | 友链头像失效 | onerror 回退到首字符占位 |
| 构建命令路径被截断 | pnpm --dir 参数含空格 | 改用 spawn 的 cwd 选项 |
| 新文章导致全站文章消失 | published 字段被序列化成带引号字符串 | 用 new Date() 包一层,输出 YAML 原生日期 |
💡 关于
src/api.ts那个坑:Astro 项目里文件名和目录名同名会导致 Vite/Rollup 模块解析出错,报错信息还特别误导。养成习惯:文件名和同级目录名不要重复。
💡 关于 Date 序列化那个坑:这个 bug 在本地 dev 模式下可能完全不暴露,只有
astro build时才会触发。建议开发时定期跑一次完整构建,别等部署时才发现问题。
八、技术选型回顾 📐
回过头看整个技术选型,大概是这样的决策链:
| 场景 | 选择 | 理由 |
|---|---|---|
| 前台框架 | Astro | 零 JS by default,内容站点性能拉满 |
| 交互组件 | Svelte 5 | 编译产物小,runes 语法写起来舒服 |
| 样式 | Tailwind CSS 4 | 原子化 + CSS 变量主题切换两不误 |
| 后台框架 | Fastify | 比 Express 快,插件系统清晰 |
| 数据库 | SQLite | 单文件部署,博客场景够用 |
| 包管理 | npm | 兼容性好,服务器构建不出幺蛾子 |
九、CSRF 校验移除 + 用户名修改功能 🔓
CSRF 校验:从有到无
后台一开始做了完整的 CSRF 防护——登录时签发 csrfToken 写入 Cookie,前端 Axios 拦截器自动读取并附加 X-CSRF-Token 请求头,后端全局 preHandler 校验所有非 GET 请求。设计上很标准,但实际用起来:
后台操作老显示「CSRF 校验失败」——Cookie 的 Secure 标记在 HTTP 环境下导致浏览器拒绝存储 csrfToken,前端拿不到 token,所有写操作全被 403。虽然之前修过一次(按 request.protocol 判断是否加 Secure),但反代环境下协议判断又有边界 case。
纠结了一圈,最终决定彻底移除 CSRF 校验——后台管理系统是内网性质(复杂路径 + JWT 鉴权 + 密码二次确认),CSRF 带来的收益不如它造成的问题多:
// 之前:全局 preHandler 里校验 CSRFapp.addHook('preHandler', async (request, reply) => { if (!isCsrfExempt(request) && !await verifyCsrf(request)) { return fail(reply, 'CSRF 校验失败', 403); }});// 现在:整段删掉移除涉及 7 个文件——后端删 requireCsrf 函数、删 CSRF_EXEMPT 白名单、删全局钩子;前端删 Axios 拦截器、删 csrfToken 状态、删类型定义。删干净比加进去还费劲。
修改用户名功能
后台本来只有改密码,没有改用户名。用户名想改只能 SSH 上服务器敲 SQL。既然「账号与访问」页面已经有了密码修改和端口配置,加个用户名修改 Tab 顺理成章:
// POST /system/username// 参数校验:3-20 位,字母/数字/下划线const changeUsernameSchema = z.object({ password: z.string().min(1).max(128), newUsername: z.string().min(3).max(20) .regex(/^[a-zA-Z0-9_]+$/),});安全措施和改密码一致:密码二次确认 → 查重(不能和已有用户名重复)→ 改完吊销所有令牌 + 清 cookie(JWT 里含旧用户名,必须重新登录)→ 操作日志记录。
前端在「账号与访问」页面加了第二个 Tab,显示当前用户名、新用户名输入框、密码确认框。提交后 1.5 秒自动跳转登录页,用新用户名 + 旧密码登录。
💡 教训:CSRF 防护在「内网管理系统 + HTTPS 反代」场景下,收益不如成本高。如果你的后台已经有多层鉴权(JWT + 复杂路径 + 密码确认),可以考虑移除 CSRF,省掉一堆 Cookie/Secure/SameSite 的边界问题。
十、构建系统全面优化:2 核 2G 服务器防卡死 ⚡
这是投入精力最多的一轮优化。服务器配置是 2 核 2G,Astro 全量构建吃内存很凶,频繁出现「构建卡死」——后台点「重新构建」后进度条转一辈子,自动构建(保存文章后触发)也经常无声中断。
卡死的三大根因
| 根因 | 表现 | 发生条件 |
|---|---|---|
| OOM 杀进程 | 子进程被内核静默杀掉,无 stderr 输出 | 2G 内存无 swap,构建峰值超 1.5G |
| Swap 抖动 | 进程活着但极慢,几乎无输出 | 有 swap 但太少,疯狂换页 |
| 超时太晚 | 卡了 20 分钟才被超时发现 | BUILD_TIMEOUT_MS = 20min 太长 |
修复 1:Swap 自动创建(根本手段)
start.sh 新增 ensure_swap() 函数,构建前自动检测:
ensure_swap() { # 读取 SwapTotal 和 MemTotal # 总内存 <= 2G 且无 swap → 自动创建 2G swap 文件 fallocate -l 2G /swapfile # 秒建(回退 dd) chmod 600 /swapfile mkswap /swapfile && swapon /swapfile # 写入 /etc/fstab 实现重启后自动挂载 echo "/swapfile none swap defaults 0 0" >> /etc/fstab}有了 2G swap,即使物理内存只有 2G,构建峰值 1.5G 也不会被 OOM 杀掉——最差也就是慢一点(走 swap),但能跑完。
修复 2:停滞检测(120 秒无输出 = 卡死)
之前只有 20 分钟总超时,太被动。新增停滞检测——每 30 秒检查一次 lastOutputAt,超过 120 秒没有任何 stdout/stderr 输出就判定卡死,立即强杀:
let lastOutputAt: number | null = null;const STALL_TIMEOUT_MS = 120 * 1000;
// 子进程每次输出都更新 lastOutputAtconst handleLine = (data: Buffer) => { lastOutputAt = Date.now(); // ...};
// 每 30 秒轮询stallTimer = setInterval(() => { if (lastOutputAt && Date.now() - lastOutputAt > STALL_TIMEOUT_MS) { pushLog(`[stall] 构建已 ${stallSec} 秒无输出,判定为卡死`); killBuildTree(); // 强杀整个进程树 }}, 30_000);这个机制能捕获两种之前无法检测的场景:
- OOM 杀进程后子进程静默退出——没有 close 事件触发(有时),但肯定没有输出了
- Swap 抖动假死——进程活着但慢到几乎不输出,120 秒够判断了
修复 3:堆内存上限适配低配服务器
之前堆上限是 [512, 2048] MB,对 2G 服务器太激进。改为按总内存分级:
| 总内存 | 堆上限范围 | 比例 | 给系统留 |
|---|---|---|---|
| ≤ 2G | [384, 768] MB | 50% | ~1.2G |
| > 2G | [512, 1024] MB | 55% | 充裕 |
function computeHeapCapMb(): number { const totalMb = Math.floor(os.totalmem() / 1024 / 1024); const ratio = totalMb <= 2048 ? 0.50 : 0.55; const maxCap = totalMb <= 2048 ? 768 : 1024; // ...}修复 4:构建工具从 pnpm 统一为 npm
构建命令原来走 pnpm run build,但服务器上 pnpm 的依赖完整性检查经常出问题(optional native binding 安装失败、build scripts 被 ignore)。统一改为 npm run build:
function getRunner() { return { cmd: 'npm', args: ['run', 'build'] };}同时 package.json 的 build 脚本改为直接调 node,绕过 .bin/ 软链(服务器 npm install 不完整时软链可能缺失):
{ "build": "node scripts/generate-icons.js && node node_modules/astro/bin/astro.mjs build && node node_modules/pagefind/lib/runner/bin.cjs --site dist"}修复 5:总超时降低
| 参数 | 旧版 | 新版 |
|---|---|---|
| 总超时 | 20 分钟 | 15 分钟 |
| 停滞超时 | 无 | 120 秒(新增) |
有了停滞检测兜底,总超时可以短一些。15 分钟对于个人博客的全量构建已经非常充裕了。
效果
优化前:构建 10 次卡死 7 次,只能 SSH 手动重启服务。
优化后:swap 自动创建后不再 OOM,偶发卡死 2 分钟内被停滞检测捕获并强杀,状态从 running 落为 failed,后续构建自动恢复,不再永久卡死。
💡 教训:2G 服务器跑 Astro 构建必须有 swap,这不是可选项是必选项。没有 swap 时 OOM-killer 会杀进程且不一定触发 close 事件,导致构建状态永久卡在
running。停滞检测(无输出超时)比单纯的总超时更有效——它能区分「正常构建中偶尔沉默」和「已经死了只是进程没退出」。
十一、域名绑定与静态托管 🌐
从 preview 到 nginx 静态托管
之前前端跑 astro preview(Node 进程),通过 IP:4321 访问。绑定域名 qiguai.net 后遇到一堆问题:
- Vite
allowedHosts拦截:Blocked request. This host ("qiguai.net") is not allowed.——Vite 默认只允许 localhost - 端口暴露:访问
IP:4321不够优雅,也不安全 - preview 进程占内存:2G 服务器多跑一个 Node 进程很奢侈
最终方案是彻底去掉 preview,改用 nginx 静态托管:
宝塔建静态站点 qiguai.net → 网站目录指向 /www/wwwroot/my-blog-master/distAstro 是 output: "static",dist/ 目录就是纯 HTML/CSS/JS,nginx 直接发静态文件,不需要 Node 中间层。start.sh 的 prod 模式从启动 preview 改为只启动后台 + 提示「nginx → dist/ 静态托管」。
好处:省一个 Node 进程、更快(nginx 发静态文件比 preview 快)、没有 allowedHosts / 端口 / Host 头问题、内存占用少。更新文章 → npm run build → nginx 自动 serve 新 dist,前端连重启都不用。
后台反代
后台(Fastify 8787 端口)通过子域名 xxxx.qiguai.net 反代:
宝塔建站点 xxxx.qiguai.net → 反向代理 → http://127.0.0.1:8787踩坑:反代目标一开始写的 http://xxxxx:8787(公网 IP),阿里云服务器访问自己的公网 IP 会被安全组拦。改为 http://127.0.0.1:8787 就好了——请求不走出服务器,不经安全组,速度快。
端口修改功能的角色变化
绑定域名后,「改端口」功能基本没用了:
| 功能 | 之前 | nginx 反代后 |
|---|---|---|
| 前端端口 | preview 监听端口 | 无用——nginx 直托管 dist/,不涉及端口 |
| 后台端口 | 后端换端口重启 | 改了反而坏事——nginx 指向旧端口,反代断链 |
| 后台路径 | SPA 重建 + 重启 | 仍有用——路径是 URL 路径,nginx 透传 |
| 改密码 | — | 仍有用 |
端口对外已经隐身(走域名),没必要改了。路径和密码继续用。
Vite allowedHosts
虽然改成了 nginx 静态托管(不再跑 preview),但 astro.config.mjs 里还是加了 allowedHosts,以备本地 dev 时用域名访问:
vite: { preview: { allowedHosts: ['qiguai.net', 'www.qiguai.net'], },},💡 教训:静态站点(
output: "static")最自然的部署方式就是 nginx 直托管,不需要 Node 进程。astro preview适合本地预览,不适合生产——多一个 Node 进程、有 Host 检查问题、占内存。反代目标永远用127.0.0.1,不要用公网 IP——云服务器访问自己的公网 IP 会被安全组拦截。
写在最后 🎯
- 架构分层要清晰,前台静态、后台动态、数据库单文件,各司其职
- 能自动化的流程坚决不手动,从部署到友链审核
Astro 的 Islands Architecture 对博客这种内容站点真的特别合适——默认零 JS,只在需要交互的组件注水,Lighthouse 跑分看着就舒服。再加上 Svelte 5 的 runes 语法、Tailwind 4 的 CSS 变量主题,开发体验整体很丝滑。
谢谢大家

