<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>RQ·LY</title><description>博客与手记</description><link>https://rqly.com/</link><language>zh_CN</language><item><title>我开始用对话发博客了：从一个想法到一次自动发布</title><link>https://rqly.com/blog/posts/2026-09-14-%E6%88%91%E5%BC%80%E5%A7%8B%E7%94%A8%E5%AF%B9%E8%AF%9D%E5%8F%91%E5%8D%9A%E5%AE%A2%E4%BA%86%E4%BB%8E%E4%B8%80%E4%B8%AA%E6%83%B3%E6%B3%95%E5%88%B0%E4%B8%80%E6%AC%A1%E8%87%AA%E5%8A%A8%E5%8F%91%E5%B8%83/</link><guid isPermaLink="true">https://rqly.com/blog/posts/2026-09-14-%E6%88%91%E5%BC%80%E5%A7%8B%E7%94%A8%E5%AF%B9%E8%AF%9D%E5%8F%91%E5%8D%9A%E5%AE%A2%E4%BA%86%E4%BB%8E%E4%B8%80%E4%B8%AA%E6%83%B3%E6%B3%95%E5%88%B0%E4%B8%80%E6%AC%A1%E8%87%AA%E5%8A%A8%E5%8F%91%E5%B8%83/</guid><description>记录我如何把 ChatGPT 接入博客仓库：通过对话提出想法，由 AI 归纳成文章、写入 Markdown 并提交 GitHub，再交给 GitHub Actions 自动构建和发布。</description><pubDate>Mon, 14 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;以前我总觉得，写博客真正困难的地方不是没有东西可写，而是从“脑子里有个想法”到“网站上出现一篇完整文章”之间，隔着一段不短的距离。&lt;/p&gt;
&lt;p&gt;先要打开编辑器，确定标题和结构，再按照网站格式填写 Front Matter，处理 Markdown，找图片，检查链接，提交代码，等待构建，最后还要确认页面有没有正常显示。每一步单独看都不复杂，但它们加在一起，就足够让很多临时冒出来的想法停在“以后再写”。&lt;/p&gt;
&lt;p&gt;最近我把这件事换了一种做法：不再把“打开编辑器”作为写作的第一步，而是先和 ChatGPT 聊起来。&lt;/p&gt;
&lt;p&gt;这篇文章本身，就是这套工作流的一次实际测试。&lt;/p&gt;
&lt;h2&gt;一、写博客的入口，从编辑器变成了对话&lt;/h2&gt;
&lt;p&gt;现在我不需要先把文章想完整。一个主题、一段经历、几句还没有整理好的话，甚至只是一个突然想到的问题，都可以直接说出来。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;我想写一篇文章，记录我是怎么借助 AI 把博客发出去的，重点说清楚我们现在形成的工作流，以及为什么使用 Luna 之后成本很低。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这句话本身还不是文章，但它已经包含了文章的方向。&lt;/p&gt;
&lt;p&gt;接下来，通过几轮对话，主题会逐渐变得清楚：这篇文章到底想记录什么，哪些内容是事实，哪些只是个人感受，应该从哪个角度展开，最后希望读者记住什么。&lt;/p&gt;
&lt;p&gt;对我来说，这比面对一个空白编辑器轻松很多。因为我不需要一开始就同时处理“想写什么”和“应该怎么写”这两件事，只需要先把想法说出来。&lt;/p&gt;
&lt;h2&gt;二、AI 做的不是简单改写，而是把对话整理成文章&lt;/h2&gt;
&lt;p&gt;如果只是把聊天记录复制下来，当然不能算是一篇博客。真正有用的部分，是把对话里散落的内容重新组织起来。&lt;/p&gt;
&lt;p&gt;在这个过程中，AI 主要承担几件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;从零散的表达中提取一个相对明确的主题；&lt;/li&gt;
&lt;li&gt;把前后几轮对话里的信息串成一条主线；&lt;/li&gt;
&lt;li&gt;删除重复、绕远和只适合聊天的部分；&lt;/li&gt;
&lt;li&gt;保留个人经历、判断和语气，而不是改成统一的说明书口吻；&lt;/li&gt;
&lt;li&gt;根据博客已有的风格补出标题、摘要、章节和标签；&lt;/li&gt;
&lt;li&gt;对不确定的地方保留边界，不把猜测写成确定事实。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这里有一个很重要的区别：我并不是把思考完全交给 AI，而是先通过对话把思考外化，再让 AI 帮我整理。&lt;/p&gt;
&lt;p&gt;最终文章里真正有价值的内容，仍然来自我经历过的事情、做过的选择和形成的判断。AI 更像一个随时可以调用的编辑，帮助我把这些东西从聊天状态整理成可以阅读的文字。&lt;/p&gt;
&lt;h2&gt;三、文章最终还是一个普通的 Markdown 文件&lt;/h2&gt;
&lt;p&gt;这套流程能够真正落地，还有一个前提：博客本身采用 GitHub 加 Markdown 的方式管理。&lt;/p&gt;
&lt;p&gt;我的博客源码在私有仓库 &lt;code&gt;ly2u/ly2u-blog&lt;/code&gt; 中，文章位于：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;src/content/posts/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每篇文章都是一个普通的 &lt;code&gt;.md&lt;/code&gt; 文件，文件开头使用博客现有的 Front Matter，例如标题、发布日期、摘要、分类、标签和是否为草稿等信息。&lt;/p&gt;
&lt;p&gt;所以在写入文章之前，AI 需要先读取仓库当前的目录结构、文章格式和最近的内容，而不是凭空假设一个路径。这样做有两个好处：&lt;/p&gt;
&lt;p&gt;第一，生成的文件能够符合现有博客的构建规则，不会因为字段写错或目录放错而导致构建失败。&lt;/p&gt;
&lt;p&gt;第二，文章会尽量延续原来博客的语气和结构。它不会突然变成一篇和站内其他内容完全不同的模板文章。&lt;/p&gt;
&lt;p&gt;我还有一个单独的笔记站 &lt;code&gt;bijiy.com&lt;/code&gt;，但它和这个博客的用途不同。笔记仍然由我自己整理和发布；这套“对话后直接提交”的流程，目前只用于 &lt;code&gt;ly2u-blog&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;四、从一句话到一个可发布文件&lt;/h2&gt;
&lt;p&gt;现在整套流程大致可以概括成下面这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;提出一个想法
      ↓
通过对话补充背景、经历和判断
      ↓
AI 提取主题，整理结构，完成文字
      ↓
读取博客现有格式，生成 Markdown
      ↓
写入 ly2u-blog/src/content/posts/
      ↓
提交 GitHub
      ↓
GitHub Actions 自动检查、构建和部署
      ↓
blog.ly2u.com 发布
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这中间最关键的一步，是把“写文章”和“发布文章”接在了一起。&lt;/p&gt;
&lt;p&gt;以前我可能需要在聊天窗口里得到一篇文章，然后自己复制、打开仓库、创建文件、提交。现在只要文章内容和方向已经明确，AI 可以继续完成后面的机械工作：按现有格式创建文件，并提交到正确的仓库和分支。&lt;/p&gt;
&lt;p&gt;文章仍然保存在 GitHub 里，仍然可以查看修改历史，也没有因为使用 AI 就引入一个新的数据库或封闭的编辑系统。&lt;/p&gt;
&lt;h2&gt;五、提交之后，云端自动完成发布&lt;/h2&gt;
&lt;p&gt;博客的 &lt;code&gt;main&lt;/code&gt; 分支配置了 GitHub Actions。每次有新的提交，工作流会自动执行：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;安装项目依赖；&lt;/li&gt;
&lt;li&gt;运行 &lt;code&gt;pnpm check&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;执行生产构建；&lt;/li&gt;
&lt;li&gt;生成 Pagefind 搜索索引；&lt;/li&gt;
&lt;li&gt;使用 Wrangler 将最终的 &lt;code&gt;dist&lt;/code&gt; 上传到 Cloudflare Pages。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;也就是说，我和 AI 的对话并不会直接操作网站服务器。对话最后只产生一个正常的 Git 提交，后面的构建和部署仍然交给已经配置好的自动化流程。&lt;/p&gt;
&lt;p&gt;这让整个过程保持得比较简单：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;对话
  ↓
Markdown
  ↓
Git commit
  ↓
GitHub Actions
  ↓
Cloudflare Pages
  ↓
博客页面
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果文章格式有问题，构建检查会先暴露问题；如果构建成功，网站再接收最终产物。至少从工程上看，文章发布并不是一次不可追踪的黑箱操作，而是一个有源文件、有提交记录、有构建过程的普通发布流程。&lt;/p&gt;
&lt;h2&gt;六、为什么 Luna 让这件事变得更容易坚持&lt;/h2&gt;
&lt;p&gt;这套工作流还有一个很现实的条件：并不是每次写博客都需要调用最重的模型。&lt;/p&gt;
&lt;p&gt;对于日常文章来说，提取主题、整理段落、润色表达、补充 Front Matter、检查文件路径，再完成一次 GitHub 提交，这些工作使用 Luna 就已经足够了。&lt;/p&gt;
&lt;p&gt;由于调用的是 Luna，按照目前的使用方式，单次额度消耗基本可以忽略。这里的价值不只是节省一点模型额度，更重要的是降低了“写一篇文章”的心理成本：我不需要因为一次普通的记录，就专门安排一套很重的流程。&lt;/p&gt;
&lt;p&gt;如果每个想法都要经过复杂的编辑、排版和发布步骤，博客很容易重新变成一个需要刻意维护的项目。轻量模型加上自动化提交，让它更接近日常记录，而不是一次正式的内容生产任务。&lt;/p&gt;
&lt;h2&gt;七、它真正减少的是启动成本&lt;/h2&gt;
&lt;p&gt;回头看，这套方式并没有改变写作最核心的部分。我仍然需要有经历、有问题、有判断，也仍然需要决定一篇文章是否值得留下来。&lt;/p&gt;
&lt;p&gt;它减少的是启动成本：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不必等到有完整提纲之后才开始；&lt;/li&gt;
&lt;li&gt;不必先处理文件名、目录和 Front Matter；&lt;/li&gt;
&lt;li&gt;不必在聊天、编辑器和 GitHub 页面之间反复切换；&lt;/li&gt;
&lt;li&gt;不必为了发布一篇短文单独维护一套复杂流程；&lt;/li&gt;
&lt;li&gt;以后想修改时，仍然可以从原来的 Markdown 和 Git 历史继续开始。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这也比较符合我搭建这个博客时一直想要的方向：网站可以有一定的功能，但内容本身尽量保持简单、开放和可迁移。&lt;/p&gt;
&lt;h2&gt;八、自动化不等于不需要判断&lt;/h2&gt;
&lt;p&gt;当然，这套流程并不是说以后只要随便说几句话，所有内容就应该直接公开。&lt;/p&gt;
&lt;p&gt;对于技术经验、工程资料、历史信息或涉及他人的内容，仍然需要人工判断哪些可以发布，哪些需要核对，哪些只适合保留在私人笔记里。AI 可以帮我整理表达，但不能替我承担事实责任，也不能替我决定一段经历是否应该公开。&lt;/p&gt;
&lt;p&gt;所以我更愿意把它理解成一种“半自动写作”：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;人负责提供真实的经历、问题和判断，AI 负责整理与执行，GitHub 负责保存版本，云端流程负责完成发布。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;自动化的是重复劳动，不是思考本身。&lt;/p&gt;
&lt;h2&gt;结语：先说出来，再让它留下来&lt;/h2&gt;
&lt;p&gt;以前我常常把写博客想成一个完整的动作：坐下来，写完一篇文章，然后发布。&lt;/p&gt;
&lt;p&gt;现在这个过程被拆成了几个更容易开始的小步骤：先说出一个想法，再通过对话把它想清楚；想清楚之后，让 AI 整理成 Markdown；文件进入 GitHub，再由云端自动完成构建和发布。&lt;/p&gt;
&lt;p&gt;这篇文章本身就是这样产生的。我没有先在编辑器里写好全文，而是先说出想记录的工作流，再让 AI 根据我们的对话把它整理出来，最后写入博客仓库。&lt;/p&gt;
&lt;p&gt;对我来说，这种方式最有意义的地方，可能不是“AI 帮我写了一篇文章”，而是以后那些原本只会停留在聊天里的想法，有机会真正变成博客里一篇可以被留下来的记录。&lt;/p&gt;
&lt;p&gt;先说出来，再让它留下来。&lt;/p&gt;
</content:encoded></item><item><title>这个博客是怎么搭起来的：从 Fuwari 到一套自己的 Astro 工作流</title><link>https://rqly.com/blog/posts/2026-09-13-%E8%BF%99%E4%B8%AA%E5%8D%9A%E5%AE%A2%E6%98%AF%E6%80%8E%E4%B9%88%E6%90%AD%E8%B5%B7%E6%9D%A5%E7%9A%84%E4%BB%8Efuwari%E5%88%B0%E4%B8%80%E5%A5%97%E8%87%AA%E5%B7%B1%E7%9A%84astro%E5%B7%A5%E4%BD%9C%E6%B5%81/</link><guid isPermaLink="true">https://rqly.com/blog/posts/2026-09-13-%E8%BF%99%E4%B8%AA%E5%8D%9A%E5%AE%A2%E6%98%AF%E6%80%8E%E4%B9%88%E6%90%AD%E8%B5%B7%E6%9D%A5%E7%9A%84%E4%BB%8Efuwari%E5%88%B0%E4%B8%80%E5%A5%97%E8%87%AA%E5%B7%B1%E7%9A%84astro%E5%B7%A5%E4%BD%9C%E6%B5%81/</guid><description>记录这个博客目前的技术架构与改造过程：从 Astro/Fuwari 出发，接入 Decap CMS、自建图床、GitHub Actions 与 Cloudflare Pages，并围绕移动端性能、代码阅读、编辑工作流和自动更新时间做了一轮深度定制。</description><pubDate>Sun, 13 Sep 2026 15:40:00 GMT</pubDate><content:encoded>&lt;p&gt;这个博客最开始并没有什么宏大的目标。&lt;/p&gt;
&lt;p&gt;我只是想要一个自己真正愿意长期写下去的地方：能记技术踩坑，也能写一些还没有完全想明白的东西；最好不依赖某个平台，不被特定的编辑器和发布流程绑住，哪天想搬走时，所有文章仍然只是普通的 Markdown 文件。&lt;/p&gt;
&lt;p&gt;所以现在看到的这个站，虽然外观仍然能看出 Fuwari 的影子，但底下的工作流已经改了不少。&lt;/p&gt;
&lt;p&gt;这篇就当作一次阶段性记录，简单拆一下它现在是怎么工作的，以及这段时间到底改了些什么。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;一、先看整体架构&lt;/h2&gt;
&lt;p&gt;目前整个博客大致可以画成这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;                    ┌─────────────────────┐
                    │      GitHub Repo    │
                    │   Markdown + Code   │
                    └──────────┬──────────┘
                               │ push
                               ▼
                    ┌─────────────────────┐
                    │   GitHub Actions    │
                    │ install / check     │
                    │ build / Pagefind    │
                    └──────────┬──────────┘
                               │ dist
                               ▼
                    ┌─────────────────────┐
                    │      Wrangler       │
                    │  Direct Upload      │
                    └──────────┬──────────┘
                               ▼
                    ┌─────────────────────┐
                    │  Cloudflare Pages   │
                    │ CDN / HTTPS / 托管  │
                    └──────────┬──────────┘
                               ▼
                           blog.ly2u.com

后台编辑：
浏览器 → Decap CMS → GitHub OAuth → GitHub Repo

图片：
浏览器 → 自建图床 → 边缘加速 → Markdown 外链
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;核心思路其实很简单：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;GitHub 保存源文件，GitHub Actions 负责构建，Cloudflare 只负责托管和分发。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这和最开始直接把 GitHub 仓库接给 Cloudflare Pages 已经不太一样了。&lt;/p&gt;
&lt;p&gt;以前每次提交代码，Cloudflare 会自己拉源码、安装依赖、重新构建；后来 GitHub Actions 本身也在做一遍 &lt;code&gt;pnpm build&lt;/code&gt; 检查，相当于同一份代码被构建两次。&lt;/p&gt;
&lt;p&gt;现在干脆把职责拆开：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;GitHub Actions 安装依赖；&lt;/li&gt;
&lt;li&gt;运行 Astro check；&lt;/li&gt;
&lt;li&gt;正式构建静态站；&lt;/li&gt;
&lt;li&gt;生成 Pagefind 索引；&lt;/li&gt;
&lt;li&gt;得到最终 &lt;code&gt;dist/&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;Wrangler 直接把成品上传到 Cloudflare Pages。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Cloudflare 不再参与源码构建，只做静态文件托管、HTTPS 和 CDN。&lt;/p&gt;
&lt;p&gt;对一个纯静态博客来说，这套关系反而更清楚。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;二、底层还是 Astro，但尽量少让前端“动起来”&lt;/h2&gt;
&lt;p&gt;博客目前基于 Astro，最初从 Fuwari 主题改出来。&lt;/p&gt;
&lt;p&gt;我喜欢 Astro 的一个原因，是它很适合这种以阅读为主的网站：大部分内容在构建时直接生成 HTML，不需要为了显示一篇文章先加载一整个前端应用。&lt;/p&gt;
&lt;p&gt;现阶段主要用到的东西包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Astro&lt;/strong&gt;：静态站主体；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS&lt;/strong&gt;：页面样式；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Svelte&lt;/strong&gt;：少数确实需要交互的设置组件；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pagefind&lt;/strong&gt;：静态全文搜索；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Swup&lt;/strong&gt;：站内页面切换；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Expressive Code&lt;/strong&gt;：桌面端代码高亮；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PhotoSwipe&lt;/strong&gt;：图片查看；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OverlayScrollbars&lt;/strong&gt;：桌面滚动体验；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Decap CMS&lt;/strong&gt;：网页端文章编辑后台。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不过后来的优化方向，反而一直是在做减法。&lt;/p&gt;
&lt;p&gt;比如搜索并不会一打开网页就加载 Pagefind，而是第一次点击搜索时才加载；PhotoSwipe 也是用户真正打开图片时才引入；OverlayScrollbars 只在桌面端、精细指针设备上初始化。&lt;/p&gt;
&lt;p&gt;右侧文章目录也是一样。以前只是用 CSS 在移动端把它隐藏，JavaScript 仍然会扫描标题、创建 Observer、计算位置。现在小屏幕下干脆连目录逻辑都不初始化。&lt;/p&gt;
&lt;p&gt;对博客来说，很多“功能”其实并不需要一直在线。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;三、真正折腾得最久的，是 iPhone 上的长文章&lt;/h2&gt;
&lt;p&gt;这个站目前改得最深的一块，其实不是外观，而是移动端代码阅读。&lt;/p&gt;
&lt;p&gt;之前写图床那篇文章时，里面有很多代码，还有一个接近 200 行的 Go 程序。Android 和桌面都正常，但在 iPhone Safari 上快速滑动长文章时，正文会突然出现大面积空白，停下来之后才重新补绘。&lt;/p&gt;
&lt;p&gt;最开始怀疑过很多东西：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;右侧 TOC；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;content-visibility&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;页面动画；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;backdrop-filter&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;多层 &lt;code&gt;overflow:hidden&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;代码块自己的滚动条。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最后做了一轮比较彻底的 A/B，才发现真正影响最大的，是长页面中常驻的 Expressive Code 复杂 DOM。&lt;/p&gt;
&lt;p&gt;桌面端完整的代码块很好看，但一段几百行的高亮代码会生成大量 token &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt;。多个代码块叠加后，iOS WebKit 在快速滚动时很容易出现绘制跟不上的情况。&lt;/p&gt;
&lt;p&gt;最后没有继续硬调 CSS，而是换了一个思路：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;正文负责阅读，代码按需查看。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;现在移动端的规则是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;很短的代码转成轻量代码框；&lt;/li&gt;
&lt;li&gt;稍长的代码在正文里变成“代码卡片”；&lt;/li&gt;
&lt;li&gt;点击卡片后，再打开独立的全屏代码阅读器；&lt;/li&gt;
&lt;li&gt;完整的代码 DOM 只在需要时出现，关闭阅读器后就销毁；&lt;/li&gt;
&lt;li&gt;桌面端仍然保留完整 Expressive Code。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这其实比原来直接把 200 行代码铺在手机正文里更合理。&lt;/p&gt;
&lt;p&gt;最初只是为了修一个 Safari 性能问题，最后反而变成了一个我更喜欢的移动端阅读方式。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;四、Swup 也踩过一个很隐蔽的坑&lt;/h2&gt;
&lt;p&gt;另一个比较典型的问题，是桌面端点击页面时会“闪两下”。&lt;/p&gt;
&lt;p&gt;一开始以为是两套过渡动画叠加，于是把内容入场动画、Swup fade 动画都关掉过，但问题仍然存在。更奇怪的是，录屏里连地址栏都会出现短暂的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/目标页/ → / → /目标页/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这就说明它不是单纯的视觉闪烁，而是真的发生了二次导航。&lt;/p&gt;
&lt;p&gt;最后查到 Swup 配置要求每次同时替换：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;main
#toc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但旧布局里 &lt;code&gt;#toc&lt;/code&gt; 是条件渲染的：文章有目录时才存在，首页、归档、传送门等页面可能根本没有这个节点。&lt;/p&gt;
&lt;p&gt;对于 Swup 来说，不同页面的容器结构并不稳定，于是切页时就可能出现异常回退。&lt;/p&gt;
&lt;p&gt;最终的解决办法很简单：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;所有页面始终保留 &lt;code&gt;#toc&lt;/code&gt; 容器，没有目录时只让它为空，而不是让节点本身消失。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;修完以后，双跳转和闪屏都消失了。&lt;/p&gt;
&lt;p&gt;这个问题也挺有代表性：有时候看起来像“动画问题”，真正的根因却是页面结构契约被破坏了。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;五、后台并不是另一个数据库，文章仍然只是 Markdown&lt;/h2&gt;
&lt;p&gt;我不太想为了一个个人博客再维护一套数据库和复杂后端，所以后台编辑继续使用 Decap CMS。&lt;/p&gt;
&lt;p&gt;它本质上只是一个 Git 的图形化编辑界面。&lt;/p&gt;
&lt;p&gt;在浏览器中打开后台后，通过 GitHub OAuth 完成身份验证；文章编辑、创建、保存，最后仍然是对仓库里的 Markdown 文件提交 commit。&lt;/p&gt;
&lt;p&gt;也就是说，无论文章是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在本地编辑器里写；&lt;/li&gt;
&lt;li&gt;在 GitHub 里直接改；&lt;/li&gt;
&lt;li&gt;在 Decap 后台改；&lt;/li&gt;
&lt;li&gt;或者由自动化工具修改；&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最后落到仓库里的东西都是同一种格式。&lt;/p&gt;
&lt;p&gt;这一点对我来说很重要，因为 Markdown 才是最终的数据源，后台只是一个入口。&lt;/p&gt;
&lt;p&gt;目前 Decap 还做了几处自己的定制：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;后台界面做了简单的中文化和样式整理；&lt;/li&gt;
&lt;li&gt;粘贴图片时自动压缩为 WebP；&lt;/li&gt;
&lt;li&gt;图片不进入 Git 仓库，而是直接上传到自建图床；&lt;/li&gt;
&lt;li&gt;上传成功后自动插入 Markdown 图片链接；&lt;/li&gt;
&lt;li&gt;可以从 GitHub 重新读取文章原始 Markdown；&lt;/li&gt;
&lt;li&gt;保留一个快速进入外部 Markdown 排版器的入口。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样仓库不会因为图片越来越多而迅速膨胀。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;六、图片和文章分开存&lt;/h2&gt;
&lt;p&gt;图片目前走的是自建图床，而不是把所有原图都塞进博客仓库。&lt;/p&gt;
&lt;p&gt;编辑后台里粘贴一张图片时，大致会经历：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;剪贴板图片
   ↓
浏览器 Canvas 压缩
   ↓
WebP
   ↓
自建上传接口
   ↓
图床存储
   ↓
边缘加速
   ↓
返回 https 图片地址
   ↓
插入 Markdown
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;浏览器端会限制最大尺寸，并在上传前进行一次压缩。&lt;/p&gt;
&lt;p&gt;这样做的好处很实际：Git 仓库里主要还是文字和代码，构建速度、clone 体积和历史记录都不会被大量二进制图片拖累。&lt;/p&gt;
&lt;p&gt;头像后来也从仓库里的 2MB 多 PNG 改成了图床上的 WebP。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;七、发布日期和最后修改日期分开了&lt;/h2&gt;
&lt;p&gt;以前文章只有 &lt;code&gt;published&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;但博客文章发出去之后，后面很可能还会继续修错字、补步骤、改代码。如果修改过很多次，只有一个发布日期并不能完整说明文章状态。&lt;/p&gt;
&lt;p&gt;现在的做法是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;published&lt;/code&gt; 继续写在 frontmatter 中，代表第一次发布；&lt;/li&gt;
&lt;li&gt;构建时通过 Git 历史读取这篇 Markdown 最近一次 commit 时间；&lt;/li&gt;
&lt;li&gt;只有文件至少发生过第二次提交时，页面才显示“最后修改日期”；&lt;/li&gt;
&lt;li&gt;JSON-LD 中同时写入 &lt;code&gt;dateModified&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样不需要每次手工维护 &lt;code&gt;updated&lt;/code&gt; 字段。&lt;/p&gt;
&lt;p&gt;无论通过哪种方式改文章，只要最终进入 Git 历史，更新时间就会自动变化。&lt;/p&gt;
&lt;p&gt;文章标题旁边现在也放了一个很轻的编辑图标。对普通访问者来说它只是一个入口；真正点击后仍然需要经过 GitHub OAuth 和仓库权限验证。&lt;/p&gt;
&lt;p&gt;对自己来说，就可以从“正在看的文章”直接进入“这篇文章的后台编辑页面”。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;八、从 Fuwari 到现在，主要改了什么&lt;/h2&gt;
&lt;p&gt;如果只看目录结构，这个站仍然能找到不少 Fuwari 的东西；但实际使用体验已经改了很多。&lt;/p&gt;
&lt;p&gt;目前比较明显的变化包括：&lt;/p&gt;
&lt;h3&gt;内容层&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;删除主题自带的演示文章和示例内容；&lt;/li&gt;
&lt;li&gt;重做“关于我”和“传送门”；&lt;/li&gt;
&lt;li&gt;增加友链区域；&lt;/li&gt;
&lt;li&gt;增加更符合自己使用习惯的站点描述和导航；&lt;/li&gt;
&lt;li&gt;增加自动最后修改时间；&lt;/li&gt;
&lt;li&gt;增加文章快速编辑入口。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;性能层&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Pagefind 改为第一次搜索时再加载；&lt;/li&gt;
&lt;li&gt;PhotoSwipe 改为按需加载；&lt;/li&gt;
&lt;li&gt;OverlayScrollbars 只在桌面初始化；&lt;/li&gt;
&lt;li&gt;移动端不初始化右侧 TOC；&lt;/li&gt;
&lt;li&gt;滚动和 resize 监听做节流；&lt;/li&gt;
&lt;li&gt;移动端关闭高成本导航栏模糊；&lt;/li&gt;
&lt;li&gt;减少不必要的 GPU layer 和入场动画；&lt;/li&gt;
&lt;li&gt;优化移动端网格，不再保留隐藏的桌面列；&lt;/li&gt;
&lt;li&gt;大头像改走体积更小的 WebP 外链。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;移动端代码&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;移除正文中长期存在的重型 Expressive Code DOM；&lt;/li&gt;
&lt;li&gt;短代码使用轻量代码框；&lt;/li&gt;
&lt;li&gt;长代码使用代码卡片；&lt;/li&gt;
&lt;li&gt;点击后进入独立全屏阅读器；&lt;/li&gt;
&lt;li&gt;代码阅读器保留行号、复制、横向滚动和语法高亮。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;构建与发布&lt;/h3&gt;
&lt;p&gt;最早：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GitHub → Cloudflare 拉源码 → Cloudflare 构建 → Pages
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;现在：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GitHub
   ↓
GitHub Actions
   ↓
pnpm install
pnpm check
pnpm build
   ↓
dist
   ↓
Wrangler
   ↓
Cloudflare Pages
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这也是我目前比较满意的一次调整：构建环境被固定在 GitHub Actions，Cloudflare 只接收最终产物。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;九、为什么没有继续往“更复杂”做&lt;/h2&gt;
&lt;p&gt;如果愿意，这个博客当然还可以继续堆很多东西：数据库、评论、账号体系、动态 API、在线草稿、实时预览……&lt;/p&gt;
&lt;p&gt;但目前我更希望它保持一种比较克制的状态。&lt;/p&gt;
&lt;p&gt;一篇文章最核心的东西仍然是一个 Markdown 文件；网站本身可以整个重新构建；图床可以单独迁移；Cloudflare 只是托管层；后台坏了也不影响文章源文件。&lt;/p&gt;
&lt;p&gt;换句话说，各部分之间有联系，但尽量不互相绑死。&lt;/p&gt;
&lt;p&gt;这也是我现在越来越喜欢的一种搭建方式：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;能静态就静态，能在构建阶段解决的就不要留到浏览器运行时；能保持普通文件格式的，就不要急着塞进数据库。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;十、接下来还会继续改&lt;/h2&gt;
&lt;p&gt;现在这个站当然还没有“做完”。&lt;/p&gt;
&lt;p&gt;实际上个人博客大概也不存在真正做完的时候。&lt;/p&gt;
&lt;p&gt;后面可能还会继续调整：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;后台编辑体验；&lt;/li&gt;
&lt;li&gt;文章版本与修改记录；&lt;/li&gt;
&lt;li&gt;搜索结果展示；&lt;/li&gt;
&lt;li&gt;移动端代码阅读器细节；&lt;/li&gt;
&lt;li&gt;RSS、SEO 和结构化数据；&lt;/li&gt;
&lt;li&gt;友链与独立博客之间的连接方式；&lt;/li&gt;
&lt;li&gt;一些真正对写作有帮助、而不是纯粹为了“功能更多”的小工具。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不过至少到现在，底层的结构已经比较清楚了。&lt;/p&gt;
&lt;p&gt;它不再只是“下载一个主题然后部署”的博客，而是慢慢变成了一套符合自己习惯的写作和发布工具。&lt;/p&gt;
&lt;p&gt;某种意义上，这也正是我想做博客的原因：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;不是先把一切设计完整再开始记录，而是在持续记录的过程中，让工具和自己一起慢慢成形。&lt;/strong&gt;&lt;/p&gt;
</content:encoded></item><item><title>iOS Safari 长文空白与 Swup 双跳转：一次博客性能排障实录</title><link>https://rqly.com/blog/posts/2026-09-13-ios-safari%E9%95%BF%E6%96%87%E7%A9%BA%E7%99%BD%E4%B8%8Eswup%E5%8F%8C%E8%B7%B3%E8%BD%AC%E4%B8%80%E6%AC%A1%E5%8D%9A%E5%AE%A2%E6%80%A7%E8%83%BD%E6%8E%92%E9%9A%9C%E5%AE%9E%E5%BD%95/</link><guid isPermaLink="true">https://rqly.com/blog/posts/2026-09-13-ios-safari%E9%95%BF%E6%96%87%E7%A9%BA%E7%99%BD%E4%B8%8Eswup%E5%8F%8C%E8%B7%B3%E8%BD%AC%E4%B8%80%E6%AC%A1%E5%8D%9A%E5%AE%A2%E6%80%A7%E8%83%BD%E6%8E%92%E9%9A%9C%E5%AE%9E%E5%BD%95/</guid><description>记录一次 Astro 博客移动端性能排障：iOS Safari 在长文章快速滚动时出现整屏空白，最终定位到 Expressive Code 的复杂高亮 DOM 与 WebKit 绘制压力，并通过移动端代码卡片与按需全屏阅读器解决；同时排查并修复 Swup 因条件渲染</description><pubDate>Sun, 13 Sep 2026 14:10:00 GMT</pubDate><content:encoded>&lt;h2&gt;起因：只有 iPhone 会“滚出一片空白”&lt;/h2&gt;
&lt;p&gt;这次问题一开始很像一个普通的移动端懒加载 Bug。&lt;/p&gt;
&lt;p&gt;博客在 Android 和桌面浏览器上都正常，但到了 iPhone Safari，只要在一篇代码较多的长文章里快速上下滑动，就会出现一个非常奇怪的现象：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;页面滚动条还在继续移动；&lt;/li&gt;
&lt;li&gt;顶部导航栏和 Safari 自己的 UI 都正常；&lt;/li&gt;
&lt;li&gt;文章正文却会突然变成一整块背景色；&lt;/li&gt;
&lt;li&gt;停止滚动后，正文又会整片补绘回来。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;第一反应自然是“正文是不是做了懒加载”“是不是目录栏还在移动端偷偷运行”“是不是某个 &lt;code&gt;content-visibility&lt;/code&gt; 或动画导致内容没有及时渲染”。&lt;/p&gt;
&lt;p&gt;但后来逐帧看录屏后，现象其实更接近 iOS WebKit 的 &lt;strong&gt;checkerboarding / missing tiles&lt;/strong&gt;：页面结构已经在那里，滚动位置也在变化，只是浏览器的绘制与栅格化没有及时跟上。&lt;/p&gt;
&lt;p&gt;这也解释了为什么 Android 基本正常，而 iOS 特别明显。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;第一轮：先把外围嫌疑一个个排除&lt;/h2&gt;
&lt;p&gt;排查这种问题，最怕一口气改十处代码。因为最后即使“好了”，也不知道到底是哪一个修改起作用。&lt;/p&gt;
&lt;p&gt;所以这次后来采用的办法很简单：&lt;strong&gt;严格做 A/B，一次只改一个变量。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;1. 移动端右侧 TOC&lt;/h3&gt;
&lt;p&gt;之前移动端虽然通过 CSS 隐藏了右侧目录，但对应 JavaScript 仍可能继续：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;扫描标题；&lt;/li&gt;
&lt;li&gt;创建 &lt;code&gt;IntersectionObserver&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;计算目录位置；&lt;/li&gt;
&lt;li&gt;监听滚动。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;于是先把移动端 TOC 的初始化彻底停掉，同时把 Pagefind、OverlayScrollbars、PhotoSwipe 等功能改成真正的按需加载。&lt;/p&gt;
&lt;p&gt;这一步对整体性能有帮助，但 &lt;strong&gt;没有解决 iOS 快速滚动时的大面积空白&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;2. &lt;code&gt;content-visibility&lt;/code&gt;、动画和 GPU 层&lt;/h3&gt;
&lt;p&gt;接着检查了文章正文中的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;content-visibility&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;contain&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;will-change&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;translateZ(0)&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;入场动画；&lt;/li&gt;
&lt;li&gt;导航栏 &lt;code&gt;backdrop-filter&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些东西都可能让 WebKit 建立更多独立绘制层。&lt;/p&gt;
&lt;p&gt;逐步简化以后，问题依旧存在。有一版甚至因为强行让整篇超长正文始终参与绘制，体感反而更严重。&lt;/p&gt;
&lt;p&gt;这时可以基本确认：&lt;strong&gt;不是“内容没加载”，而更像“内容太重，Safari 来不及画”。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;3. 超高圆角卡片与 &lt;code&gt;overflow:hidden&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;文章页原先存在多层：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#swup-container
└── overflow-hidden
    └── 文章外框
        └── overflow-hidden + border-radius
            └── #post-container.card-base
                └── overflow-hidden + border-radius
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对一个几万像素高的长页面来说，这种层层裁剪看起来也很可疑。&lt;/p&gt;
&lt;p&gt;于是做了一版测试：移动端只取消这些长容器的裁剪，其他全部不动。&lt;/p&gt;
&lt;p&gt;结果：&lt;strong&gt;没有改善。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;又排除一个方向。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;真正的突破口：把代码块全部隐藏后，居然不卡了&lt;/h2&gt;
&lt;p&gt;这篇文章有一个很突出的特点：代码非常多，而且有一个接近 200 行的 Go 代码块。&lt;/p&gt;
&lt;p&gt;代码块使用 Expressive Code 渲染。视觉效果很好，但它并不是简单的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;pre&amp;gt;&amp;lt;code&amp;gt;...&amp;lt;/code&amp;gt;&amp;lt;/pre&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而是会生成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;frame；&lt;/li&gt;
&lt;li&gt;行容器；&lt;/li&gt;
&lt;li&gt;行号；&lt;/li&gt;
&lt;li&gt;每一行里的大量语法 token &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;高亮、标记、复制按钮等附加结构。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一段接近 200 行的代码，最后可能变成几千个 DOM 节点。&lt;/p&gt;
&lt;p&gt;为了验证它是不是核心因素，做了一个最极端的测试：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;手机端直接把所有代码块 &lt;code&gt;display:none&lt;/code&gt;。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;结果非常明显：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原来那种快速滑动后整屏空白、停下再补绘的现象基本消失。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这一步是整个排障过程里最关键的证据。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;第二轮 A/B：到底是代码滚动容器，还是语法高亮 DOM？&lt;/h2&gt;
&lt;p&gt;知道“代码块有问题”还不够，因为代码块同时包含两个容易影响 Safari 的因素：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;复杂的 Expressive Code DOM；&lt;/li&gt;
&lt;li&gt;代码块内部的 &lt;code&gt;overflow:auto&lt;/code&gt; 嵌套滚动。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;实验一：保留高亮，只取消代码块内部滚动&lt;/h3&gt;
&lt;p&gt;把代码重新显示出来，保留完整语法高亮，但在手机端取消 480px 限高和内部滚动，让代码全部展开。&lt;/p&gt;
&lt;p&gt;结果：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;空白问题重新出现。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;所以嵌套滚动不是主因。&lt;/p&gt;
&lt;h3&gt;实验二：把 Expressive Code 扁平化成普通 &lt;code&gt;&amp;lt;pre&amp;gt;&amp;lt;code&amp;gt;&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;接下来把复杂 DOM 全部移除，只留下普通代码文本。&lt;/p&gt;
&lt;p&gt;结果：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;不卡了。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;但视觉效果明显下降，而且第一次实现时还把行号一起读进了代码文本，出现了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;188
}
189

190
func main() {
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种“行号和代码被拆成两行”的奇怪效果。&lt;/p&gt;
&lt;p&gt;后来虽然修正了文本提取，但普通代码块依然不好看。&lt;/p&gt;
&lt;h3&gt;实验三：保留 Expressive Code 外壳，只删除 token span&lt;/h3&gt;
&lt;p&gt;又尝试了一个更保守的方案：顶部栏、行号、frame 都保留，只把每行内部最重的 token 节点清掉。&lt;/p&gt;
&lt;p&gt;结果依然会复现卡顿。&lt;/p&gt;
&lt;p&gt;到这里，方向已经很清楚了：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;对 iOS Safari 来说，长正文里常驻多个 Expressive Code 组件本身就是一个足够强的绘制压力源。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;并不一定只有 200 行的代码才有问题，多个 7 行、11 行、17 行的小代码块叠加，同样会增加 WebKit 的 paint / raster 压力。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;最终方案：手机端“代码卡片 + 全屏代码阅读器”&lt;/h2&gt;
&lt;p&gt;既然真正的问题不是代码内容，而是“复杂代码组件长期常驻在几万像素高的正文页面里”，最终就没有必要继续在 CSS 上硬扛。&lt;/p&gt;
&lt;p&gt;更合理的思路是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;正文负责阅读，代码按需查看。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;最终移动端分成两档。&lt;/p&gt;
&lt;h3&gt;短代码：转成轻量代码框&lt;/h3&gt;
&lt;p&gt;8 行以内的命令或配置仍然直接展示，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;systemctl daemon-reload
systemctl enable --now image-uploader
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但正文中的 Expressive Code live DOM 会被移除，替换成一个轻量代码框，只保留必要的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;深色背景；&lt;/li&gt;
&lt;li&gt;语言标签；&lt;/li&gt;
&lt;li&gt;复制按钮；&lt;/li&gt;
&lt;li&gt;普通代码文本。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;长代码：变成代码卡片&lt;/h3&gt;
&lt;p&gt;超过 8 行的代码，不再直接塞进正文，而是显示成轻量卡片：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌──────────────────────────────┐
│ &amp;lt;/&amp;gt;  GO · 198 行             │
│                              │
│ package main                 │
│ import (                     │
│     ...                      │
│                              │
│                   查看代码 → │
└──────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;卡片本身只有极少量 DOM，对长文章几乎没有额外压力。&lt;/p&gt;
&lt;p&gt;点击“查看代码”后，再进入独立的全屏代码阅读器。&lt;/p&gt;
&lt;h3&gt;全屏阅读器按需加载&lt;/h3&gt;
&lt;p&gt;完整语法高亮并没有被放弃。&lt;/p&gt;
&lt;p&gt;它只是从“常驻正文”改成：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;用户点击代码卡片；&lt;/li&gt;
&lt;li&gt;临时创建全屏阅读器；&lt;/li&gt;
&lt;li&gt;插入完整的高亮代码；&lt;/li&gt;
&lt;li&gt;阅读器自己负责横向、纵向滚动；&lt;/li&gt;
&lt;li&gt;关闭后销毁重型 DOM；&lt;/li&gt;
&lt;li&gt;回到正文原来的滚动位置。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这就把最关键的问题拆开了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;以前：
长正文 + 多个完整 Expressive Code + 页面滚动

现在：
长正文 + 轻量代码卡片
        ↓ 点击时
独立全屏代码阅读器 + 单个完整代码块
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最终实机测试中，正文快速上下甩动已经不再出现之前那种大面积空白。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;全屏阅读器又踩了两个小坑&lt;/h2&gt;
&lt;p&gt;正文流畅以后，全屏代码查看器还经历了两次修正。&lt;/p&gt;
&lt;h3&gt;1. 行号和代码错位&lt;/h3&gt;
&lt;p&gt;最初直接搬运 Expressive Code 的结构，阅读器里形成了两层滚动容器，行号和代码布局也被破坏。&lt;/p&gt;
&lt;p&gt;表现为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1
package main
2
import (
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同时手指上下滑时只要稍微带一点横向动作，代码就会整体偏过去。&lt;/p&gt;
&lt;p&gt;最终改成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;阅读器正文只有 &lt;strong&gt;一个滚动层&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;每行固定为“行号 gutter + code”同行布局；&lt;/li&gt;
&lt;li&gt;横向和纵向都由同一个容器负责。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2. 全屏代码没有颜色&lt;/h3&gt;
&lt;p&gt;后来又发现全屏阅读器虽然结构正常，但部分代码看起来像纯白文本。&lt;/p&gt;
&lt;p&gt;原因之一是 Expressive Code / Shiki 的 token 颜色通过 CSS 变量保存，代码被搬到原 Markdown 树以外以后，对应主题规则没有可靠命中。&lt;/p&gt;
&lt;p&gt;于是给全屏阅读器作用域重新恢复 token 的主题颜色变量。&lt;/p&gt;
&lt;p&gt;另外，文章里有一段 &lt;code&gt;caddyfile&lt;/code&gt;，当前高亮器并不直接识别这个语言标识，因此又给它增加了一个近似的 Shiki alias，映射到 &lt;code&gt;nginx&lt;/code&gt; 语法规则。&lt;/p&gt;
&lt;p&gt;它不可能 100% 理解 Caddyfile 语义，但至少不会整段退化为纯文本。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;另一个完全独立的问题：桌面端为什么点一次页面却闪两下？&lt;/h2&gt;
&lt;p&gt;移动端问题解决后，又发现电脑版导航还有一个很怪的现象：&lt;/p&gt;
&lt;p&gt;点击一次“传送门”或文章，页面会像刷新两遍一样闪烁。&lt;/p&gt;
&lt;p&gt;最开始怀疑是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Swup 自己有一次淡出 / 淡入；&lt;/li&gt;
&lt;li&gt;页面还有一套 &lt;code&gt;onload-animation&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;两层动画叠加造成“双闪”。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以先把视觉动画全部关掉。&lt;/p&gt;
&lt;p&gt;结果：&lt;strong&gt;还是闪。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这时录屏里的地址栏给出了关键线索。&lt;/p&gt;
&lt;p&gt;它不是单纯视觉闪烁，而是真的出现了类似：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/portal/
   ↓
/
   ↓
/portal/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说，一次点击里 URL 真正发生了多次历史记录 / 页面导航变化。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Swup 的真正坑：配置了两个容器，但其中一个并不总存在&lt;/h2&gt;
&lt;p&gt;检查 Astro 配置后发现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;swup({
  containers: [&quot;main&quot;, &quot;#toc&quot;],
  // ...
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说，每次 Swup 导航都要求同时替换：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;main&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;#toc&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但页面布局原先是这样写的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{siteConfig.toc.enable &amp;amp;&amp;amp; headings.length &amp;gt; 0 &amp;amp;&amp;amp; (
  &amp;lt;div&amp;gt;
    ...
    &amp;lt;div id=&quot;toc&quot;&amp;gt;
      &amp;lt;TOC headings={headings} /&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
)}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;问题就出在这里：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;只有文章有 headings 时，&lt;code&gt;#toc&lt;/code&gt; 才存在。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;首页、传送门、归档等页面可能完全没有 &lt;code&gt;#toc&lt;/code&gt; 节点，但 Swup 全局配置又明确要求它是一个需要替换的容器。&lt;/p&gt;
&lt;p&gt;于是不同页面之间的 Swup 容器结构并不一致。&lt;/p&gt;
&lt;p&gt;这也比“CSS 动画”更能解释为什么地址栏会发生真实的目标页 → 根目录 → 目标页跳转。&lt;/p&gt;
&lt;h3&gt;最终修复&lt;/h3&gt;
&lt;p&gt;办法非常简单：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;所有页面始终保留 &lt;code&gt;#toc&lt;/code&gt; 节点。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;没有目录时，不删除这个容器，只让它保持空内容：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div id=&quot;toc&quot; class=&quot;w-full h-full transition-swup-fade&quot;&amp;gt;
  {siteConfig.toc.enable &amp;amp;&amp;amp; headings.length &amp;gt; 0 &amp;amp;&amp;amp; (
    &amp;lt;TOC headings={headings} /&amp;gt;
  )}
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样对 Swup 来说，每个页面的容器结构始终一致：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;main   永远存在
#toc   永远存在
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;实机再次测试后，桌面端的双闪和 URL 二次跳转消失。&lt;/p&gt;
&lt;p&gt;这个坑其实很值得记住：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;用了 Swup / PJAX 这类局部页面替换方案以后，被声明为 container 的节点，最好在所有参与导航的页面中保持稳定存在。不要让它跟着业务内容条件渲染掉。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;顺手做掉的几个优化&lt;/h2&gt;
&lt;p&gt;这次排障过程中还顺便清理了几处长期隐患。&lt;/p&gt;
&lt;h3&gt;头像不再打包 2MB+ PNG&lt;/h3&gt;
&lt;p&gt;原来的头像 PNG 超过 2MB，对一个静态博客来说完全没有必要。&lt;/p&gt;
&lt;p&gt;现在直接改为使用独立图床上的 WebP：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://img.bijiy.com/2026/09/img-1789278943991983319.webp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样既减小仓库与构建产物，也让头像走现有图床/CDN 链路。&lt;/p&gt;
&lt;h3&gt;搜索、图片查看器、滚动条都按需加载&lt;/h3&gt;
&lt;p&gt;Pagefind、PhotoSwipe、OverlayScrollbars 这类功能都不是首屏必须项，因此尽量改成真正的 lazy load，而不是“CSS 隐藏但 JavaScript 已经全部跑起来”。&lt;/p&gt;
&lt;p&gt;这不一定是本次 iOS bug 的根因，但对博客整体首屏和移动端资源占用都更合理。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;这次最有价值的不是某一行代码，而是排障方法&lt;/h2&gt;
&lt;p&gt;回头看，这个问题其实很容易被误判。&lt;/p&gt;
&lt;p&gt;“滚动后内容空白”会让人自然想到：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;懒加载；&lt;/li&gt;
&lt;li&gt;IntersectionObserver；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;content-visibility&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;DOM 被隐藏；&lt;/li&gt;
&lt;li&gt;网络资源没加载完。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但真正的问题却是：&lt;strong&gt;内容早就在 DOM 里了，只是 iOS WebKit 来不及把它画出来。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果没有做那次“把所有代码块完全隐藏”的极端 A/B，很可能还会继续在外围 CSS 上浪费很多时间。&lt;/p&gt;
&lt;p&gt;这次留下几个以后可以直接复用的原则：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;先确认是加载问题，还是绘制问题。&lt;/strong&gt; DOM 已存在但画不出来，与懒加载完全是两条排查路线。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;一次只改一个变量。&lt;/strong&gt; 否则“修好了”也不知道为什么好了。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;极端 A/B 很有价值。&lt;/strong&gt; 怀疑代码块，就先全部 &lt;code&gt;display:none&lt;/code&gt;；怀疑高亮，就退化成纯文本。先证明相关性，再谈优雅方案。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;移动端不必机械复制桌面交互。&lt;/strong&gt; 200 行代码在 6 英寸屏幕上直接展开，本来就未必是最佳体验。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PJAX/Swup 的容器必须结构稳定。&lt;/strong&gt; 条件渲染一个被声明为替换目标的容器，很容易制造难以解释的历史记录与重载问题。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;性能优化最终最好转化成产品设计。&lt;/strong&gt; “代码卡片 + 全屏阅读器”不是降级，而是比原方案更适合手机的阅读方式。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最开始只是想解决一个 iPhone 滚动卡顿，最后反而把博客的移动端代码阅读方式重新设计了一遍。&lt;/p&gt;
&lt;p&gt;这大概也是折腾独立博客最有意思的地方：问题看起来总是从一个小地方开始，最后逼着你把整个链路真正弄明白。&lt;/p&gt;
</content:encoded></item><item><title>从零打造极简高可用博客图床：海外大盘VPS + Go原生轻量后端 + EdgeOne边缘加速实践</title><link>https://rqly.com/blog/posts/2026-09-13-%E4%BB%8E%E9%9B%B6%E6%89%93%E9%80%A0%E6%9E%81%E7%AE%80%E9%AB%98%E5%8F%AF%E7%94%A8%E5%8D%9A%E5%AE%A2%E5%9B%BE%E5%BA%8A%E6%B5%B7%E5%A4%96%E5%A4%A7%E7%9B%98vps-go%E5%8E%9F%E7%94%9F%E8%BD%BB%E9%87%8F%E5%90%8E%E7%AB%AF-edgeone%E8%BE%B9%E7%BC%98%E5%8A%A0%E9%80%9F%E5%AE%9E%E8%B7%B5/</link><guid isPermaLink="true">https://rqly.com/blog/posts/2026-09-13-%E4%BB%8E%E9%9B%B6%E6%89%93%E9%80%A0%E6%9E%81%E7%AE%80%E9%AB%98%E5%8F%AF%E7%94%A8%E5%8D%9A%E5%AE%A2%E5%9B%BE%E5%BA%8A%E6%B5%B7%E5%A4%96%E5%A4%A7%E7%9B%98vps-go%E5%8E%9F%E7%94%9F%E8%BD%BB%E9%87%8F%E5%90%8E%E7%AB%AF-edgeone%E8%BE%B9%E7%BC%98%E5%8A%A0%E9%80%9F%E5%AE%9E%E8%B7%B5/</guid><description>记录下如何利用手头闲置的海外Dedirock石头盘VPS，结合不到100行Go原生代码、Caddy反代与腾讯云EdgeOne边缘CDN，搭建一套内存仅占8MB、支持本地WebP转码、跨设备历史管理及博客后台无感粘贴的私有图床方案。</description><pubDate>Sun, 13 Sep 2026 07:21:00 GMT</pubDate><content:encoded>&lt;h2&gt;折腾的出发点&lt;/h2&gt;
&lt;p&gt;在运营基于 Astro / Hugo 等静态框架的独立博客时，图床选型始终是绕不开的话题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;公共图床/第三方免费托管&lt;/strong&gt;：随时面临防盗链、被限流甚至跑路的风险；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;商业对象存储（如 COS/OSS）+ 国内 CDN&lt;/strong&gt;：按量计费虽然安全，但一旦遭遇恶意刷刷流量，容易产生高额账单；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Git 仓库内嵌媒体文件&lt;/strong&gt;：随时间推移仓库体积快速膨胀，Clone 和 CI/CD 部署构建极度缓慢。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;手头有一台闲置的大容量海外 VPS（1.5T 硬盘，Debian 12），既然硬盘空间充裕，将其改造成私有图床源站是极具性价比的选择。然而海外主机直连境内延迟高、且网络波动频繁，要达到商用级别的体验，必须解决三个关键需求：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;资源极简&lt;/strong&gt;：尽量避免引入重型面板或全家桶图床程序，内存占用控制在 10MB 以内；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;边缘加速与回源保护&lt;/strong&gt;：借助国内边缘加速节点接管大流量，海外源站仅作为物理存储底层；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;闭环体验&lt;/strong&gt;：支持前端自动 WebP 压缩、跨设备管理历史图片，并在博客后台（Decap CMS）实现像 Notion 一样截屏按 &lt;code&gt;Ctrl + V&lt;/code&gt; 即可无感直传。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;本文完整记录这一架构的搭建细节、踩坑经验与落地代码。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;架构设计概览&lt;/h2&gt;
&lt;p&gt;整体架构链路非常清晰：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[ 写作端 / Decap CMS ]
        │ (Ctrl+V 触发前端 Canvas 转码 WebP)
        ▼
[ EdgeOne 边缘加速 (Anycast) ]
   ├── 动态接口 (/upload, /list, /delete) ──(直接穿透 / 0缓存)──► [ 海外 VPS: Caddy:80 ]
   └── 静态图片 (*.webp, *.png, ...)   ──(强缓存 30 天 / 拦截)──► [ 边缘节点直接响应 ]
                                                                        │ (仅首次回源)
                                                                        ▼
                                                                 [ Go 原生服务 :3008 ]
                                                                        │ (磁盘读写)
                                                                        ▼
                                                                 [ /data/images ]
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;客户端（浏览器）&lt;/strong&gt;：拦截上传前先通过 HTML5 Canvas 转码为 WebP，原图几 MB 瞬间压至数十 KB；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;EdgeOne CDN&lt;/strong&gt;：国内各省 Anycast 节点覆盖。针对上传与查询接口做 &lt;strong&gt;Bypass（不缓存）&lt;/strong&gt;，针对静态图片配置 &lt;strong&gt;30天节点强缓存&lt;/strong&gt; 与 &lt;strong&gt;忽略 Query 参数&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;反向代理（Caddy）&lt;/strong&gt;：接收 EdgeOne 的 HTTP 回源，将动态接口反代至后端，将静态资源交由自身高性能静态引擎托管；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;后端服务（Go 编译二进制）&lt;/strong&gt;：仅使用 Go 原生标准库实现，无任何第三方框架，常驻内存仅 &lt;strong&gt;~8MB&lt;/strong&gt;，负责鉴权、写盘、按时间倒序扫盘及防越权删除。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;一、 后端实现：原生 Go 极简轻量服务&lt;/h2&gt;
&lt;p&gt;不需要 Gin、Echo 等大型框架，Go 的 &lt;code&gt;net/http&lt;/code&gt;、&lt;code&gt;os&lt;/code&gt;、&lt;code&gt;filepath&lt;/code&gt; 原生库足以支撑高并发的文件读写。&lt;/p&gt;
&lt;h3&gt;1. 源码编写与接口逻辑&lt;/h3&gt;
&lt;p&gt;在 VPS 上创建目录 &lt;code&gt;/opt/image-uploader&lt;/code&gt;，编写 &lt;code&gt;main.go&lt;/code&gt;。核心接口包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/upload&lt;/code&gt;：校验 Header 中的 &lt;code&gt;X-Upload-Token&lt;/code&gt;，以年月（&lt;code&gt;2026/09&lt;/code&gt;）分目录落盘；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/list&lt;/code&gt;：校验 Token 后扫描存储目录，提取最新上传的文件列表返回前端；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/delete&lt;/code&gt;：校验 Token 并进行严格的路径清洗（防止 &lt;code&gt;../&lt;/code&gt; 目录穿越攻击），物理删除指定文件。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;package main

import (
	&quot;encoding/json&quot;
	&quot;fmt&quot;
	&quot;io&quot;
	&quot;net/http&quot;
	&quot;net/url&quot;
	&quot;os&quot;
	&quot;path/filepath&quot;
	&quot;sort&quot;
	&quot;strings&quot;
	&quot;time&quot;
)

// 配置项
const UploadSecret = &quot;YOUR_CUSTOM_SECRET_KEY&quot; // 替换为你的私有密钥
const StoragePath = &quot;/data/images&quot;
const PublicDomain = &quot;https://img.example.com&quot;

type Response struct {
	Success bool     `json:&quot;success&quot;`
	URL     string   `json:&quot;url,omitempty&quot;`
	Images  []string `json:&quot;images,omitempty&quot;`
	Error   string   `json:&quot;error,omitempty&quot;`
}

type DeleteRequest struct {
	URL string `json:&quot;url&quot;`
}

// 跨域处理与身份校验
func checkAuth(w http.ResponseWriter, r *http.Request) bool {
	w.Header().Set(&quot;Access-Control-Allow-Origin&quot;, &quot;*&quot;)
	w.Header().Set(&quot;Access-Control-Allow-Methods&quot;, &quot;GET, POST, OPTIONS&quot;)
	w.Header().Set(&quot;Access-Control-Allow-Headers&quot;, &quot;Content-Type, X-Upload-Token&quot;)

	if r.Method == http.MethodOptions {
		w.WriteHeader(http.StatusOK)
		return false
	}

	w.Header().Set(&quot;Content-Type&quot;, &quot;application/json&quot;)
	if r.Header.Get(&quot;X-Upload-Token&quot;) != UploadSecret {
		w.WriteHeader(http.StatusUnauthorized)
		json.NewEncoder(w).Encode(Response{Success: false, Error: &quot;Unauthorized&quot;})
		return false
	}
	return true
}

func uploadHandler(w http.ResponseWriter, r *http.Request) {
	if !checkAuth(w, r) {
		return
	}
	if r.Method != http.MethodPost {
		json.NewEncoder(w).Encode(Response{Success: false, Error: &quot;Method not allowed&quot;})
		return
	}

	// 限制请求体上限 30MB
	r.Body = http.MaxBytesReader(w, r.Body, 30&amp;lt;&amp;lt;20)

	now := time.Now()
	dateDir := now.Format(&quot;2006/01&quot;)
	fullDir := filepath.Join(StoragePath, dateDir)
	if err := os.MkdirAll(fullDir, 0755); err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		json.NewEncoder(w).Encode(Response{Success: false, Error: &quot;Failed to create directory&quot;})
		return
	}

	ext := &quot;.webp&quot;
	contentType := r.Header.Get(&quot;Content-Type&quot;)
	if contentType == &quot;image/png&quot; {
		ext = &quot;.png&quot;
	} else if contentType == &quot;image/jpeg&quot; {
		ext = &quot;.jpg&quot;
	} else if contentType == &quot;image/gif&quot; {
		ext = &quot;.gif&quot;
	}

	fileName := fmt.Sprintf(&quot;img-%d%s&quot;, now.UnixNano(), ext)
	dstPath := filepath.Join(fullDir, fileName)

	dst, err := os.Create(dstPath)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		json.NewEncoder(w).Encode(Response{Success: false, Error: &quot;Failed to save file&quot;})
		return
	}
	defer dst.Close()

	if _, err := io.Copy(dst, r.Body); err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		json.NewEncoder(w).Encode(Response{Success: false, Error: &quot;Failed to write content&quot;})
		return
	}

	publicURL := fmt.Sprintf(&quot;%s/%s/%s&quot;, PublicDomain, dateDir, fileName)
	json.NewEncoder(w).Encode(Response{Success: true, URL: publicURL})
}

func listHandler(w http.ResponseWriter, r *http.Request) {
	if !checkAuth(w, r) {
		return
	}

	type fileItem struct {
		url     string
		modTime time.Time
	}
	var items []fileItem

	_ = filepath.Walk(StoragePath, func(path string, info os.FileInfo, err error) error {
		if err != nil || info.IsDir() {
			return nil
		}
		ext := strings.ToLower(filepath.Ext(path))
		if ext == &quot;.webp&quot; || ext == &quot;.png&quot; || ext == &quot;.jpg&quot; || ext == &quot;.jpeg&quot; || ext == &quot;.gif&quot; {
			rel, err := filepath.Rel(StoragePath, path)
			if err == nil {
				items = append(items, fileItem{
					url:     fmt.Sprintf(&quot;%s/%s&quot;, PublicDomain, filepath.ToSlash(rel)),
					modTime: info.ModTime(),
				})
			}
		}
		return nil
	})

	sort.Slice(items, func(i, j int) bool {
		return items[i].modTime.After(items[j].modTime)
	})

	var result []string
	limit := 80
	for i, it := range items {
		if i &amp;gt;= limit {
			break
		}
		result = append(result, it.url)
	}

	json.NewEncoder(w).Encode(Response{Success: true, Images: result})
}

func deleteHandler(w http.ResponseWriter, r *http.Request) {
	if !checkAuth(w, r) {
		return
	}

	var req DeleteRequest
	if err := json.NewDecoder(r.Body).Decode(&amp;amp;req); err != nil || req.URL == &quot;&quot; {
		w.WriteHeader(http.StatusBadRequest)
		json.NewEncoder(w).Encode(Response{Success: false, Error: &quot;Invalid payload&quot;})
		return
	}

	u, err := url.Parse(req.URL)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		json.NewEncoder(w).Encode(Response{Success: false, Error: &quot;Invalid URL&quot;})
		return
	}

	cleanPath := filepath.Clean(strings.TrimPrefix(u.Path, &quot;/&quot;))
	if strings.Contains(cleanPath, &quot;..&quot;) || cleanPath == &quot;index.html&quot; || cleanPath == &quot;&quot; {
		w.WriteHeader(http.StatusForbidden)
		json.NewEncoder(w).Encode(Response{Success: false, Error: &quot;Forbidden target&quot;})
		return
	}

	targetFile := filepath.Join(StoragePath, cleanPath)
	if !strings.HasPrefix(targetFile, StoragePath) {
		w.WriteHeader(http.StatusForbidden)
		json.NewEncoder(w).Encode(Response{Success: false, Error: &quot;Path traversal denied&quot;})
		return
	}

	if err := os.Remove(targetFile); err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		json.NewEncoder(w).Encode(Response{Success: false, Error: &quot;File deletion failed&quot;})
		return
	}

	json.NewEncoder(w).Encode(Response{Success: true})
}

func main() {
	http.HandleFunc(&quot;/upload&quot;, uploadHandler)
	http.HandleFunc(&quot;/list&quot;, listHandler)
	http.HandleFunc(&quot;/delete&quot;, deleteHandler)

	// 绑定内网避开常见端口
	fmt.Println(&quot;Image Uploader running at 127.0.0.1:3008&quot;)
	_ = http.ListenAndServe(&quot;127.0.0.1:3008&quot;, nil)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. 编译与 Systemd 守护进程托管&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cd /opt/image-uploader
go build -o /opt/image-uploader/uploader main.go

# 创建 systemd 服务
cat &amp;lt;&amp;lt; &apos;EOF&apos; &amp;gt; /etc/systemd/system/image-uploader.service
[Unit]
Description=Lightweight Image Uploader Service
After=network.target

[Service]
Type=simple
User=root
WorkingDirectory=/opt/image-uploader
ExecStart=/opt/image-uploader/uploader
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now image-uploader
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;二、 Caddy 网关与防重定向死循环配置&lt;/h2&gt;
&lt;p&gt;在 CDN 加速架构中，一个极为经典的踩坑点是：&lt;strong&gt;CDN 到源站走 HTTP (80 端口)，而源站 Caddy 默认开启全自动 HTTPS 跳转（308），导致浏览器触发 &lt;code&gt;ERR_TOO_MANY_REDIRECTS&lt;/code&gt; 死循环&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 &lt;code&gt;/etc/caddy/Caddyfile&lt;/code&gt; 中显式指定 &lt;code&gt;http://&lt;/code&gt;，告知 Caddy 信任前置 CDN 代理，不再自动发起重定向：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://img.example.com {
    # 1. 动态接口转发给 Go 本地后端
    @api path /upload /list /delete
    handle @api {
        reverse_proxy 127.0.0.1:3008
    }

    # 2. 静态图片直接由 Caddy 高性能响应，根目录访问工作台
    handle {
        root * /data/images
        file_server {
            index index.html
        }
        @images path *.webp *.png *.jpg *.jpeg *.gif
        header @images Cache-Control &quot;public, max-age=2592000&quot;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;执行 &lt;code&gt;systemctl reload caddy&lt;/code&gt; 重载即可。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;三、 EdgeOne 边缘规则引擎配置核心&lt;/h2&gt;
&lt;p&gt;EdgeOne（或任意国内具备规则引擎的 CDN）的配置核心是两点：&lt;strong&gt;动态穿透&lt;/strong&gt; 与 &lt;strong&gt;静态锁死&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;1. 源站配置&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;源站类型&lt;/strong&gt;：IP&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;回源协议&lt;/strong&gt;：HTTP（80 端口）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;回源 Host&lt;/strong&gt;：&lt;code&gt;img.example.com&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;前端客户端&lt;/strong&gt;：在 EdgeOne 后台申请免费证书并开启 &lt;strong&gt;强制 HTTPS&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2. 规则引擎编排（自上而下按顺序匹配）&lt;/h3&gt;
&lt;h4&gt;分支一：动态接口不缓存（&lt;code&gt;IF₁&lt;/code&gt;）&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;匹配条件&lt;/strong&gt;：&lt;code&gt;URL path&lt;/code&gt; 正则匹配 &lt;code&gt;^/($|index\.html|upload|list|delete)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;操作行为&lt;/strong&gt;：
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;节点缓存 TTL&lt;/code&gt;：&lt;strong&gt;不缓存（Bypass）&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;em&gt;效果&lt;/em&gt;：保证控制面板、上传、拉取列表与删除操作每次都能直通 VPS，绝不发生数据脏读。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;分支二：静态媒体资源强缓存（&lt;code&gt;IF₂&lt;/code&gt;）&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;匹配条件&lt;/strong&gt;：&lt;code&gt;文件后缀&lt;/code&gt; 正则匹配 &lt;code&gt;\.(webp|png|jpe?g|gif|svg)$&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;操作行为&lt;/strong&gt;：
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;节点缓存 TTL&lt;/code&gt;：&lt;strong&gt;自定义 30 天&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;强制缓存&lt;/code&gt;：&lt;strong&gt;开启&lt;/strong&gt;（忽略源站可能返回的私有头，全网节点直接固化）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;自定义 Cache Key&lt;/code&gt;：查询字符串（Query String）选择 &lt;strong&gt;全部忽略&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;em&gt;效果&lt;/em&gt;：图片仅在初次请求时回源海外 VPS 一次，随后 30 天内全部由境内边缘 Anycast 节点直接向读者分发，毫秒级加载。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;四、 客户端体验优化：前端 WebP 压缩与无缝接入&lt;/h2&gt;
&lt;h3&gt;1. 为什么在前端（浏览器）做图片压缩？&lt;/h3&gt;
&lt;p&gt;传统方案往往由后端 Go/Python 借助 libvips 或 imagemagick 转码，这会给 1H1.5G 的廉价 VPS 带来巨大的 CPU 峰值压力。
我们在前端通过 &lt;strong&gt;HTML5 Canvas&lt;/strong&gt; 实现无感转码：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;访客在粘贴或拖入截图瞬间，浏览器利用本地客户端算力直接完成 1920px 限制缩放与 85% 质量 WebP 编码；&lt;/li&gt;
&lt;li&gt;原本 3~5MB 的高清 PNG 截图瞬间压缩为 100~300KB，传输带宽与 VPS 存储占用下降 90% 以上。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2. 博客管理端（Decap CMS）无感粘贴与凭证脱敏&lt;/h3&gt;
&lt;p&gt;为了彻底避免将 Secret 密钥推送到公开/私有仓库的源码历史中，采用 &lt;strong&gt;&lt;code&gt;localStorage&lt;/code&gt; 懒加载记忆方案&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 从本地安全读取，首访自动弹窗询问并持久化
function getUploadSecret() {
  let secret = localStorage.getItem(&apos;site_image_token&apos;);
  if (!secret) {
    secret = prompt(&apos;【首次配置】请输入专属图床 Token（仅持久化保存在当前浏览器）：&apos;);
    if (secret &amp;amp;&amp;amp; secret.trim()) {
      secret = secret.trim();
      localStorage.setItem(&apos;site_image_token&apos;, secret);
    } else {
      return null;
    }
  }
  return secret;
}

// 监听编辑区内粘贴
window.addEventListener(&apos;paste&apos;, async (e) =&amp;gt; {
  const items = (e.clipboardData || e.originalEvent.clipboardData).items;
  let imageFile = null;
  for (const item of items) {
    if (item.type.indexOf(&apos;image&apos;) !== -1) {
      imageFile = item.getAsFile();
      break;
    }
  }

  if (imageFile) {
    e.preventDefault();
    const imgUrl = await uploadImageDirect(imageFile);
    if (imgUrl) {
      insertMarkdownAtCursor(`\n![](${imgUrl})\n`);
    }
  }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;五、 运维与安全思考小结&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;权限隔离&lt;/strong&gt;：虽然最终产出的图片外链是公网可读的（符合公开博客传播的本质需求），但通过 &lt;code&gt;X-Upload-Token&lt;/code&gt; 与服务端绝对路径判断，彻底封死了未授权写盘与目录遍历；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CDN 缓存特性与删除机制&lt;/strong&gt;：当在图床后台执行物理删除后，源站磁盘空间已即时释放。如果个别文件需要立即在全网失效，只需在 CDN 控制台提交单条“清除缓存”即可；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;极轻量常驻&lt;/strong&gt;：整套系统没有数据库依赖、无需 Redis，依靠原生文件系统结构自组织索引，不仅能在廉价配置的 VPS 上稳定自愈运行，还能兼作跨设备的通用贴图工具。&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item></channel></rss>