
		<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
			<channel>
				<title>XFffff&#39;s Blog - 记录生活和技术</title>
				<link>https://blog.the37777777.top/blog</link>
				<description>记录生活和技术的个人空间</description>
				<language>en</language>
				<managingEditor>1127251096@qq.com (XFffff)</managingEditor>
				<webMaster>1127251096@qq.com (XFffff)</webMaster>
				<lastBuildDate>Fri, 14 Aug 2026 00:00:00 GMT</lastBuildDate>
				<atom:link href="https://blog.the37777777.top/feed.xml" rel="self" type="application/rss+xml"/>
				
		<item>
			<guid>https://blog.the37777777.top/blog/202608/vibe-coding-triple-projects</guid>
			<title>一周三个项目：织词、游戏大厅与阅海 —— 一次 vibe coding 的实践心得</title>
			<link>https://blog.the37777777.top/blog/202608/vibe-coding-triple-projects</link>
			<description>八月中旬的一周里，我同时推进了三个项目：织词（桌面提示词工作台）、游戏大厅（P2P 多人游戏平台）、阅海（NAS 图书馆应用）。这篇不聊功能清单，只讲过程中的心得体会和踩过的坑，以及 vibe coding 这种开发方式的真实边界。</description>
			<content:encoded><![CDATA[<h2>为什么要写这篇</h2>
<p>七月底那篇 <a href="/blog/summer-dev-review">2026 开发复盘</a> 写的是「半年五个项目」的长线回顾。而这周的情况很不一样：<strong>三个项目几乎同时开工、同时推进、同时收尾</strong>，其中两个还涉及我之前完全没碰过的领域。</p>
<p>这篇只讲三件事：做了什么（一句话）、遇到了什么问题、以及 vibe coding 到底靠不靠谱。不想写成功能清单，很多细节也没必要展开。</p>
<hr>
<h2>三个项目，三句话</h2>
<table>
<thead>
<tr>
<th>项目</th>
<th>是什么</th>
<th>主要技术</th>
</tr>
</thead>
<tbody><tr>
<td><strong>织词 PromptWeaver</strong></td>
<td>Windows 桌面提示词工作台</td>
<td>Electron、React、zustand</td>
</tr>
<tr>
<td><strong>游戏大厅 Game Hall</strong></td>
<td>私有 P2P 多人游戏平台</td>
<td>Next.js、WebRTC、WebSocket</td>
</tr>
<tr>
<td><strong>阅海 Yuehai</strong></td>
<td>飞牛 NAS 图书馆应用</td>
<td>Kavita fork、fnOS 打包</td>
</tr>
</tbody></table>
<p>三个项目几乎没有重叠的技术栈：一个桌面端、一个实时联机、一个 NAS 生态。这也是我刻意为之——想看看 vibe coding 在不同领域的表现。</p>
<hr>
<h2>一、织词：桌面端的性能与数据</h2>
<p>桌面应用的第一个体会：<strong>本地数据越多，越考验工程能力</strong>。角色、标签、翻译映射、别名……数据量上来之后，从「能跑」到「不卡」之间隔着一次重构。</p>
<p>遇到的问题：</p>
<ul>
<li><strong>状态管理失控</strong>：功能越加越多，一个主组件膨胀到几百行、七八个 <code>useState</code>，改一处崩三处。最后老老实实重构，拆成多个 store 才稳住。</li>
<li><strong>渲染性能</strong>：列表数据大之后，滚动明显卡顿，不得不引入虚拟滚动。</li>
<li><strong>中文搜索远比想象复杂</strong>：汉字、拼音、首字母、别名、同义词，每个都要命中；还遇到过某条全量查询耗时 20 秒的超时问题，最后靠给匹配逻辑加门控条件解决。</li>
<li><strong>中英双语的一致性</strong>：输出是中英双份的，两边经常对不上。后面做了不少数据审计工作，才把差集清零。</li>
</ul>
<p>心得：<strong>数据质量工程（data engineering）的坑，不比写代码少</strong>。vibe coding 能很快写出功能，但「数据对不对、全不全、一致不一致」这件事，AI 帮不了多少，得自己一遍遍核对。</p>
<hr>
<h2>二、游戏大厅：P2P 联网是深水区</h2>
<p>这个项目的定位很简单：朋友之间通过邀请码注册，建立房间，然后双方设备<strong>点对点直连</strong>对局，服务器只做控制面。</p>
<p>遇到的问题：</p>
<ul>
<li><strong>库与环境的版本暗坑</strong>：密码哈希库在 CI 的 Node 版本下直接报「不是函数」，查了半天是版本兼容问题，升级运行时才解决。</li>
<li><strong>白屏问题</strong>：为安全加了严格的内容安全策略（CSP），结果某些环境下一打开就是白屏，排查下来是安全策略和浏览器扩展冲突，最后做了权衡调整。</li>
<li><strong>P2P 连不上</strong>：不同运营商、不同 NAT 环境下，直连经常失败。只能做成「局域网直连 → 点对点 → 中继兜底」三级策略，再加网络检测向导让用户自己看链路质量。</li>
<li><strong>平台限制</strong>：部署平台对 WebSocket 连接时长有硬限制，不得不设计短连接 + 退避重连 + 消息重放的方案。</li>
<li><strong>测试不稳定</strong>：端到端测试经常时好时坏，同一套用例在本地全过、CI 上随机挂，花了不少时间稳定它们。</li>
</ul>
<p>心得：<strong>联网和安全的坑，vibe coding 只能帮你走到一半</strong>。AI 能写出 WebRTC 握手、能写出认证流程，但「为什么这个环境下连不上」「为什么 CI 上测不过」这种需要实机环境反复验证的问题，最后还是得靠人一点点试。</p>
<hr>
<h2>三、阅海：改一个大项目的勇气</h2>
<p>这个项目是给 NAS 做的图书馆应用，基于开源的阅读器 fork 改造。这是三个项目里**最「改别人的代码」**的一个。</p>
<p>遇到的问题：</p>
<ul>
<li><strong>gitignore 误吞</strong>：一次配置失误，把源码文件整个从版本控制里吞掉了，差点丢文件，花了不少力气恢复。</li>
<li><strong>安全漏洞</strong>：fork 上游的代码里存在认证相关的漏洞，逐项审计修复，顺手把沙箱和内容安全也补上了。</li>
<li><strong>上游版本管理</strong>：fork 了多个开源项目，版本固定、许可证分层、上游更新跟踪，全是琐碎但必须做对的事。</li>
<li><strong>NAS 集成</strong>：安装向导、权限、数据卷、生命周期脚本，和普通 Web 应用完全不是一个思路。</li>
</ul>
<p>心得：<strong>改别人的大项目，最大的瓶颈是上下文</strong>。项目太大，AI 的上下文窗口根本装不下整个代码库，经常「改了这个忘了那个」。只能把改动拆得很碎，一次只动一个点，每步都靠测试兜底。</p>
<hr>
<h2>vibe coding 到底有什么问题</h2>
<p>三个项目跑下来，说说我的真实感受：</p>
<p><strong>1. 功能写得快，问题出在后面。</strong> 一周能堆出别人一个月的功能量，但随之而来的性能、数据一致性、边界情况，AI 不会替你想到。</p>
<p><strong>2. 测试是唯一的救命稻草。</strong> 没有测试，vibe coding 就是给未来的自己埋雷。三个项目都是测试越写越重，到后期几乎改一个点就要跑一遍全量回归。</p>
<p><strong>3. 上下文是硬天花板。</strong> 单文件、单模块 AI 很强；整个项目级别的「全局视野」，AI 给不了，得靠人拆分和设计。</p>
<p><strong>4. 安全和隐私不能外包。</strong> 代码生成得再快，认证、权限、密钥管理这些还是要自己过一遍，不能假设 AI 写的就是安全的。</p>
<p><strong>5. 数据比代码更费神。</strong> 尤其涉及大量结构化数据时，AI 写转换代码很利索，但数据本身的正确性，只能靠审计。</p>
<hr>
<h2>最后</h2>
<p>这周最大的收获不是三个项目本身，而是摸清了 vibe coding 的边界：<strong>它把「从零到能跑」的时间压缩到极限，但「从能跑到能上线」的每一步，依然要靠自己的判断力</strong>。</p>
<p>工具不会替你踩坑，只会让你踩坑的速度更快。想清楚这一点，vibe coding 就是很好的工具；想不清楚，它只是加速翻车的引擎。</p>
]]></content:encoded>
			<pubDate>Fri, 14 Aug 2026 00:00:00 GMT</pubDate>
			<author>1127251096@qq.com (XFffff)</author>
			<category>成长</category><category>复盘</category><category>vibe coding</category><category>全栈</category><category>Electron</category><category>Next.js</category><category>NAS</category>
      <enclosure url="https://blog.the37777777.top/static/images/covers/my-blog-journey.webp" length="0" type="image/webp" />
		</item>
	
		<item>
			<guid>https://blog.the37777777.top/blog/202608/opencode-an-zhuang-ji-you-hua-jiao-cheng-2</guid>
			<title>OpenCode安装及优化教程</title>
			<link>https://blog.the37777777.top/blog/202608/opencode-an-zhuang-ji-you-hua-jiao-cheng-2</link>
			
			<content:encoded><![CDATA[<h2>1. 安装</h2>
<p>OpenCode 官网：<a href="https://opencode.ai/">https://opencode.ai/</a></p>
<p>OpenCode GitHub 仓库：<a href="https://github.com/anomalyco/opencode">https://github.com/anomalyco/opencode</a></p>
<p>选择对应操作系统版本进行安装，具体步骤参考官方文档</p>
<h2>2. 配置 API Key</h2>
<ol>
<li>打开设置</li>
<li>找到「提供商」，然后在提供商这里选择你拥有的对应的 API 进行连接；如果没有可以用自定义提供商，注意<strong>自定义提供商</strong>支持的是 <strong>OpenAI 兼容格式</strong></li>
<li>等待连接成功提示，即完成绑定就可以用了</li>
</ol>
<h2>3. 优化</h2>
<h3>3.1 安装插件</h3>
<p>推荐安装 oh-my-openagent</p>
<p>GitHub 仓库：<a href="https://github.com/code-yeongyu/oh-my-openagent">https://github.com/code-yeongyu/oh-my-openagent</a></p>
<p>复制下面的文档提示词给 AI，让 AI 安装即可（例如：「请帮我安装 oh-my-openagent」）</p>
<h3>3.2 MCP 服务器</h3>
<p>安装 oh-my-openagent 以后会自带几个 MCP 服务器，这边推荐几个好用的：</p>
<ul>
<li>playwright</li>
<li>chrome-devtools</li>
<li>github</li>
</ul>
<h2>4. 按需配置 oh-my-openagent agent 使用模型</h2>
<p>探索可以启用<strong>速度快的</strong>，调研可以使用<strong>有原生搜索能力的</strong>，然后规划可以用<strong>性能强的</strong>，像审查呀那些的也推荐是<strong>性能强的</strong>，一般的写入用<strong>正常的模型</strong>即可，不需要特别强，看需求写入即可。</p>
<Callout type="tip" title="提示">
  输入 `ulw` 可以启用 Ultra Work 模式，使用体验会更好
</Callout>]]></content:encoded>
			<pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
			<author>1127251096@qq.com (XFffff)</author>
			<category>OpenCode</category>
      
		</item>
	
		<item>
			<guid>https://blog.the37777777.top/blog/202607/summer-dev-review</guid>
			<title>2026 开发复盘：博客、TimeMark、游戏、知屋与光子遥控</title>
			<link>https://blog.the37777777.top/blog/202607/summer-dev-review</link>
			<description>从二月博客上线到八月光子遥控首推，把半年多里做过的五个项目、写过的代码、踩过的坑，以及从「能用框架」到「敢造轮子」再到「敢啃协议底层」的变化，尽量诚实地写下来。</description>
			<content:encoded><![CDATA[<h2>为什么要写这篇</h2>
<p>2 月博客上线的时候，我写过一篇 <a href="/blog/my-blog-journey">从零到一：博客搭建之旅</a>。那篇文章讲的是「怎么把站搭起来」。</p>
<p>半年多过去了，我又做了 TimeMark、两款 Web 游戏、知屋阅读书房，还有 8 月初刚推出的<strong>光子遥控</strong>——一个自研 14 种红外协议编码器的安卓 App。博客本身也从 v1.0 迭代到 v2.2。如果只用一句话概括这段时间，大概是：</p>
<p><strong>从「跟着教程和框架走」，慢慢变成「敢自己设计架构、敢砍过度设计、敢为真实场景做取舍、敢啃底层协议」。</strong></p>
<p>这篇不是某一次升级的 changelog，而是把<strong>所有项目串成一条线</strong>的复盘。技术细节会写（而且这次附上真实代码），但更重要的是：每个项目为什么做、做错了什么、学到了什么。</p>
<hr>
<h2>时间线：六个阶段</h2>
<p>回看 git 记录和 Changelog，这大半年大致可以分成六段：</p>
<table>
<thead>
<tr>
<th>阶段</th>
<th>时间</th>
<th>主线</th>
<th>关键词</th>
</tr>
</thead>
<tbody><tr>
<td>① 建站</td>
<td>2 月</td>
<td>个人博客 v1.0</td>
<td>Next.js、Cloudflare Pages、Contentlayer</td>
</tr>
<tr>
<td>② 打磨</td>
<td>4 月</td>
<td>液态玻璃 UI + TimeMark 立项</td>
<td>设计系统、Docker 初体验</td>
</tr>
<tr>
<td>③ 扩张</td>
<td>5 月</td>
<td>博客 v2.0 + Web 游戏 + TimeMark v2</td>
<td>Pagefind、Vanilla JS、单容器重构</td>
</tr>
<tr>
<td>④ 节点</td>
<td>6 月</td>
<td>高考</td>
<td>项目暂停，Moment 记录</td>
</tr>
<tr>
<td>⑤ 工程化</td>
<td>7 月</td>
<td>博客 v2.1 + 知屋 1.0</td>
<td>构建管线、安全审查、全栈书房</td>
</tr>
<tr>
<td>⑥ 新篇</td>
<td>8 月初</td>
<td>光子遥控 + 博客 v2.2</td>
<td>Kotlin、Compose、红外协议、安全加固</td>
</tr>
</tbody></table>
<p>下面按项目和阶段展开。</p>
<hr>
<h2>一、个人博客：从 v1.0 到 v2.2</h2>
<p>博客是我所有项目的「大本营」——文章在这里发，其他项目的开发过程也在这里记录。</p>
<h3>v1.0：先跑起来（2 月）</h3>
<p>技术栈很早就定了：<strong>Next.js 15 + Contentlayer2 + Tailwind + Cloudflare Pages 静态导出</strong>。</p>
<p>选静态导出是有意为之：没有服务端，部署简单，攻击面小。评论用 Giscus（GitHub Discussions），搜索先用 kbar 匹配标题。</p>
<p>v1.0 解决的是「有没有」的问题。深色模式、RSS、Sitemap、基础安全头（CSP、HSTS）都在这一版落地。</p>
<p>那会儿我对前端几乎一无所知，是照着 <a href="https://github.com/mk965/mengke.me">mengke.me</a> 这个开源项目一步步改造的。Fork → 换文案 → 换配色 → 加功能，就像在别人的地基上盖自己的房子。后来我才慢慢意识到，<strong>「会改别人的代码」和「会设计自己的架构」之间，隔着半年时间的距离</strong>。</p>
<h3>v1.1–v1.2：功能补全 + 液态玻璃（4 月）</h3>
<p>4 月是博客改动最密集的一个月之一，commit 数量能到几十条。主要方向：</p>
<p><strong>交互与阅读</strong></p>
<ul>
<li>阅读进度条、增强 TOC（移动端抽屉）</li>
<li>相关文章评分算法、系列导航（上一篇/下一篇/进度）</li>
<li>键盘快捷键帮助面板、Changelog 页、友链申请表</li>
<li>Service Worker 离线支持、构建期 OG 图生成</li>
</ul>
<p><strong>液态玻璃设计系统</strong></p>
<p>全站 UI 从手写 glass 样式迁移到统一的 <code>liquid-glass</code> 四层体系。核心是 CSS 变量 + <code>backdrop-filter</code>，不依赖任何第三方库：</p>
<pre><code class="language-css">:root {
  --lg-blur: 20px; /* 标准层级模糊度 */
  --lg-blur-elevated: 30px; /* 导航栏 / 模态框 */
  --lg-blur-subtle: 12px; /* 次要区块 */
  --lg-saturate: 180%;
  --lg-bg: rgba(255, 255, 255, 0.45);
}

.dark {
  --lg-bg: rgba(255, 255, 255, 0.04); /* 深色模式自动切换 */
}

.liquid-glass {
  backdrop-filter: blur(var(--lg-blur)) saturate(var(--lg-saturate));
  background: var(--lg-bg);
  transition: background 0.3s ease;
}
</code></pre>
<p>四个层级对应不同场景：<code>liquid-glass</code>（卡片/列表）、<code>liquid-glass-elevated</code>（导航栏/模态框）、<code>liquid-glass-subtle</code>（次要区块）、<code>liquid-glass-input</code>（表单，带 focus 光晕）。深浅色模式靠 <code>.dark</code> 下的变量覆盖，一套 CSS 两套皮肤。</p>
<p>还做了滑动 Pill 导航指示器（spring easing 药丸动画），以及中文标签 404 的 Cloudflare Pages 路由兼容。</p>
<p><strong>项目结构规范化</strong></p>
<p><code>css/</code> → <code>styles/</code>，JSON 数据迁入 <code>data/</code>，图片目录统一为 <code>covers/</code>、<code>banners/</code>、<code>posts/</code>。写了 <code>CONTENT_GUIDE.md</code>，以后发文章有章可循。</p>
<p>这个月我最深的体会是：<strong>好看的 UI 不是玄学，是 CSS 变量的分层管理</strong>。把颜色、模糊度、透明度抽象成变量之后，「换皮肤」就只是改几个数字的事。</p>
<h3>v1.3：SEO、About 与视觉（5 月初）</h3>
<ul>
<li>AI 爬虫权限（GPTBot、ClaudeBot 等）与 <code>llms.txt</code> 自动生成</li>
<li>About 页重设计：Profile 卡片 3D tilt、流动背景动画</li>
<li>一言（Hitokoto）迁入 Footer，30 秒自动刷新</li>
<li>Moments 数据源统一为单一 <code>moments.json</code></li>
</ul>
<h3>v2.0：从「能用」到「好用」（5 月底）</h3>
<p>这是博客迄今最大的一次功能爆发，<strong>50+ 项改进、70+ 文件、9 次集中 commit</strong>。详细过程见 <a href="/blog/blog-v2-optimization">博客大升级 v2.0</a>，这里只列骨架：</p>
<table>
<thead>
<tr>
<th>方向</th>
<th>代表能力</th>
</tr>
</thead>
<tbody><tr>
<td>MDX 组件库</td>
<td>Callout、Tabs、Steps、Accordion 等 15 个</td>
</tr>
<tr>
<td>搜索</td>
<td>Pagefind 构建期全文索引，支持中文</td>
</tr>
<tr>
<td>阅读</td>
<td>字体档位、专注模式、位置记忆、TTS、难度标签</td>
</tr>
<tr>
<td>分享</td>
<td>微信/QQ/微博等 7 渠道 + 选中文字分享</td>
</tr>
<tr>
<td>视觉</td>
<td>View Transitions、终端 404、暗色模式过渡动画</td>
</tr>
<tr>
<td>移动</td>
<td>滑动手势切文、PWA 安装提示、J/K 键盘导航</td>
</tr>
</tbody></table>
<p>v2.0 的教训也很明确：<strong>不要过度工程化</strong>（曾想用本地 AI 做语义搜索，最后 Pagefind 够用）；<strong>Sandpack 在线运行代码块</strong>因加载不稳定被移除；<strong>trusted-devices.json 误提交</strong>敲了一次安全警钟。</p>
<h3>v2.1：好维护、好审计（7 月）</h3>
<p>高考后的暑假，博客进入「工程化」阶段——不大改 UI，而是让构建更快、边界更清晰：</p>
<p><strong>构建期 Mermaid</strong></p>
<p>文章里的图表在 Contentlayer 编译时用 JSDOM + Mermaid 预渲染成 SVG，亮/暗双主题各一份，DOMPurify 清理输出。读者侧零 JS 渲染、零闪烁。失败则回退客户端组件，不阻断发布。</p>
<p><strong>构建并发</strong></p>
<p><code>run-with-concurrency.ts</code> 按 CPU 核心数限制图片优化与 OG 生成的并行度（2–6 路），避免 <code>Promise.all</code> 打满内存。</p>
<p><strong>构建前安全自检</strong></p>
<p>新增 <code>scripts/security-audit.ts</code>，在每次构建前自动跑一遍。核心逻辑很朴素——就是检查清单 + 正则扫描：</p>
<pre><code class="language-typescript">// scripts/security-audit.ts（节选）
const required = [
  &#39;Content-Security-Policy&#39;,
  &#39;Strict-Transport-Security&#39;,
  &#39;X-Frame-Options&#39;,
  &#39;X-Content-Type-Options&#39;,
  &#39;Referrer-Policy&#39;,
  &#39;Permissions-Policy&#39;,
]
for (const h of required) {
  if (headers.includes(h)) pass(h)
  else fail(`缺少 ${h}`)
}

// 扫描源码里的硬编码密钥模式
const secretPatterns = [
  /ghp_[A-Za-z0-9]{36}/, // GitHub PAT
  /sk-[A-Za-z0-9]{20,}/, // OpenAI key
  /AKIA[A-Z0-9]{16}/, // AWS key
]
</code></pre>
<p>检查项包括：<code>_headers</code> 是否含全套安全头、工作区是否存在 <code>.env</code> 等敏感文件、源码中是否出现密钥模式、Service Worker 是否只缓存同源 GET。有错误就 <code>process.exit(1)</code> 阻断构建——把「安全」从自觉变成强制。</p>
<p><strong>内容与元数据</strong></p>
<ul>
<li>全文 RSS（而不只是摘要）</li>
<li><code>schema-dts</code> 生成类型安全的 <code>BlogPosting</code> JSON-LD</li>
<li><code>remark-smartypants</code> 排版插件</li>
</ul>
<p><strong>又踩过的坑</strong></p>
<ul>
<li>kbar 打开搜索时导航 Pill 位移：根因是滚动条被隐藏导致布局宽度变化。最终解法是 <code>ResizeObserver</code> 监听容器尺寸重算指示器位置：</li>
</ul>
<pre><code class="language-typescript">// components/header/index.tsx（节选）
const updateIndicator = () =&gt; {
  const active = containerRef.current?.querySelector(&#39;[data-active]&#39;)
  if (active) {
    indicator.style.width = `${active.offsetWidth}px`
    indicator.style.transform = `translateX(${active.offsetLeft}px)`
  }
}

const observer = new ResizeObserver(() =&gt; updateIndicator())
observer.observe(containerRef.current)
</code></pre>
<ul>
<li><code>Image</code> 组件误删 <code>overflow-hidden</code>：Logo 和 Profile 卡片圆角变方，已恢复。</li>
</ul>
<h3>v2.2：安全加固 + 新功能（8 月初）</h3>
<p>做完光子遥控之后，我回头给博客做了一轮「年度审计」——用 AI 帮我把整个站点的安全、依赖、性能、SEO 从头过了一遍，一次提交 55 个文件的改动。这大概是<strong>第一次用「审计」而不是「加功能」的视角</strong>来对待自己的项目：</p>
<p><strong>安全加固</strong></p>
<ul>
<li>供应链 CVE 修复：通过 <code>pnpm-workspace.yaml</code> 的 overrides 把 postcss、sharp、picomatch、ws 强制升级到安全版本，<code>pnpm audit --prod</code> 高危清零</li>
<li>CSP 大收紧：<code>img-src</code> 限制为 <code>&#39;self&#39; data: blob: https:</code>，<code>frame-src</code> 只保留视频平台白名单并补上 vimeo.com，新增 <code>frame-ancestors &#39;none&#39;</code>（防点击劫持），HSTS 加 <code>preload</code></li>
<li>JSON-LD 里的 <code>&lt;/script&gt;</code> 转义（防止标题注入破坏页面结构）</li>
<li>pagefind 搜索摘要加 DOMPurify 净化（防存储型 XSS）</li>
<li>Service Worker 缓存策略升级：缓存名 bump 到 v2，HTML 请求加 <code>response.ok</code> 校验（防缓存 404 页面）</li>
<li><code>search.json</code> 精简：把 filePath、structuredData 等冗余字段从输出剥离，避免暴露内部路径</li>
<li>CodeQL workflow 的 GitHub Action 全部 pin 到完整 SHA（供应链安全）</li>
<li>清理 <code>GITHUB_API_TOKEN</code> 环境变量（已弃用）和一堆死代码</li>
</ul>
<p><strong>性能</strong></p>
<ul>
<li>LCP 图片加 <code>fetchpriority=&quot;high&quot;</code>（首页头像、文章封面提前加载）</li>
<li>Giscus 评论区改为 IntersectionObserver 懒加载 + 点击按钮兜底（首屏不再加载评论 iframe）</li>
<li>文章正文加 <code>content-visibility: auto</code>（浏览器跳过屏外区块的渲染）</li>
<li>给实际用到的外部域名加 preconnect（giscus.app、hitokoto 等）</li>
</ul>
<p><strong>SEO</strong></p>
<ul>
<li>OG 图升级为多尺寸（1200×630 + 1:1 方形）</li>
<li>BlogPosting 结构化数据补 <code>wordCount</code>、<code>inLanguage</code>、<code>dateModified</code></li>
<li>About 页新增 ProfilePage JSON-LD（作者信息对搜索引擎更友好）</li>
</ul>
<p><strong>新功能</strong></p>
<ul>
<li><strong>Photos 照片墙页面</strong>：8 月新页面，支持 4 种媒体类型（静态照片 / Live Photo / 动图 / 视频），瀑布流 + lightbox + 长按播放，媒体直接打包进仓库</li>
<li><strong>搜索升级</strong>：kbar 集成 fuse.js 模糊搜索，中文错字、拼音都能命中（原来只能精确匹配标题）</li>
<li><strong>分享扩展</strong>：从 7 渠道扩到 32 平台（内嵌 17 个常用 + 更多面板 14 个）</li>
</ul>
<p><strong>依赖升级</strong></p>
<p>14 项依赖安全升级（react 19.2.8、next 15.5.22、prettier 3.9.6 等），全部逐组升级、每步构建回归，最后 <code>pnpm audit --prod</code> 0 high/critical。</p>
<p>这次审计让我意识到：<strong>项目做久了，「维护」本身就是一种能力</strong>。不加新功能、只做减法，反而让整个站点更健康。</p>
<hr>
<h2>二、TimeMark：第一个 Docker 全栈项目</h2>
<p>TimeMark 是智能事件提醒系统——农历生日、纪念日，多渠道通知（微信、QQ、钉钉、邮件等三十多种）。</p>
<p>完整开发故事见 <a href="/blog/timemark-docker">TimeMark：我的第一个 Docker 项目</a>。复盘里只提炼<strong>架构演进</strong>，因为这是半年里最重要的技术认知之一。</p>
<h3>v1.x：三容器的「豪华配置」</h3>
<p>第一版：PostgreSQL + Redis/Bull + 应用，<strong>三个容器、约 800MB 内存</strong>。</p>
<p>选型时觉得「专业」——正经数据库、业界标准队列。NAS 用户反馈很直接：低功耗机器上跑不动，一个提醒工具何必这么重。</p>
<h3>v2.0：大道至简</h3>
<p>两周重写后端：</p>
<table>
<thead>
<tr>
<th>项目</th>
<th>v1.x</th>
<th>v2.0</th>
</tr>
</thead>
<tbody><tr>
<td>数据库</td>
<td>PostgreSQL 容器</td>
<td>sql.js（单文件 SQLite）</td>
</tr>
<tr>
<td>任务调度</td>
<td>Redis + Bull</td>
<td>Croner（纯 JS cron）</td>
</tr>
<tr>
<td>部署</td>
<td>3 容器</td>
<td><strong>1 容器</strong></td>
</tr>
<tr>
<td>内存</td>
<td>~800MB</td>
<td><strong>~256MB</strong></td>
</tr>
</tbody></table>
<p>v2.1 又给所有环境变量加了内置默认值，<code>docker compose up -d</code> 后零配置即用——因为家庭内网场景下，「强迫用户生成随机密钥」本身就是设计错误。还有云平台版（Vercel + PostgreSQL + Serverless），给没有 NAS 的场景用。</p>
<h3>从 TimeMark 学到的</h3>
<ol>
<li><strong>先想清楚用户场景，再选技术</strong>——家庭用户要的是简单可靠，不是架构炫技</li>
<li><strong>monorepo + 共享类型</strong>——前后端共用 <code>Event</code> 等类型定义，改一处两边生效</li>
<li><strong>踩坑密度高</strong>——Docker Hub 用户名改六次、镜像名拼写、GHCR 路径……都是真实发布才会遇到的问题</li>
<li><strong>换数据库不是换驱动</strong>——PostgreSQL → SQLite 时 <code>TO_CHAR()</code>、布尔值、日期排序全都不一样，SQL「标准」在不同数据库里各有怪癖</li>
<li><strong>通知渠道用适配器模式</strong>——35+ 渠道统一抽象成一个接口，新增渠道只写一个 adapter，这个设计让我后来做红外协议编码器时受益很大</li>
</ol>
<hr>
<h2>三、Web 游戏：敢不用框架</h2>
<p>高考前不到一个月，我用 <strong>纯 Vanilla JavaScript</strong> 做了两款游戏，零依赖、零框架。详见 <a href="/blog/web-games-dev">从弹幕射击到经典扫雷</a>。</p>
<h3>星域战机（弹幕 STG）</h3>
<ul>
<li>纯 JS 约 <strong>11,500 行</strong>，含配置近两万行</li>
<li><strong>20 流派 × 200 技能 × 20 武器 × 40 道具</strong>，数据驱动</li>
<li>引擎与内容分离：新流派只改 <code>config.js</code>，不动引擎</li>
</ul>
<p>核心技术点：Canvas 渲染、<code>requestAnimationFrame</code> 游戏循环、<strong>对象池</strong>复用子弹/粒子、Web Audio API 音效。</p>
<p>对象池是弹幕游戏不卡顿的关键——屏幕上几百颗子弹飞，如果每颗都 <code>new</code>，GC 会疯掉。整个游戏运行中 <code>new</code> 只发生在最开始：</p>
<pre><code class="language-javascript">class ObjectPool {
  constructor(factory, initialSize) {
    this._items = []
    this._factory = factory
    for (var i = 0; i &lt; (initialSize || 0); i++) {
      this._items.push(factory())
    }
  }
  pop() {
    return this._items.length &gt; 0 ? this._items.pop() : this._factory()
  }
  push(obj) {
    this._items.push(obj)
  }
}
</code></pre>
<p>还有游戏循环的「死亡螺旋」——切标签页再切回来，浏览器暂停了 <code>requestAnimationFrame</code>，回来时 deltaTime 飙到几秒，所有物体瞬间飞出屏幕。解法是一行 <code>Math.min(rawDt, 50)</code>，超过 50ms 的帧一律按 50ms 算：游戏可能短暂变慢，但不会崩。</p>
<h3>经典扫雷</h3>
<ul>
<li>像素级还原 Windows XP/7 扫雷</li>
<li><strong>BFS 洪水填充</strong>展开空白区、无尽模式、成就、PWA 离线</li>
<li>三天完成，验证「小项目也能打磨细节」</li>
</ul>
<h3>为什么做游戏</h3>
<p>博客和 TimeMark 都站在框架肩膀上。我想证明：<strong>不依赖 React/Next，也能做复杂交互。</strong> 对象池、BFS、状态机——这些在框架里常被封装，自己写一遍才真正理解。后来做光子遥控的红外协议编码器时，这种「从底层自己造」的感觉又回来了——原来框架和库帮你藏起来的细节，才是真正值钱的知识。</p>
<hr>
<h2>四、知屋：从阅读器到 1.0 书房</h2>
<p>知屋是<strong>私人阅读与笔记</strong>项目，和博客的静态架构完全不同：</p>
<pre><code>静态页面（Astro） + Serverless API（Hono） + Redis 会话 + 对象存储 + Git LFS 书库
</code></pre>
<p>博客是「写出来给人看」；知屋是「用起来管自己的书与笔记」。<strong>闭源、需登录</strong>，不对外公开仓库与部署地址。</p>
<h3>7 月集中迭代做了什么</h3>
<p><strong>阅读与内容</strong></p>
<ul>
<li>EPUB/PDF 阅读、划线批注、三栏布局阅读器</li>
<li>从 PagePulse 迁移教材目录、FSRS 间隔复习、闪卡与词汇</li>
<li>书架按章节正文含量自动分级（书籍 vs 学习资料）</li>
<li>内置教材封面本地化、章节纲要逐节正文</li>
</ul>
<p><strong>AI</strong></p>
<ul>
<li>从本机 ONNX 逐步迁到<strong>云端流式代理</strong>（用户 Key 加密存 Redis，不进部署环境变量）</li>
<li>多会话对话 UI、停止/重试、提示词缓存</li>
<li>后期精简 Agent 提示词与权限边界</li>
</ul>
<p><strong>存储</strong></p>
<ul>
<li>Vercel Blob → Cloudflare R2 优先，统一 <code>cloud-store</code> 抽象层——换存储厂商只改一个实现，上层代码不动</li>
<li>私有书库 Vault（Git LFS）存 PDF/笔记/进度，与前端代码仓分离，无公开 raw 链接</li>
</ul>
<p><strong>安全（三轮加固）</strong></p>
<ul>
<li>localStorage 会话 → <strong>HttpOnly Cookie</strong></li>
<li>人机验证（Turnstile）、CORS 绑定正式域名</li>
<li>本地 security-probe 自检脚本</li>
<li>修复登录重复弹窗、OpenList 兼容性等</li>
</ul>
<p><strong>客户端</strong></p>
<ul>
<li>Android / Windows 原生壳（加载线上站点，WebView 行为与浏览器一致）</li>
</ul>
<p>知屋让我第一次系统面对<strong>有用户数据的后端安全</strong>：静态博客扫一遍 <code>_headers</code> 就够；全栈项目要管会话、存储、AI Key、跨域、探针，完全是另一个量级。三轮 <code>fix(security)</code> 才稳住，每一轮都是被真实问题（或者说，被「万一被别人搞」的想象）逼出来的。</p>
<hr>
<h2>五、光子遥控：第一个原生 Android 项目</h2>
<blockquote>
<p>完整代码：<a href="https://github.com/WXFffff666/photon-remote">github.com/WXFffff666/photon-remote</a></p>
</blockquote>
<p>这是 8 月初刚推出的项目，也是我的<strong>第一个原生 Android 应用</strong>——之前的所有项目都在 Web 技术栈里打转。</p>
<h3>为什么做</h3>
<p>家里遥控器太多了：电视一个、机顶盒一个、空调一个、风扇一个，每个都长得差不多，还总找不到。市面上万能遥控 App 不少，但要么广告满天飞，要么机顶盒运营商码库不全。于是决定自己写一个。</p>
<p><strong>光子遥控</strong>是一款安卓万能红外遥控器：把电视、机顶盒、空调、风扇等家电装进手机里，中国国内市场优先，完全离线可用。</p>
<h3>技术栈</h3>
<table>
<thead>
<tr>
<th>层面</th>
<th>技术</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>语言</td>
<td>Kotlin 2.1.20</td>
<td></td>
</tr>
<tr>
<td>UI</td>
<td>Jetpack Compose + Material 3</td>
<td>动态取色 / 深浅色 / 纯黑 AMOLED / 强调色</td>
</tr>
<tr>
<td>自适应</td>
<td>material3-adaptive-navigation-suite</td>
<td>手机 Compact 与平板 Expanded 双形态</td>
</tr>
<tr>
<td>数据库</td>
<td>Room（设备 / 按键 / 宏三张表）</td>
<td></td>
</tr>
<tr>
<td>配置存储</td>
<td>DataStore Preferences</td>
<td></td>
</tr>
<tr>
<td>序列化</td>
<td>kotlinx.serialization</td>
<td>按键动作 / 布局 / 备份</td>
</tr>
<tr>
<td>红外发射</td>
<td>ConsumerIrManager + USB + 音频转红外</td>
<td>三路自动路由</td>
</tr>
<tr>
<td>码库解码</td>
<td>IREXT（JNI 离线解码二进制码库）</td>
<td>MIT 协议</td>
</tr>
<tr>
<td>测试</td>
<td>JUnit 4 + Mockito + Robolectric</td>
<td><strong>211 个单测全通过</strong></td>
</tr>
</tbody></table>
<h3>14 种红外协议编码器</h3>
<p>红外遥控最底层的知识：每种协议定义了载波频率、前导码、位时序。比如最经典的 NEC 协议——38kHz 载波，前导 <code>9000/4500 µs</code>，逻辑 0 是 <code>562/562 µs</code>，逻辑 1 是 <code>562/1687 µs</code>，每字节 LSB 先发。</p>
<p>我把这 14 种协议（NEC 家族 / RC5 / RC6 / SONY / SAMSUNG / SHARP / JVC / KASEIKYO / PIONEER / RAW）全部自研实现，每个协议一个文件，接口统一：</p>
<pre><code class="language-kotlin">// ir/core/ProtocolType.kt
enum class ProtocolType {
    NEC, NECX1, NECX2, RC5, RC6, SONY12, SONY15, SONY20,
    SAMSUNG32, SHARP, JVC, KASEIKYO, PIONEER, RAW
}
</code></pre>
<p>以 NEC 编码器为例——输入一个 hex 码串，输出一串 mark/space 时间间隔（微秒）：</p>
<pre><code class="language-kotlin">// ir/protocol/NecEncoder.kt（节选）
object NecEncoder : IrProtocolEncoder {
    override val protocol: ProtocolType = ProtocolType.NEC
    override val repeatIntervalMs: Int = 110   // NEC 长按重复间隔

    const val FREQUENCY = 38000                // 38kHz 载波
    const val PRE_MARK = 9000                  // 前导 mark µs
    const val PRE_SPACE = 4500                 // 前导 space µs
    const val BIT_MARK = 562                   // 位 mark
    const val BIT0_SPACE = 562                 // 逻辑 0 的 space
    const val BIT1_SPACE = 1687                // 逻辑 1 的 space

    override fun encode(hex: String, press: PressKind): IRPattern {
        if (press == PressKind.REPEAT) {
            // NEC 短重复帧：9000/2250 + 562µs mark，与码值无关
            return IRPattern(FREQUENCY, intArrayOf(PRE_MARK, 2250, 562))
        }
        val normalized = hex.trim().uppercase().padStart(8, &#39;0&#39;)
        require(normalized.length &lt;= 8) { &quot;NEC 码最多 8 位十六进制: $hex&quot; }

        // 每字节 LSB 先发：0x00FF12ED → 4 字节，每字节低位在前
        val bytes = normalized.chunked(2).map { it.toInt(16) }
        val bits = bytes.flatMap { b -&gt; (0..7).map { (b shr it) and 1 } }

        val list = mutableListOf&lt;Int&gt;()
        list += PRE_MARK; list += PRE_SPACE
        for (b in bits) {
            list += BIT_MARK
            list += if (b == 0) BIT0_SPACE else BIT1_SPACE
        }
        list += 562                                  // 帧尾
        list += (108_800 - list.sum()).coerceAtLeast(0)  // 补零到整帧
        return IRPattern(FREQUENCY, list.toIntArray())
    }
}
</code></pre>
<p>这段代码看起来简单，但每个数字背后都是真实硬件的行为约束：<code>ConsumerIrManager.transmit</code> 会<strong>阻塞到整帧发完</strong>（NEC 补零后最长 108.8ms），所以绝不能在主线程调用；长按重复帧必须和完整帧区分开，否则长按音量键会卡死界面。</p>
<h3>单线程发射调度器</h3>
<p>宏、暴力找码、长按连发可能同时触发发送，而 IREXT 的 JNI 解码是共享原生状态的单例——并发调用会互相污染。所以所有红外发送和 IREXT 解码都走一个<strong>单线程队列</strong>：</p>
<pre><code class="language-kotlin">// ir/transmitter/IrDispatcher.kt（节选）
class IrDispatcher(
    private val transmitFn: suspend (IRPattern) -&gt; Boolean,
    private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
) {
    private val queue = Channel&lt;suspend () -&gt; Unit&gt;(Channel.UNLIMITED)

    init {
        scope.launch { for (task in queue) task() }   // 常驻消费协程，天然串行
    }

    suspend fun send(pattern: IRPattern): Boolean =
        onQueue { transmitFn(pattern) }

    suspend fun &lt;T&gt; onQueue(block: suspend () -&gt; T): T {
        val deferred = CompletableDeferred&lt;T&gt;()
        queue.send { deferred.completeWith(runCatching { block() }) }
        return deferred.await()
    }
}
</code></pre>
<p>原理很简单：一个 <code>Channel</code> 队列 + 一个常驻消费协程，所有任务进去后逐个执行，天然串行。<code>CompletableDeferred</code> 让调用方能拿到结果，<code>runCatching</code> 保证某个任务炸了不影响队列后续任务。</p>
<h3>机顶盒省市运营商三级筛选 + 离线码库</h3>
<p>中国市场的机顶盒码库按「省份 → 城市 → 运营商（移动/联通/电信/广电）」组织，再结合定位自动匹配。码库来自 IREXT 的开源实现（MIT 协议，JNI 离线解码二进制码库），加上精选的 irdb CSV，<strong>完全离线内置</strong>，飞行模式下也能用。</p>
<h3>其他亮点</h3>
<ul>
<li><strong>宏</strong>：把多个设备的多步按键串成一条宏（如「电视开机 → 调音量 → 机顶盒确定」），可调步间延迟、可中途停止</li>
<li><strong>暴力找码</strong>：协议 + hex 前缀约束，自动迭代发码，设备一响应就保存为按键</li>
<li><strong>导入导出</strong>：Flipper <code>.ir</code>、LIRC <code>.conf</code>、JSON 全量备份，逐条校验，坏记录跳过不中断</li>
<li><strong>无红外扩展</strong>：USB 红外外设（RLE 帧收发、热插拔重连）和音频转红外适配器（AudioTrack 192kHz 合成 38kHz 载波）</li>
<li><strong>211 个单元测试</strong>：协议波形（前导/位序/帧尾/总长）、数据层、调度器、序列化全覆盖</li>
</ul>
<h3>从光子遥控学到的</h3>
<ol>
<li><strong>「简单」的底层往往最难</strong>——一个 NEC 编码器 40 行，但它是被真实硬件约束逼出来的：阻塞时长、重复帧、补零，全是查协议文档 + 实机测试得来的</li>
<li><strong>共享原生状态要串行化</strong>——JNI 单例 + 并发调用 = 状态污染，一个 Channel 队列解决</li>
<li><strong>测试先行在协议层最值钱</strong>——波形是纯函数，输入输出确定，211 个单测里协议测试占了很大比重，改起来心里有底</li>
<li><strong>国产市场的坑</strong>——运营商机顶盒码库、省市筛选、定位匹配，这些「本地化」需求是海外开源项目永远不会替你考虑的</li>
</ol>
<hr>
<h2>六、其他项目（简表）</h2>
<table>
<thead>
<tr>
<th>项目</th>
<th>类型</th>
<th>技术要点</th>
</tr>
</thead>
<tbody><tr>
<td>LinkRoom</td>
<td>桌面工具</td>
<td>C#、EasyTier P2P 局域网联机</td>
</tr>
<tr>
<td>知屋 Vault</td>
<td>私有数据仓</td>
<td>Git LFS、manifest 索引，仅供知屋拉取</td>
</tr>
<tr>
<td>TimeMark 云平台版</td>
<td>Serverless</td>
<td>PostgreSQL、Cron Jobs，免 Docker</td>
</tr>
<tr>
<td>小米平板系统补丁模块</td>
<td>系统模块</td>
<td>Smali 反编译补丁，Magisk 生态</td>
</tr>
<tr>
<td>给设备编译驱动模块</td>
<td>系统模块</td>
<td>给自己的设备编译适配驱动（如 GPU），不是编译完整驱动</td>
</tr>
</tbody></table>
<p>这些在博客 Projects 页有卡片展示；闭源项目只写能力描述，不附仓库或线上链接。</p>
<p>顺带说一句：上面提到的「给设备编译驱动」，是<strong>给自己的设备编译适配</strong>——比如给我的小米平板编一个能用的 GPU 驱动模块，配合 Magisk 刷进去用。这不是从零编译完整驱动（我也没那个能力），而是在开源驱动源码基础上做配置、打补丁、适配到我的设备上。折腾设备这件事，本来就不是「从零造轮子」，而是<strong>把别人造好的轮子装到自己车上，并且让它转起来</strong>。</p>
<hr>
<h2>七、横向对比：我怎么做技术选型</h2>
<p>半年里五个主项目，架构差异很大，但选型逻辑有迹可循：</p>
<table>
<thead>
<tr>
<th>项目</th>
<th>渲染/部署</th>
<th>数据</th>
<th>为什么这样选</th>
</tr>
</thead>
<tbody><tr>
<td>博客</td>
<td>静态导出 + CDN</td>
<td>无（MDX 文件）</td>
<td>内容站、零运维、安全面小</td>
</tr>
<tr>
<td>TimeMark Docker</td>
<td>单容器</td>
<td>SQLite 文件</td>
<td>NAS 用户要备份简单、内存省</td>
</tr>
<tr>
<td>TimeMark 云</td>
<td>Serverless</td>
<td>PostgreSQL</td>
<td>无 NAS 也要能用</td>
</tr>
<tr>
<td>Web 游戏</td>
<td>纯静态 HTML/JS</td>
<td>无</td>
<td>练底层、零构建链</td>
</tr>
<tr>
<td>知屋</td>
<td>静态 + API</td>
<td>Redis + R2 + LFS</td>
<td>要登录、要同步、要大文件</td>
</tr>
<tr>
<td>光子遥控</td>
<td>原生 App</td>
<td>Room（本地）</td>
<td>要离线、要硬件能力（红外）</td>
</tr>
</tbody></table>
<p>一个越来越清晰的原则：<strong>用户是谁、数据存在哪、要不要登录</strong>——三个问题先答了，技术栈就不会偏太远。到了光子遥控，答案变成了：<strong>要不要碰硬件、要不要离线</strong>——要，那就原生 + 本地库 + 离线码库，别想用 Web 壳糊弄。</p>
<hr>
<h2>八、安全：三种完全不同的工作量</h2>
<h3>静态博客</h3>
<p>攻击面主要是 XSS（MDX、Giscus）、供应链依赖、CSP 配置。v2.1 加了构建前 <code>security-audit.ts</code>（代码见上文），v2.2 又做了一轮完整加固（CVE overrides、CSP 收紧、JSON-LD 转义、DOMPurify、sw.js 缓存策略），检查项包括：</p>
<ul>
<li><code>_headers</code> 是否含 CSP、HSTS、X-Frame-Options 等</li>
<li>工作区是否存在 <code>.env</code>、误提交的敏感文件</li>
<li>源码中是否出现 <code>ghp_</code>、<code>sk-</code> 等密钥模式</li>
<li>Service Worker 是否只缓存同源 GET</li>
<li><code>pnpm audit --prod</code> 是否 0 high</li>
</ul>
<p>已知权衡：Next.js 静态导出需要 CSP <code>unsafe-inline</code>；<code>img-src *</code> 方便文章外链图——都是可接受的残余风险。</p>
<h3>全栈应用（知屋）</h3>
<p>HttpOnly Cookie、Turnstile、CORS 收紧、AI Key 加密、R2 权限、安全探针脚本……<strong>三轮</strong> <code>fix(security)</code> 才稳住。有后端就必须按后端的标准做，不能套用静态站的检查清单。</p>
<h3>原生 App（光子遥控）</h3>
<p>又是另一种维度：<strong>开源协议合规</strong>。App 内置了 IREXT（MIT）、irdb（宽松许可）、Flipper IRDB（MIT）三套第三方码库，README 里逐一声明出处；<strong>明确排除了 AGPLv3 的 mi_remote_database</strong>——选型时就把许可不兼容的库挡在门外，比事后换库便宜得多。</p>
<hr>
<h2>九、踩坑合集（跨项目）</h2>
<p>把各项目里印象最深的坑放在一起，方便以后翻：</p>
<ol>
<li><strong>过度设计</strong> — TimeMark v1 三容器；博客曾想用本地 AI 搜索。解法：先 MVP，再按需加。</li>
<li><strong>像素级 UI bug</strong> — kbar 位移、圆角被 <code>overflow-hidden</code> 误删。解法：模态框 + 居中布局要测滚动条；改 UI 组件要看回归。</li>
<li><strong>发布细节</strong> — Docker 镜像名拼错、Hub 用户名多次调整。解法：CI 里固定镜像名，文档写清推送步骤。</li>
<li><strong>静态站 CSP</strong> — 想要严格 nonce，但没有服务端。解法：接受 <code>unsafe-inline</code> 或维护 hash 列表（成本高）。</li>
<li><strong>死代码拖延</strong> — Sandpack 移除 Run 按钮后依赖留了几个月。解法：功能砍掉就删依赖，别「以后可能还用」。</li>
<li><strong>安全文件误提交</strong> — trusted-devices.json。解法：<code>.gitignore</code> + 构建前扫描。</li>
<li><strong>JNI 状态污染</strong> — 光子遥控的 IREXT 是共享原生单例，并发解码会互相污染。解法：所有解码走单线程队列（IrDispatcher）。</li>
<li><strong>阻塞型硬件调用</strong> — <code>ConsumerIrManager.transmit</code> 会阻塞到整帧发完（最长 108.8ms）。解法：绝不在主线程调用，统一走后端调度器。</li>
<li><strong>供应链漏洞</strong> — 传递依赖（postcss、sharp 等）有 CVE。解法：pnpm overrides 强制升级，audit 常态化。</li>
<li><strong>缓存了 404</strong> — sw.js 缓存了失败响应，离线时页面错乱。解法：缓存前检查 <code>response.ok</code>。</li>
</ol>
<hr>
<h2>十、数据印象（不追求精确）</h2>
<table>
<thead>
<tr>
<th>维度</th>
<th>博客</th>
<th>TimeMark</th>
<th>游戏</th>
<th>知屋</th>
<th>光子遥控</th>
</tr>
</thead>
<tbody><tr>
<td>时间跨度</td>
<td>2 月–8 月持续</td>
<td>4 月–5 月主开发</td>
<td>5 月约两周</td>
<td>7 月集中爆发</td>
<td>8 月初（进行中）</td>
</tr>
<tr>
<td>主语言</td>
<td>TypeScript</td>
<td>TypeScript</td>
<td>JavaScript</td>
<td>TypeScript</td>
<td>Kotlin</td>
</tr>
<tr>
<td>体量感</td>
<td>100+ commit（半年）</td>
<td>全栈 + Docker</td>
<td>~1.5 万行 JS</td>
<td>全栈 + 存储</td>
<td>单测 211 个</td>
</tr>
<tr>
<td>开源</td>
<td>私有仓库</td>
<td>开源</td>
<td>开源</td>
<td>闭源</td>
<td>开源</td>
</tr>
</tbody></table>
<p>博客 Changelog 从 v1.0.0 记到 v2.2.0，光 5 月底 v2.0 单次升级就 50+ 项改进——这些数字说明的是<strong>迭代频率</strong>，不是炫耀代码行数。</p>
<hr>
<h2>十一、如果重来，我会怎么排期</h2>
<p>诚实说，有些顺序我会调整：</p>
<ol>
<li><strong>博客 v2.0 可以拆成两期</strong> — MDX 组件 + Pagefind 一期，分享/动画一期，避免一次改太多难以回归测试。</li>
<li><strong>TimeMark 应该更早做单容器</strong> — v1 的三容器架构本可跳过，直接听 NAS 用户场景。</li>
<li><strong>知屋 AI 路径应更早定云端</strong> — 本机 ONNX 在 Vercel 构建体积和 Edge 跟踪防护下走了很多弯路，最后仍迁到服务端代理。</li>
<li><strong>文档与博客同步写</strong> — TimeMark 和游戏的专文是事后补的；边做边写能少靠 git 回忆决策原因。</li>
<li><strong>安全审计应该定期做，而不是等大版本</strong> — v2.2 的 55 文件大改动如果拆成每月一次的小审计，风险会更小。</li>
</ol>
<p>反过来，<strong>不会改</strong>的选择：博客坚持静态导出、游戏坚持 Vanilla JS、TimeMark v2 砍 Redis/Postgres、知屋闭源且数据与代码分仓、光子遥控坚持自研协议编码器（而不是直接套现成库）——自己把 NEC 波形写一遍，才知道那些开源库帮你藏了多少细节。</p>
<hr>
<h2>十二、下半年想做什么</h2>
<p>不列具体地址，只列方向：</p>
<ul>
<li>博客：继续写文章，CSP 与依赖审计常态化，少堆新功能、多打磨阅读体验</li>
<li>知屋：1.0 后的文档与复习闭环，存储配额与 LFS 策略长期可维护</li>
<li>TimeMark：跟用户反馈迭代通知渠道与部署体验</li>
<li>游戏：高考后若有空，给扫雷加更多主题或给 STG 做平衡性调参</li>
<li>光子遥控：继续补全 UI 细节与更多设备码组，把离线码库的维护流程跑通</li>
<li>折腾设备：继续给我的设备编译适配驱动模块、玩 NAS，把「折腾」写进博客</li>
</ul>
<hr>
<h2>总结</h2>
<p>这半年多，我不是在「学一门技术」，而是在<strong>反复经历同一条曲线</strong>：</p>
<p>做一个东西 → 觉得不够好用或大改 → 踩坑 → 写文档或博文 → 下一个项目带着上次的教训</p>
<p>博客教会我前端工程化和设计系统；TimeMark 教会我<strong>别为用技术而用技术</strong>；游戏教会我<strong>框架之下还有什么</strong>；知屋教会我<strong>有数据就要有安全模型</strong>；光子遥控教会我<strong>最底层的协议里藏着最硬的知识</strong>——以及，选型时要先把开源协议的许可看清楚。</p>
<p>如果你也在读书、做作业之余做 side project，我的建议只有一句：<strong>做给自己用的东西，然后把踩坑写下来。</strong> 半年后回头看，会比只记得「做过某某项目」有用得多。</p>
<hr>
<p><em>更多分项细节可参阅本系列其它文章：<a href="/blog/my-blog-journey">博客搭建之旅</a>、<a href="/blog/blog-v2-optimization">博客 v2.0 升级</a>、<a href="/blog/timemark-docker">TimeMark Docker</a>、<a href="/blog/web-games-dev">Web 游戏开发手记</a>，以及站内 <a href="/changelog">Changelog</a>。</em></p>
]]></content:encoded>
			<pubDate>Sun, 02 Aug 2026 00:00:00 GMT</pubDate>
			<author>1127251096@qq.com (XFffff)</author>
			<category>技术</category><category>成长</category><category>Next.js</category><category>Docker</category><category>全栈</category><category>复盘</category><category>Kotlin</category><category>Android</category>
      <enclosure url="https://blog.the37777777.top/static/images/covers/my-blog-journey.webp" length="0" type="image/webp" />
		</item>
	
		<item>
			<guid>https://blog.the37777777.top/blog/202605/blog-v2-optimization</guid>
			<title>博客大升级 v2.0：从单调到丰富的全站优化之旅</title>
			<link>https://blog.the37777777.top/blog/202605/blog-v2-optimization</link>
			<description>记录博客 v2.0 全站增强的完整过程：15 个 MDX 组件、Pagefind 中文全文搜索、7 渠道社交分享、阅读体验升级等 50+ 项改进。</description>
			<content:encoded><![CDATA[<h2>写在前面</h2>
<p>距离上次写博客搭建文章已经过了几个月了。这段时间里，我一直在用这个博客写文章、记录生活，但总觉得少了点什么——功能太单调了，读者体验也不够好。</p>
<p>于是趁着周末，我决定给博客来一次大升级。没想到这一改就停不下来了，最后做了 <strong>50+ 项改进</strong>，涉及 <strong>70+ 个文件</strong>，提交了 <strong>9 次 commit</strong>。</p>
<p>这篇文章记录一下这次升级的过程，包括做了什么、怎么做的、踩了哪些坑。</p>
<h2>为什么要升级</h2>
<p>说实话，之前的博客虽然能用，但有几个问题一直困扰我：</p>
<ol>
<li><strong>功能单调</strong> — 除了写文章和评论，没有其他互动方式</li>
<li><strong>搜索不够好</strong> — kbar 搜索只能匹配标题，不能搜文章内容</li>
<li><strong>分享不方便</strong> — 想分享给朋友，只有几个国外平台的按钮</li>
<li><strong>阅读体验一般</strong> — 字体大小固定，没有专注模式</li>
<li><strong>MDX 组件太少</strong> — 写技术文章时想用 Tabs、Steps 这些组件，但没有</li>
<li><strong>有几个 Bug</strong> — 图片放大不了、一言不刷新、有测试数据残留</li>
</ol>
<p>所以这次的目标很明确：<strong>让博客从&quot;能用&quot;变成&quot;好用&quot;</strong>。</p>
<h2>这次做了什么</h2>
<h3>🐛 Bug 修复（4 项）</h3>
<ul>
<li>修复了 <code>trusted-devices.json</code> 被意外提交到 Git 的安全问题</li>
<li>实现了一言（Hitokoto）30 秒自动刷新 + 淡入淡出动画</li>
<li>清理了 Moments 里的测试数据</li>
<li>移除了代码块的 Run 按钮（Sandpack 加载太不稳定了）</li>
</ul>
<h3>📦 MDX 组件库（15 个新组件）</h3>
<p>这是这次升级最大的工作量。我一口气做了 15 个 MDX 组件：</p>
<table>
<thead>
<tr>
<th>组件</th>
<th>用途</th>
</tr>
</thead>
<tbody><tr>
<td>Callout</td>
<td>提示框（info/tip/warning/error/success）</td>
</tr>
<tr>
<td>Tabs</td>
<td>选项卡（多语言代码对比）</td>
</tr>
<tr>
<td>Steps</td>
<td>步骤指南（带时间线）</td>
</tr>
<tr>
<td>Accordion</td>
<td>折叠面板（FAQ）</td>
</tr>
<tr>
<td>ComparisonTable</td>
<td>对比表格（✅/❌）</td>
</tr>
<tr>
<td>Timeline</td>
<td>时间线</td>
</tr>
<tr>
<td>FileTree</td>
<td>文件目录树</td>
</tr>
<tr>
<td>Terminal</td>
<td>终端输出</td>
</tr>
<tr>
<td>Kbd</td>
<td>键盘按键</td>
</tr>
<tr>
<td>GitHubCard</td>
<td>GitHub 仓库卡片</td>
</tr>
<tr>
<td>BeforeAfter</td>
<td>图片对比滑块</td>
</tr>
<tr>
<td>Chart</td>
<td>SVG 图表</td>
</tr>
<tr>
<td>Video</td>
<td>B站/YouTube 嵌入</td>
</tr>
<tr>
<td>MathNumber</td>
<td>公式编号</td>
</tr>
</tbody></table>
<h3>🔍 Pagefind 全文搜索</h3>
<p>这是我最满意的功能之一。Pagefind 在构建时自动索引所有文章内容，支持中文分词，搜索速度极快。</p>
<h3>📖 阅读体验增强（6 项）</h3>
<ul>
<li>字体大小控制（S/M/L 三档）</li>
<li>专注阅读模式（隐藏所有干扰）</li>
<li>阅读位置记忆（下次打开恢复位置）</li>
<li>文章语音朗读（Web Speech API）</li>
<li>文章难度标签</li>
<li>归档时间线页面</li>
</ul>
<h3>🔗 社交分享（7 渠道）</h3>
<p>微信（二维码）、QQ、微博、X、Telegram、Reddit、复制链接。移动端优先使用系统原生分享。</p>
<h3>🎨 视觉与动画</h3>
<ul>
<li>页面切换动画（View Transitions API）</li>
<li>终端风格 404 页面（打字机效果）</li>
<li>暗色模式切换动画（圆形扩散）</li>
</ul>
<h3>📱 移动端优化</h3>
<ul>
<li>左右滑动切换文章</li>
<li>PWA 安装提示</li>
<li>下拉刷新动画</li>
</ul>
<h3>🔧 其他改进（15+ 项）</h3>
<p>键盘导航 J/K、拼音搜索、KaTeX 公式复制、Mermaid 图表放大、TOC 折叠记忆、文章卡片入场动画、代码块增强（行号/文件名）等等。</p>
<h2>技术亮点</h2>
<h3>Pagefind 全文搜索</h3>
<p>Pagefind 的集成非常简单。只需要在 <code>postbuild</code> 脚本中加一行：</p>
<pre><code class="language-bash">npx pagefind --site out --glob &quot;**/*.html&quot;
</code></pre>
<p>它会自动扫描所有 HTML 页面，生成搜索索引。在文章内容区域加上 <code>data-pagefind-body</code> 属性，就能精确控制索引范围：</p>
<pre><code class="language-tsx">&lt;div className=&quot;prose&quot; data-pagefind-body&gt;
  {children}
&lt;/div&gt;
</code></pre>
<p>搜索结果支持中文分词，虽然不支持词干提取（stemming），但对于博客搜索来说完全够用了。</p>
<h3>MDX 组件示例</h3>
<p>有了这些组件，写技术文章方便多了。比如：</p>
<pre><code class="language-mdx">&lt;Callout type=&quot;tip&quot; title=&quot;小贴士&quot;&gt;
  使用 `pnpm` 比 `npm` 快很多，而且节省磁盘空间。
&lt;/Callout&gt;

&lt;Tabs items={[&#39;pnpm&#39;, &#39;npm&#39;, &#39;yarn&#39;]}&gt;
  &lt;Tab value=&quot;pnpm&quot;&gt;pnpm install next&lt;/Tab&gt;
  &lt;Tab value=&quot;npm&quot;&gt;npm install next&lt;/Tab&gt;
  &lt;Tab value=&quot;yarn&quot;&gt;yarn add next&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Steps&gt;
  &lt;Step title=&quot;安装依赖&quot;&gt;运行 pnpm install&lt;/Step&gt;
  &lt;Step title=&quot;启动开发&quot;&gt;运行 pnpm dev&lt;/Step&gt;
  &lt;Step title=&quot;构建部署&quot;&gt;运行 pnpm build&lt;/Step&gt;
&lt;/Steps&gt;
</code></pre>
<h3>液态玻璃设计统一</h3>
<p>这次把所有组件都统一到了液态玻璃设计系统。四个层级：</p>
<ul>
<li><code>liquid-glass</code> — 标准卡片</li>
<li><code>liquid-glass-elevated</code> — 弹出层/模态框</li>
<li><code>liquid-glass-subtle</code> — 次要元素</li>
<li><code>liquid-glass-input</code> — 表单输入</li>
</ul>
<p>所有新组件都遵循这个规范，确保视觉一致性。</p>
<h3>View Transitions 页面动画</h3>
<p>利用浏览器原生的 View Transitions API，实现了页面切换时的平滑过渡。不支持的浏览器会自动降级为普通跳转，不影响功能。</p>
<h2>遇到的坑</h2>
<h3>1. Cloudflare Pages 不支持原生模块</h3>
<p>一开始我想用 <code>@xenova/transformers</code> 在构建时跑本地 AI 模型来做语义搜索。结果发现 CF Pages 的构建环境不支持 <code>onnxruntime-node</code>（原生 C++ 模块）。</p>
<p>最后放弃了 AI 方案，改用 Pagefind + 标签权重算法。对于我目前不到 10 篇文章的规模，效果其实差不多。</p>
<h3>2. Sandpack 加载不稳定</h3>
<p>代码块的&quot;Run&quot;按钮用的是 CodeSandbox 的 Sandpack，但不管开不开加速器都经常加载失败，一直转圈。最后决定直接移除这个功能。</p>
<h3>3. overflow-hidden 阻止图片缩放</h3>
<p><code>react-medium-image-zoom</code> 的放大效果被父容器的 <code>overflow-hidden</code> 给裁剪了。解决方法很简单——把 <code>overflow-hidden</code> 去掉就行。</p>
<h3>4. Safari 隐私模式 localStorage 报错</h3>
<p>Safari 的隐私浏览模式下，<code>localStorage.setItem()</code> 会直接抛异常。所有用到 localStorage 的地方都需要 try/catch 包裹。</p>
<h2>数据统计</h2>
<table>
<thead>
<tr>
<th>指标</th>
<th>数字</th>
</tr>
</thead>
<tbody><tr>
<td>总改进项</td>
<td>50+</td>
</tr>
<tr>
<td>新增 MDX 组件</td>
<td>15 个</td>
</tr>
<tr>
<td>Git 提交</td>
<td>9 次</td>
</tr>
<tr>
<td>涉及文件</td>
<td>70+</td>
</tr>
<tr>
<td>新增代码行</td>
<td>~3000 行</td>
</tr>
<tr>
<td>分享渠道</td>
<td>7 个</td>
</tr>
</tbody></table>
<h2>下一步</h2>
<ul>
<li>📝 继续写文章，记录学习和生活</li>
<li>🎓 准备高考（这才是正事！）</li>
<li>🔧 根据实际使用情况继续打磨细节</li>
<li>📊 观察 Pagefind 搜索的实际效果</li>
</ul>
<h2>总结</h2>
<p>做博客就像种花——你不能指望种下去第二天就开花，需要持续浇水、施肥、修剪。每次升级都是一次&quot;施肥&quot;，让博客变得更好一点。</p>
<p>这次 v2.0 升级让我学到了很多：</p>
<ul>
<li><strong>不要过度工程化</strong> — 一开始想用 AI 模型，后来发现简单算法就够用</li>
<li><strong>用户体验很重要</strong> — 小细节（如字体控制、位置记忆）能大幅提升阅读感受</li>
<li><strong>保持一致性</strong> — 液态玻璃设计系统让所有组件看起来是一个整体</li>
<li><strong>渐进增强</strong> — 新功能不支持就降级，不影响基本使用</li>
</ul>
<p>好了，该去复习了。高考加油！💪</p>
]]></content:encoded>
			<pubDate>Sun, 31 May 2026 00:00:00 GMT</pubDate>
			<author>1127251096@qq.com (XFffff)</author>
			<category>技术</category><category>成长</category><category>Next.js</category><category>Cloudflare</category><category>前端</category><category>MDX</category><category>Pagefind</category>
      <enclosure url="https://blog.the37777777.top/static/images/covers/my-blog-journey.webp" length="0" type="image/webp" />
		</item>
	
		<item>
			<guid>https://blog.the37777777.top/blog/202605/web-games-dev</guid>
			<title>从弹幕射击到经典扫雷：纯 JS 游戏开发手记</title>
			<link>https://blog.the37777777.top/blog/202605/web-games-dev</link>
			<description>高考前的最后一个项目——用纯 Vanilla JavaScript 做了两款 Web 游戏：一个 11500 行的弹幕射击，一个像素级还原的 Windows 扫雷。零依赖，零框架，从对象池到 BFS 洪水填充，记录一个高中生的游戏开发之旅。</description>
			<content:encoded><![CDATA[<h2>起因</h2>
<p>距离高考还有不到一个月。</p>
<p>按理说这个时候应该全力冲刺，但说实话，连续刷了几个月的题，脑子有时候真的转不动了。晚自习回来，打开电脑，本来想看看数学错题，结果手不自觉地打开了 VS Code。这种感觉做过博客和 TimeMark 的人应该懂：一旦你习惯了创造东西，就很难停下来。</p>
<p>做游戏这个念头其实很早就有了。小时候在学校机房玩 4399 上的 Flash 小游戏，那时候就想：这些东西是怎么做出来的？后来学了 JavaScript，发现浏览器本身就是一个游戏引擎。Canvas 能画任何东西，requestAnimationFrame 能跑 60 帧。不需要 Unity，不需要 Godot，一个浏览器就够了。</p>
<p>还有一个原因。做完博客和 TimeMark 之后，我一直在用框架。Next.js、React、Hono……框架确实好用，但我想证明一件事：<strong>纯 Vanilla JavaScript，不依赖任何框架和库，也能做出复杂的东西。</strong> 博客是站在巨人肩膀上，TimeMark 是全栈但有框架兜底，这次我想从零开始，自己造所有轮子。</p>
<p>于是就有了这两个项目。一个大的，一个小的。一个花了两周，一个花了三天。</p>
<h2>星域战机：一万一千行的弹幕射击</h2>
<h3>它是什么</h3>
<p>星域战机是一个 HTML5 Canvas 弹幕射击游戏（STG）。无尽模式，越打越难，死了重来。</p>
<p>听起来简单对吧？但内容量比我预想的大得多。最终纯 JavaScript 部分大约一万一千多行，算上 HTML 和 CSS 接近两万行。</p>
<p>规模有多大？看数据：</p>
<ul>
<li><strong>20 个流派</strong>（相当于角色 build）</li>
<li><strong>200 个技能</strong>（每次升级随机三选一）</li>
<li><strong>20 种武器</strong></li>
<li><strong>40 个道具</strong></li>
<li><strong>16+ 种敌人</strong></li>
<li><strong>4 个 Boss</strong></li>
</ul>
<p>这些内容全部是数据驱动的，后面会详细说。</p>
<p>🎮 在线试玩：<a href="https://game1.the37777777.top/ulw">game1.the37777777.top/ulw</a></p>
<p>📦 源码：<a href="https://github.com/WXFffff666/stg-game">github.com/WXFffff666/stg-game</a></p>
<h3>数据驱动架构：引擎和内容完全分离</h3>
<p>这个项目最让我满意的设计决策是：<strong>引擎只负责执行逻辑，所有内容定义在配置文件里。</strong></p>
<p>想加一个新流派？往 <code>config.js</code> 里加一条数据就行，引擎代码一行都不用改。来看看实际的流派配置长什么样：</p>
<pre><code class="language-javascript">FACTIONS: {
  attackSpeed: {
    id: &#39;attackSpeed&#39;, name: &#39;⚡ 攻速流&#39;, color: &#39;#ffdd00&#39;,
    description: &#39;极致射速，弹幕如雨&#39;,
    baseStats: { attackSpeed: 0.4, attack: 0.85, hp: 100, speed: 300, critRate: 0.05, critMult: 1.5, bulletCount: 0 },
    icon: &#39;⚡&#39;
  },
  counter: {
    id: &#39;counter&#39;, name: &#39;🛡️ 反伤流&#39;, color: &#39;#ff6644&#39;,
    description: &#39;挨打反弹，以守为攻&#39;,
    baseStats: { attackSpeed: 1.0, attack: 0.9, hp: 150, speed: 260, reflectDamage: 0.3, defense: 0.2, critRate: 0.05, critMult: 1.5 },
    icon: &#39;🛡️&#39;
  },
  crit: {
    id: &#39;crit&#39;, name: &#39;💥 暴击流&#39;, color: &#39;#ff0000&#39;,
    description: &#39;一击必杀，刀刀暴击&#39;,
    baseStats: { attackSpeed: 1.0, attack: 1.2, hp: 90, speed: 290, critRate: 0.25, critMult: 3.0, critDamage: 0 },
    icon: &#39;💥&#39;
  },
  // ... 17 more factions
}
</code></pre>
<p>20 个流派，每个都是这样一条配置。攻速流射速快但攻击力低，反伤流血厚能反弹伤害，暴击流脆皮但爆发高。引擎不关心具体有多少流派，它只读取 <code>baseStats</code> 然后计算。</p>
<p>技能也是同样的思路。每个技能就是一个纯数据对象：</p>
<pre><code class="language-javascript">{ id: &#39;atk_up_1&#39;, name: &#39;攻击提升 I&#39;, faction: &#39;any&#39;, type: &#39;passive&#39;, rarity: &#39;common&#39;,
  effects: [{ stat: &#39;attack&#39;, op: &#39;multiply&#39;, value: 0.15 }] },
{ id: &#39;bullet_plus_1&#39;, name: &#39;弹道+1&#39;, faction: &#39;any&#39;, type: &#39;passive&#39;, rarity: &#39;uncommon&#39;,
  effects: [{ stat: &#39;bulletCount&#39;, op: &#39;add&#39;, value: 1 }] },
{ id: &#39;crit_up_2&#39;, name: &#39;暴击率提升 II&#39;, faction: &#39;any&#39;, type: &#39;passive&#39;, rarity: &#39;uncommon&#39;,
  effects: [{ stat: &#39;critRate&#39;, op: &#39;add&#39;, value: 0.15 }] },
</code></pre>
<p>看到没？<code>effects</code> 数组里定义了这个技能修改哪个属性、用什么运算、改多少。引擎只需要遍历 effects，根据 <code>op</code> 字段做加法或乘法就行。200 个技能，引擎处理逻辑就那么几行，剩下的全是数据。</p>
<p>这种架构的好处是：<strong>加内容的速度极快。</strong> 一旦引擎稳定了，后面就是纯粹的内容创作。我后期加技能的速度大概是一小时 5、6 个，因为不需要碰引擎代码。</p>
<p>每个流派还有自己的终极技能，定义在 <code>skills.js</code> 里：</p>
<pre><code class="language-javascript">var FACTION_SYSTEM = {
  attackSpeed: {
    corePassive: { effects: [{ stat: &#39;attackSpeed&#39;, op: &#39;multiply&#39;, value: -0.15 }, { stat: &#39;attack&#39;, op: &#39;multiply&#39;, value: 0.1 }] },
    exclusiveSkills: [&#39;as_dual_wield&#39;, &#39;as_frenzy&#39;, &#39;as_machine_gun&#39;],
    ultimate: { id: &#39;ut_attackSpeed&#39;, name: &#39;⚡ 弹幕终结&#39;, faction: &#39;attackSpeed&#39;, type: &#39;passive&#39;, rarity: &#39;legendary&#39;, ultimate: true,
      description: &#39;极致攻速的终极形态&#39;,
      effects: [{ stat: &#39;attackSpeed&#39;, op: &#39;multiply&#39;, value: -0.5 }, { stat: &#39;ricochet&#39;, op: &#39;add&#39;, value: 3 }],
      visualColor: &#39;#ffdd00&#39;, visualType: &#39;lightning&#39; }
  },
  // ...
};
</code></pre>
<p>攻速流的终极技能「弹幕终结」：攻速再乘以 -0.5（注意这里 attackSpeed 的值越小射速越快），子弹还能弹射 3 次。配合前面基础属性里 <code>attackSpeed: 0.4</code> 的底子，这个流派到后期就是满屏子弹乱飞。</p>
<h3>对象池：零 GC 压力</h3>
<p>弹幕射击游戏最大的性能瓶颈是什么？垃圾回收（GC）。</p>
<p>想象一下：屏幕上同时有几百颗子弹在飞，每颗子弹飞出屏幕就销毁，新子弹不断创建。如果每颗子弹都 <code>new Bullet()</code>，GC 会疯掉，游戏会卡顿。</p>
<p>我写了一个通用的对象池类来解决这个问题：</p>
<pre><code class="language-javascript">class ObjectPool {
  constructor(factory, initialSize) {
    this._items = [];
    this._factory = factory;
    for (var i = 0; i &lt; (initialSize || 0); i++) {
      this._items.push(factory());
    }
  }

  get length() { return this._items.length; }

  pop() {
    if (this._items.length &gt; 0) {
      return this._items.pop();
    }
    return this._factory();
  }

  push(obj) {
    this._items.push(obj);
  }
}
</code></pre>
<p>原理很简单：构造的时候传入一个工厂函数和初始大小，预先创建一批对象放进池子。需要新对象的时候调 <code>pop()</code>，池子里有就直接拿，没有才用工厂函数创建新的。对象「死亡」后调 <code>push()</code> 放回池子。整个游戏运行过程中，<code>new</code> 操作只在最开始发生几次，之后全部是复用。</p>
<p>子弹、粒子、敌人、伤害数字……所有频繁创建销毁的对象都用了对象池。实测下来，即使屏幕上有 300+ 子弹同时飞行，帧率也能稳定在 60fps。</p>
<h3>踩坑：游戏循环与 Spiral of Death</h3>
<p>游戏的心跳是 <code>requestAnimationFrame</code> + deltaTime。但这里有个坑，我一开始没注意到，后来被坑了一整个晚上。</p>
<p>先看最终的代码：</p>
<pre><code class="language-javascript">// Inside Game class _loop method:
const rawDt = timestamp - this.lastTime;
this.lastTime = timestamp;

// Cap delta time to prevent spiral of death
const dt = Math.min(rawDt, 50) * 0.001 * this.timeScale;

if (!this.isPaused) {
  this.gameTime += rawDt;
  this._update(dt);
}

this._draw();
this.rafId = requestAnimationFrame(this._loop);
</code></pre>
<p>看到那行 <code>Math.min(rawDt, 50)</code> 了吗？这是为了防止所谓的「spiral of death」（死亡螺旋）。</p>
<p>什么意思呢？假设某一帧因为 GC 或者其他原因卡了 500ms，如果不做限制，deltaTime 就是 0.5 秒。这一帧里所有物体都会移动半秒的距离，子弹可能直接穿过敌人，碰撞检测全部失效。更糟糕的是，这一帧要处理的计算量暴增，导致下一帧也卡，deltaTime 更大，计算量更多……恶性循环，游戏直接崩掉。</p>
<p>解决方案就是给 deltaTime 加个上限。超过 50ms（相当于 20fps 以下）的帧，一律按 50ms 算。游戏可能会短暂变慢，但不会崩。</p>
<p>这个 bug 的现象是：切到其他标签页再切回来，游戏里所有东西瞬间飞出屏幕。当时我完全懵了，以为是碰撞检测的问题，排查了半天。后来才想明白，浏览器在后台标签页会暂停 requestAnimationFrame，切回来的时候 deltaTime 直接飙到几秒。加了这个 cap 之后就好了。</p>
<p>注释里写的 &quot;prevent spiral of death&quot; 就是那天晚上加的，算是给自己留个纪念。</p>
<h3>程序化音效：零音频文件</h3>
<p>整个游戏没有一个音频文件。所有音效都是用 Web Audio API 实时合成的：</p>
<pre><code class="language-javascript">// Shoot sound: short square wave decay
playShoot() {
  this._playTone(&#39;square&#39;, 800, 400, 0.05, 0.08);
}

// Explosion: noise burst with lowpass filter
playExplosion() {
  this._playNoise(0.2, 0.12, 1000);
}

// Core tone generator
_playTone(type, startFreq, endFreq, duration, vol) {
  this._ensureContext();
  if (this._muted) return;
  const ctx = this._ctx;
  const now = ctx.currentTime;

  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  osc.type = type;
  osc.frequency.setValueAtTime(Math.max(startFreq, 0.01), now);
  osc.frequency.exponentialRampToValueAtTime(Math.max(endFreq, 0.01), now + duration);
  gain.gain.setValueAtTime(vol, now);
  gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
  osc.connect(gain);
  gain.connect(this._masterGain);
  osc.start(now);
  osc.stop(now + duration + 0.01);
}
</code></pre>
<p>射击声是一段 800Hz 到 400Hz 的方波快速衰减，持续 0.05 秒。爆炸声是白噪声加 1000Hz 低通滤波器。升级音效是两个正弦波的快速上行。听起来很 retro，但和像素风的画面很搭。</p>
<p>好处是显而易见的：零网络请求，零加载时间，包体积极小。坏处是调音效全靠耳朵，没有可视化工具，改一个参数就要重新听一遍。<code>playShoot</code> 那个 0.05 秒的持续时间我调了十几次才觉得「对味」。0.03 太短听不清，0.08 又太拖沓，最后定在 0.05。</p>
<p>还有个坑：<code>exponentialRampToValueAtTime</code> 不能 ramp 到 0，只能 ramp 到一个接近 0 的值（我用的 0.001）。一开始写的是 ramp 到 0，浏览器直接报错，查了半天 MDN 才发现这个限制。指数衰减嘛，数学上不可能到 0，想想也合理。</p>
<h3>最大的挑战：200 个技能的平衡性</h3>
<p>200 个技能，怎么保证不会出现某个组合过于逆天？</p>
<p>说实话，保证不了。😅</p>
<p>我的做法是分层：技能分 common、uncommon、rare、legendary 四个稀有度，高稀有度出现概率低。同一流派的技能之间有协同效果，但不同流派混搭也不会太弱。然后就是大量的自己试玩，发现太强的就削一刀，太弱的就加强。</p>
<p>比如攻速流一开始太强了。基础 <code>attackSpeed: 0.4</code> 加上终极技能再乘 -0.5，配合「弹道+1」技能多发子弹，屏幕上全是子弹，Boss 秒融。后来我把终极技能的弹射次数从 5 降到 3，才算平衡了一点。</p>
<p>这个过程其实挺像做数学题的：调参数、验证、再调。只不过验证的方式是打一局游戏而不是算一道题。</p>
<h2>经典扫雷：三天的像素级还原</h2>
<h3>它是什么</h3>
<p>做完星域战机之后，我想做个小的项目换换脑子。扫雷是我小时候在 Windows XP 上玩得最多的游戏之一，规则简单，但实现起来有不少细节。</p>
<p>目标很明确：<strong>像素级还原 Windows XP/7 的扫雷体验。</strong> 不是「类似扫雷」，是打开之后让人觉得「这就是那个扫雷」。</p>
<p>最终成品大约 2800 行代码，支持 PWA 离线游玩。</p>
<p>🎮 在线试玩：<a href="https://game2.the37777777.top/">game2.the37777777.top</a></p>
<p>📦 源码：<a href="https://github.com/WXFffff666/minesweeper">github.com/WXFffff666/minesweeper</a></p>
<h3>功能清单</h3>
<p>别看扫雷「简单」，要做到完整还是有不少东西的：</p>
<ul>
<li>3D 凸起/凹陷边框（经典 Windows 风格）</li>
<li>LED 数字计数器（剩余雷数 + 计时器）</li>
<li>左右键同时按的 chord click（双击翻开周围）</li>
<li>第一次点击保证安全（不会踩雷）</li>
<li>BFS 洪水填充（点到空白区域自动展开）</li>
<li>无尽模式（通关后自动开下一局）</li>
<li>12 个成就</li>
<li>3 套主题（经典、暗色、现代）</li>
<li>统计数据和排行榜</li>
<li>PWA 离线支持</li>
</ul>
<h3>模块模式架构</h3>
<p>扫雷的代码结构比星域战机清晰得多（毕竟规模小很多）。我用了 IIFE 模块模式，把逻辑拆成独立模块。来看看渲染模块的实际代码：</p>
<pre><code class="language-javascript">MS.Renderer = (function() {
    &#39;use strict&#39;;

    var boardEl = document.querySelector(&#39;.board&#39;);
    var mineCounterEl = document.querySelector(&#39;.mine-counter&#39;);
    var timerEl = document.querySelector(&#39;.timer&#39;);
    var smileyBtn = document.querySelector(&#39;.smiley-btn&#39;);
    var currentCols = 0;

    function createBoard(rows, cols) {
        currentCols = cols;
        boardEl.innerHTML = &#39;&#39;;
        boardEl.style.setProperty(&#39;--cols&#39;, cols);

        var fragment = document.createDocumentFragment();
        for (var r = 0; r &lt; rows; r++) {
            for (var c = 0; c &lt; cols; c++) {
                var cell = document.createElement(&#39;div&#39;);
                cell.className = &#39;cell&#39;;
                cell.dataset.row = r;
                cell.dataset.col = c;
                fragment.appendChild(cell);
            }
        }
        boardEl.appendChild(fragment);
    }

    // ... more methods ...

    return { createBoard: createBoard, updateCell: updateCell, ... };
})();
</code></pre>
<p>每个模块是一个 IIFE，内部变量完全私有，只通过 return 暴露公共接口。<code>MS.Engine</code> 负责游戏逻辑，<code>MS.Renderer</code> 负责渲染，<code>MS.Input</code> 负责输入处理，<code>MS.Audio</code> 负责音效，<code>MS.Stats</code> 负责统计和成就。</p>
<p>纯逻辑和渲染完全分离。<code>MS.Engine</code> 不知道自己被画在哪里，<code>MS.Renderer</code> 不关心游戏规则。如果以后想换一套 UI（比如从 DOM 换成 Canvas），只需要重写 <code>MS.Renderer</code>，其他模块一行不动。</p>
<p>注意 <code>createBoard</code> 里用了 <code>document.createDocumentFragment()</code>。如果直接在循环里 <code>appendChild</code> 到 DOM，每次都会触发重排。用 fragment 先在内存里拼好，最后一次性插入，性能差距在大棋盘（30x16 = 480 个格子）上很明显。</p>
<h3>BFS 洪水填充</h3>
<p>扫雷里点到一个周围没有雷的空白格子，会自动展开一大片区域。这个功能的实现是经典的 BFS（广度优先搜索）。来看实际代码：</p>
<pre><code class="language-javascript">function floodFill(startRow, startCol) {
    var queue = [];
    var revealed = [];

    queue.push({ row: startRow, col: startCol });

    while (queue.length &gt; 0) {
        var cell = queue.shift();
        var r = cell.row;
        var c = cell.col;
        var boardCell = board[r][c];

        if (boardCell.revealed || boardCell.flagged) continue;

        boardCell.revealed = true;
        revealedCount++;
        revealed.push({ row: r, col: c });

        if (boardCell.adjacentMines === 0) {
            var neighbors = getNeighbors(r, c);
            for (var i = 0; i &lt; neighbors.length; i++) {
                var nr = neighbors[i].row;
                var nc = neighbors[i].col;
                if (!board[nr][nc].revealed &amp;&amp; !board[nr][nc].flagged) {
                    queue.push({ row: nr, col: nc });
                }
            }
        }
    }

    return revealed;
}
</code></pre>
<p>为什么用迭代 BFS 而不是递归 DFS？因为大棋盘（比如 30x16）上，递归深度可能超过浏览器的调用栈限制，直接 stack overflow。迭代版本用一个队列，不管棋盘多大都不会爆栈。</p>
<p>注意这里的逻辑：只有 <code>adjacentMines === 0</code> 的格子才会继续扩展邻居。如果一个格子周围有雷（数字不为 0），它会被翻开但不会继续往外扩。这就是为什么 BFS 展开后边缘总是一圈数字。</p>
<p>函数返回所有被翻开的格子坐标，渲染模块拿到这个数组后批量更新 DOM。逻辑和渲染分离的好处在这里就体现出来了。</p>
<p>这也是算法课上学的东西第一次在实际项目里派上用场。真的挺有成就感的。</p>
<h3>踩坑：第一次点击保证安全</h3>
<p>扫雷有个规则很多人不知道：第一次点击永远不会踩雷。Windows 原版就是这样的。</p>
<p>实现方式是：<strong>雷不是游戏开始时就放好的，而是第一次点击之后才放。</strong> 放雷的时候排除掉点击位置周围 3x3 的区域：</p>
<pre><code class="language-javascript">function placeMines(safeRow, safeCol) {
    var positions = [];
    for (var r = 0; r &lt; rows; r++) {
        for (var c = 0; c &lt; cols; c++) {
            // Exclude 3x3 area around safe cell
            if (Math.abs(r - safeRow) &lt;= 1 &amp;&amp; Math.abs(c - safeCol) &lt;= 1) {
                continue;
            }
            positions.push({ row: r, col: c });
        }
    }

    shuffle(positions);

    var minesToPlace = Math.min(totalMines, positions.length);
    for (var i = 0; i &lt; minesToPlace; i++) {
        board[positions[i].row][positions[i].col].mine = true;
    }
}
</code></pre>
<p>先收集所有「可以放雷」的位置（排除安全区），然后 shuffle 打乱，取前 N 个放雷。</p>
<p>这里有个边界情况我一开始没想到：<code>Math.min(totalMines, positions.length)</code> 这行。如果棋盘太小、雷太多，排除了 3x3 安全区之后可放的位置不够怎么办？比如 5x5 的棋盘放 20 颗雷，排除 9 个安全格后只剩 16 个位置。没有这个 <code>Math.min</code> 的话，数组越界直接报错。虽然正常游戏不太会出现这种情况，但自定义难度的时候用户什么参数都可能填。</p>
<p>还有个细节：排除的是 3x3 区域而不是单个格子。为什么？因为如果只排除点击的那一个格子，第一次点击虽然不会死，但可能翻开一个数字 8（周围全是雷），体验很差。排除 3x3 保证第一次点击至少能展开一小片区域，给玩家一个好的开局。</p>
<h3>像素级还原的 CSS 挑战</h3>
<p>Windows XP 扫雷那个经典的 3D 外观，全靠 CSS 实现：</p>
<pre><code class="language-css">.cell-raised {
  border-top: 2px solid #fff;
  border-left: 2px solid #fff;
  border-bottom: 2px solid #808080;
  border-right: 2px solid #808080;
}

.cell-sunken {
  border-top: 1px solid #808080;
  border-left: 1px solid #808080;
  border-bottom: 1px solid #fff;
  border-right: 1px solid #fff;
}
</code></pre>
<p>上边和左边用亮色，下边和右边用暗色，就能模拟出光照从左上方打过来的 3D 效果。翻开的格子反过来，就是凹陷的效果。注意凸起是 2px 边框，凹陷是 1px，这个细节也是对着原版截图一点点调出来的。</p>
<p>LED 数字显示器也是纯 CSS 画的，用七段数码管的方式拼出 0-9。计时器和雷数计数器都用这个组件。</p>
<p>说起来简单，但调到「看起来对」花了不少时间。我对着 Windows XP 虚拟机的截图一个像素一个像素地比对，调颜色、调边框宽度、调间距。强迫症发作的时候真的停不下来。</p>
<h3>PWA 离线支持</h3>
<p>扫雷这种游戏太适合做 PWA 了。断网环境下也能玩，安装到手机桌面之后和原生 App 体验一模一样。</p>
<p>Service Worker 缓存了所有静态资源，第一次加载之后就完全不需要网络了。加上整个游戏零外部依赖，离线体验和在线完全一致。</p>
<h2>技术对比</h2>
<p>两个项目放在一起看，差异还挺大的：</p>
<table>
<thead>
<tr>
<th align="left"></th>
<th align="left">星域战机</th>
<th align="left">经典扫雷</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>代码量</strong></td>
<td align="left">~20000 行</td>
<td align="left">~2800 行</td>
</tr>
<tr>
<td align="left"><strong>渲染方式</strong></td>
<td align="left">Canvas 2D</td>
<td align="left">DOM</td>
</tr>
<tr>
<td align="left"><strong>架构</strong></td>
<td align="left">数据驱动 + 对象池</td>
<td align="left">模块模式 + MVC</td>
</tr>
<tr>
<td align="left"><strong>音效</strong></td>
<td align="left">Web Audio 程序化</td>
<td align="left">Web Audio 程序化</td>
</tr>
<tr>
<td align="left"><strong>离线</strong></td>
<td align="left">Service Worker</td>
<td align="left">PWA + Service Worker</td>
</tr>
<tr>
<td align="left"><strong>开发周期</strong></td>
<td align="left">~2 周</td>
<td align="left">~3 天</td>
</tr>
<tr>
<td align="left"><strong>输入</strong></td>
<td align="left">键盘 + 鼠标</td>
<td align="left">鼠标 + 触屏</td>
</tr>
<tr>
<td align="left"><strong>状态管理</strong></td>
<td align="left">全局 Game 对象</td>
<td align="left">模块内部状态</td>
</tr>
</tbody></table>
<p>星域战机是「大而全」，什么都自己造轮子；扫雷是「小而精」，在有限的规模里把每个细节打磨到位。两种开发体验都很有意思。</p>
<h2>共同的设计哲学</h2>
<p>虽然两个项目规模差很多，但有几个原则是一致的：</p>
<p><strong>零依赖。</strong> 没有 React，没有 Vue，没有 jQuery，没有任何 npm 包。所有代码都是从零写的。这不是为了装逼，是为了学习。用框架的时候，很多底层细节被封装掉了，你不知道 Canvas 怎么画一个圆，不知道事件委托怎么实现，不知道音频合成的原理。自己写一遍，这些东西就真的变成你的了。</p>
<p><strong>数据驱动。</strong> 星域战机的技能配置是数据驱动，扫雷的主题和成就也是数据驱动。把「是什么」和「怎么做」分开，代码会清晰很多。加内容不需要改逻辑，改逻辑不会破坏内容。</p>
<p><strong>程序化音效。</strong> 两个游戏都没有音频文件，全部用 Web Audio API 实时合成。省带宽，省加载时间，而且可以动态调整参数（比如根据连击数改变音调）。</p>
<p><strong>渐进增强。</strong> 核心功能不依赖任何高级 API。Web Audio 不支持？静音也能玩。Service Worker 不支持？在线也能玩。触屏设备？也做了适配。不会因为某个 API 不可用就整个游戏崩掉。</p>
<h2>写在最后</h2>
<p>这两个游戏是在备考间隙做的。晚上回来，刷完当天的任务，如果还有精力，就写一会儿代码。有时候是半小时，有时候是两小时。星域战机断断续续写了两周，扫雷集中三天搞定。</p>
<p>对我来说，写代码是一种很好的减压方式。和刷题不同，写代码的时候你在创造东西，看着一个游戏从空白画布变成能玩的成品，那种成就感是做对一道数学题给不了的。</p>
<p>回头看这一年多的项目：博客让我入了前端的门，TimeMark 让我理解了全栈和 Docker，这两个游戏让我真正掌握了 Vanilla JS 的底层能力。每个项目都在前一个的基础上往前走了一步。</p>
<p>高考之后，我想试试用真正的游戏引擎做点东西。Godot 或者 Unity，做一个有完整剧情的小游戏。也可能会继续用 Web 技术，毕竟浏览器的能力还远没被我榨干。</p>
<p>不管怎样，这两个项目算是高中阶段的一个句号。</p>
<p>好了，该去背英语了。</p>
<hr>
<p><strong>试玩链接：</strong></p>
<ul>
<li>星域战机：<a href="https://game1.the37777777.top/ulw">game1.the37777777.top/ulw</a></li>
<li>经典扫雷：<a href="https://game2.the37777777.top/">game2.the37777777.top</a></li>
</ul>
]]></content:encoded>
			<pubDate>Sun, 24 May 2026 00:00:00 GMT</pubDate>
			<author>1127251096@qq.com (XFffff)</author>
			<category>JavaScript</category><category>Canvas</category><category>Game Dev</category><category>前端</category><category>PWA</category>
      <enclosure url="https://blog.the37777777.top/static/images/covers/web-games-dev.webp" length="0" type="image/webp" />
		</item>
	
		<item>
			<guid>https://blog.the37777777.top/blog/202604/timemark-docker</guid>
			<title>TimeMark：我的第一个 Docker 项目</title>
			<link>https://blog.the37777777.top/blog/202604/timemark-docker</link>
			<description>从三容器到单容器，从 800MB 到 256MB —— 一个高中生独立开发的智能生日提醒系统。支持农历、38+ 通知渠道、一键部署到 NAS。</description>
			<content:encoded><![CDATA[<h2>起因</h2>
<p>去年过年的时候，我忘了给一个长辈发生日祝福。不是不想发，是真的忘了。我妈提醒我的时候已经过了两天，场面一度很尴尬。</p>
<p>后来我想，能不能做个工具自动提醒我？上网搜了一圈，发现要么是手机日历（不支持农历），要么是付费 App（一年几十块），要么是那种企业级的日程管理系统（杀鸡用牛刀）。我家里有台 NAS，跑着飞牛OS，上面已经有好几个 Docker 容器了。如果能做一个 Docker 应用，一键部署到 NAS 上，岂不是完美？</p>
<p>就这样，TimeMark 诞生了。这是我做完博客之后的第二个项目，也是我第一次接触 Docker 开发。</p>
<h2>TimeMark 是什么</h2>
<p>简单说，TimeMark 是一个智能事件提醒系统。你把家人朋友的生日、纪念日录进去，它会在指定时间通过微信、QQ、钉钉、飞书、邮件、Bark 等渠道自动发通知给你。</p>
<p>听起来不复杂对吧？但有几个点让它变得有意思：</p>
<ul>
<li><strong>农历支持</strong>。我奶奶的生日是农历的，每年对应的公历日期都不一样，还要处理闰月的情况。</li>
<li><strong>NAS 友好</strong>。专门为家用 NAS 设计，J4125、N5105 这种低功耗处理器也能跑。</li>
<li><strong>一键部署</strong>。三行命令搞定，不需要懂代码。</li>
</ul>
<p>目前支持飞牛OS、群晖、威联通、铁威马这几个主流 NAS 系统。</p>
<h2>技术选型：为什么选这些</h2>
<p>做博客的时候我用了 Next.js + Tailwind CSS，算是入了前端的门。TimeMark 是全栈项目，前后端都要写，所以技术选型花了不少心思。</p>
<p><strong>后端框架选了 Hono。</strong> 那段时间刷 GitHub Trending，发现 Hono 经常上榜。点进去一看：轻量、快、TypeScript 原生支持、API 设计很优雅。试了一下，确实比 Express 写起来舒服。而且 Hono 的中间件机制很灵活，做鉴权、日志这些很方便。</p>
<p><strong>数据库选了 sql.js（SQLite）。</strong> 这个选择其实是踩了坑之后才做的，后面会讲。最终选 sql.js 的原因很简单：零依赖，数据就是一个文件，NAS 用户备份的时候直接拷贝就行。做博客的时候我就学到了一个道理：能简单就别复杂。</p>
<p><strong>前端还是 React + TailwindCSS。</strong> 熟悉嘛，博客项目打下的基础直接用上了。</p>
<p><strong>项目结构用了 pnpm workspace monorepo。</strong> 前端和后端放在一个仓库里，共享 TypeScript 类型定义。比如一个 <code>Event</code> 类型，前端后端用的是同一份定义，改一处两边都生效。类型安全这东西，用过就回不去了。</p>
<p>架构设计参考了不少开源项目，Docker 最佳实践也翻了官方文档好几遍。</p>
<h2>架构演进：从过度设计到大道至简</h2>
<h3>v1.x 时代：三个容器的「豪华配置」</h3>
<p>第一版的 TimeMark，我用了 PostgreSQL 做数据库，Redis + Bull 做任务队列，加上应用本身，一共三个 Docker 容器。</p>
<p>为什么这么设计？因为我看的教程和开源项目都是这么搞的。PostgreSQL 是「正经」数据库，Redis 做缓存和消息队列是「业界标准」，Bull 是 Node.js 生态里最流行的任务调度库。一切看起来都很「专业」。</p>
<p>然后 NAS 用户开始反馈了。</p>
<blockquote>
<p>&quot;这玩意吃了我 800MB 内存，我 NAS 一共才 4GB。&quot;
&quot;J4125 跑你这个 CPU 占用 30%，我还跑着其他服务呢。&quot;
&quot;就一个提醒工具，为什么要装 PostgreSQL？&quot;</p>
</blockquote>
<p>说实话，他们说得对。一个生日提醒系统，数据量撑死几百条，用 PostgreSQL 确实是大炮打蚊子。Redis 就更离谱了，我用它只是为了跑定时任务，完全可以用更轻量的方案替代。</p>
<h3>v2.0 重写：砍掉一切不必要的</h3>
<p>想明白之后，我花了两周重写了整个后端：</p>
<ul>
<li><strong>PostgreSQL → sql.js</strong>。SQLite 跑在 WASM 里，不需要单独的数据库容器。数据就是一个 <code>.db</code> 文件。</li>
<li><strong>Redis + Bull → Croner</strong>。Croner 是一个纯 JavaScript 的 cron 调度器，几十 KB 大小，功能完全够用。</li>
<li><strong>三容器 → 单容器</strong>。只需要一个 Docker 容器，内存占用从 800MB 降到 256MB，减少了 70%。</li>
</ul>
<p>这次重写让我真正理解了一句话：<strong>不要为了用技术而用技术。</strong> 选型的时候应该先想清楚需求是什么，而不是什么流行就用什么。一个面向家庭用户的小工具，简单可靠比「架构优雅」重要一万倍。</p>
<h2>踩过的坑</h2>
<p>做这个项目踩的坑比做博客多得多。挑几个印象最深的说说。</p>
<h3>Docker Hub 用户名：连改了 6 次</h3>
<p>第一次往 Docker Hub 发布镜像，我以为随便填个用户名就行。结果 push 的时候一直报错，说找不到仓库。</p>
<p>折腾了半天才搞明白：Docker Hub 的镜像名格式是 <code>用户名/镜像名</code>，这个用户名必须和你的 Docker Hub 账号完全一致。我一开始注册的时候手滑打错了，后来又改了好几次。翻 git log 能看到连续 6 个 commit 都在改这个用户名：<code>wfffff666</code>、<code>xxxxxf666</code>、<code>xfffff666</code>……</p>
<p>现在回头看那段 git 历史，挺好笑的。但当时真的很崩溃，因为 CI/CD 流水线一直跑不通，每次改完都要等 GitHub Actions 重新构建。</p>
<p>最终确定的镜像地址是 <code>xfffff666/timemark:latest</code>。</p>
<h3>SQL 方言不兼容：PostgreSQL 到 SQLite 的迁移噩梦</h3>
<p>v2.0 把数据库从 PostgreSQL 换成 sql.js 的时候，我天真地以为改个连接字符串就行了。</p>
<p>大错特错。</p>
<p>PostgreSQL 和 SQLite 的 SQL 方言差异比我想象的大得多。<code>TO_CHAR()</code> 函数在 SQLite 里根本不存在，日期格式化要用 <code>strftime()</code>。PostgreSQL 的布尔值是 <code>true/false</code>，SQLite 里是 <code>1/0</code>。Date 对象的处理方式也完全不同。</p>
<p>我不得不把所有涉及日期的查询全部重写。光这一项就改了好几天，提交了十几个 commit。有些 bug 还很隐蔽，比如农历转换后的日期比较逻辑，在 PostgreSQL 里跑得好好的，换到 SQLite 就出错了，因为两边的日期排序规则不一样。</p>
<p>教训：换数据库不是换个驱动那么简单。SQL 看起来是「标准」的，但每个数据库都有自己的方言和怪癖。</p>
<h3>crypto.randomUUID 在 NAS 上不工作</h3>
<p>这个 bug 让我排查了很久。本地开发一切正常，部署到 NAS 上之后，用户注册和登录功能直接崩了。</p>
<p>报错信息是 <code>crypto.randomUUID is not a function</code>。</p>
<p>查了一圈才知道：<code>crypto.randomUUID()</code> 需要「安全上下文」（Secure Context），也就是 HTTPS 环境。但 NAS 用户大多是通过局域网 HTTP 访问的，没有 HTTPS。</p>
<p>解决方案是加了一个 fallback：</p>
<pre><code class="language-typescript">function generateUUID(): string {
  if (typeof crypto !== &#39;undefined&#39; &amp;&amp; crypto.randomUUID) {
    return crypto.randomUUID()
  }
  // fallback: 手动拼接 UUID v4
  return &#39;xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx&#39;.replace(/[xy]/g, (c) =&gt; {
    const r = (Math.random() * 16) | 0
    const v = c === &#39;x&#39; ? r : (r &amp; 0x3) | 0x8
    return v.toString(16)
  })
}
</code></pre>
<p>这种「本地没问题，线上就炸」的 bug 是最烦人的。以后写代码得多想想目标用户的实际使用环境。</p>
<h3>时区问题：经典的差 8 小时</h3>
<p>做 Web 开发的人大概都会遇到时区问题，我也没逃过。</p>
<p>用户反馈说登录时间显示不对，明明是下午三点登录的，系统显示的是早上七点。差了刚好 8 小时。</p>
<p>原因很简单：服务端存的是 UTC 时间，前端展示的时候没有做时区转换。中国是 UTC+8，所以差了 8 小时。</p>
<p>听起来很好修对吧？但实际上我前前后后改了好几次才彻底修好。因为时区问题不只出现在登录时间，事件提醒的触发时间、农历日期的计算、cron 表达式的解析……到处都有时区的影子。每修一个地方就发现另一个地方也有问题。</p>
<p>最后我统一了策略：服务端全部用 UTC 存储和计算，只在返回给前端的时候转换成 <code>Asia/Shanghai</code>。</p>
<h3>Docker 容器里的 DNS 解析失败</h3>
<p>有用户反馈说通知发不出去，看日志发现是 DNS 解析失败。容器里 <code>ping</code> 任何域名都不通。</p>
<p>这是国内网络环境的老问题了。Docker 默认用的 DNS 是 <code>8.8.8.8</code>（Google DNS），在国内很多网络环境下不稳定。</p>
<p>解决方案是在 <code>docker-compose.yml</code> 里加上国内 DNS：</p>
<pre><code class="language-yaml">dns:
  - 223.5.5.5 # 阿里 DNS
  - 8.8.8.8 # Google DNS（备用）
</code></pre>
<p><code>223.5.5.5</code> 是阿里云的公共 DNS，国内解析速度快、稳定性好。加上之后问题就解决了。</p>
<h2>几个有意思的功能</h2>
<p>TimeMark 的功能不少，挑几个我觉得有意思的说说。</p>
<p><strong>农历支持</strong>是最费心思的。我用了 <code>lunar-javascript</code> 这个库来做农历和公历的互转。农历有闰月，比如闰四月，这种情况下同一个农历生日在公历上的日期会跳来跳去。我写了一套逻辑来处理这些边界情况，测试用例写了一大堆。</p>
<p><strong>通知渠道</strong>目前支持 35 个以上，覆盖了微信（Server 酱、PushPlus）、QQ、Telegram、钉钉、飞书、邮件、Bark、Gotify 等等。这些渠道的 API 各不相同，我抽象了一个统一的通知接口，新增渠道只需要实现一个 adapter 就行。</p>
<p><strong>安全方面</strong>也下了功夫。JWT 做身份认证，bcrypt 做密码哈希，AES-256 加密存储第三方通知渠道的密钥（比如邮箱密码、API Token），还有登录失败锁定机制防暴力破解。虽然是个小工具，但跑在 NAS 上，安全不能马虎。</p>
<p>还有个小功能我挺喜欢的：<strong>智能关系映射</strong>。你输入「我爸」，系统会自动识别成「父亲」这个关系类型，方便后续分类和展示。</p>
<h2>部署方式</h2>
<p>说了这么多，怎么用呢？</p>
<h3>最快的方式：三行命令</h3>
<pre><code class="language-bash">mkdir timemark &amp;&amp; cd timemark
curl -sSL https://raw.githubusercontent.com/WXFffff666/timemark-docker/master/docker-compose.dockerhub.yml -o docker-compose.yml
docker compose up -d
</code></pre>
<h3>完整的 docker-compose.yml</h3>
<p>如果你想手动创建配置文件，内容就这么多：</p>
<pre><code class="language-yaml"># TimeMark 一键部署 (Docker Hub)
# 使用: docker compose up -d

services:
  app:
    image: xfffff666/timemark:latest
    container_name: timemark-app
    restart: unless-stopped
    ports:
      - &#39;3000:3000&#39;
    environment:
      TZ: Asia/Shanghai
    dns:
      - 223.5.5.5
      - 8.8.8.8
    volumes:
      - ./data:/app/data
</code></pre>
<p>就这 18 行，没了。不需要配置数据库，不需要配置 Redis，不需要设置环境变量。<code>docker compose up -d</code> 之后访问 <code>http://你的NAS的IP:3000</code> 就能用了。</p>
<h3>几个部署细节</h3>
<p><strong>端口冲突？</strong> 如果 3000 端口被占了，改成别的就行：</p>
<pre><code class="language-yaml">ports:
  - &#39;8080:3000&#39; # 改成 8080 或者任何空闲端口
</code></pre>
<p><strong>数据在哪？</strong> 所有数据存在 <code>./data/timemark.db</code> 这个 SQLite 文件里。备份的时候直接拷贝这个文件就行：</p>
<pre><code class="language-bash">cp ./data/timemark.db ./data/timemark.db.bak
</code></pre>
<p><strong>DNS 为什么要配？</strong> 国内网络环境下，Docker 默认的 DNS（8.8.8.8）经常解析失败。加上 <code>223.5.5.5</code>（阿里 DNS）就稳了。这也是我踩坑踩出来的经验。</p>
<p><strong>公网部署？</strong> 如果要放到公网服务器上，建议自定义 <code>JWT_SECRET</code> 和 <code>MASTER_KEY</code>：</p>
<pre><code class="language-yaml">environment:
  TZ: Asia/Shanghai
  JWT_SECRET: 你自己生成一个随机字符串
  MASTER_KEY: 再生成一个随机字符串
</code></pre>
<h3>镜像地址</h3>
<table>
<thead>
<tr>
<th align="center">镜像源</th>
<th>地址</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><strong>Docker Hub</strong>（推荐）</td>
<td><code>xfffff666/timemark:latest</code></td>
<td>无需登录，直接拉取</td>
</tr>
<tr>
<td align="center"><strong>GHCR</strong></td>
<td><code>ghcr.io/wfffff666/timemark:latest</code></td>
<td>需要 GitHub 登录</td>
</tr>
</tbody></table>
<p>飞牛OS、群晖、威联通、铁威马都能用。只要你的 NAS 支持 Docker，就没问题。</p>
<h2>v2.1.0：继续打磨</h2>
<p>v2.0 发布之后，我以为可以告一段落了。结果用了几天就发现还有很多可以改进的地方。</p>
<h3>8 个新通知渠道</h3>
<p>v2.0 的时候支持 27 个通知渠道，已经不少了。但我发现国内用户最常用的几个推送服务还没支持：Server 酱、PushPlus、Bark 这些。</p>
<p>于是我又加了 8 个新渠道：</p>
<table>
<thead>
<tr>
<th>渠道</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>🦞 微信龙虾 (ClawBot)</td>
<td>直接推送到个人微信</td>
</tr>
<tr>
<td>📡 Server 酱</td>
<td>微信推送服务</td>
</tr>
<tr>
<td>📮 PushPlus</td>
<td>多渠道推送</td>
</tr>
<tr>
<td>🔔 Bark</td>
<td>iOS 推送通知</td>
</tr>
<tr>
<td>📢 Gotify</td>
<td>自托管推送</td>
</tr>
<tr>
<td>🐱 喵推送 (Meow)</td>
<td>鸿蒙系统推送</td>
</tr>
<tr>
<td>📲 PushMe</td>
<td>多平台推送</td>
</tr>
<tr>
<td>💼 企业微信应用</td>
<td>企微应用消息</td>
</tr>
</tbody></table>
<p>加完之后渠道总数到了 38+。因为之前设计了统一的通知接口，新增渠道只需要实现一个 adapter，所以加起来还挺快的。每个渠道大概半天就能搞定，主要时间花在看各家的 API 文档上。</p>
<p>顺便还把邮件渠道做了分类重组，支持多账号选择了。比如你可以配置多个邮箱，创建事件的时候选择用哪个邮箱发通知。</p>
<h3>零配置即开即用</h3>
<p>v2.0 的时候，虽然已经是单容器了，但还是需要配置 <code>JWT_SECRET</code> 和 <code>MASTER_KEY</code> 这两个环境变量。有用户反馈说不知道怎么生成随机字符串，觉得麻烦。</p>
<p>想了想，确实没必要强制配置。对于家庭内网使用的场景，内置一个默认密钥就够了。于是我给所有环境变量都加了内置默认值：</p>
<ul>
<li><code>JWT_SECRET</code> 和 <code>MASTER_KEY</code> 有内置默认值</li>
<li>默认管理员账号 <code>admin</code> / <code>TimeMark@2026</code></li>
<li><code>docker compose up -d</code> 之后直接就能用，不需要改任何配置</li>
</ul>
<p>当然，如果是公网部署，还是建议自定义密钥。但对于大多数 NAS 用户来说，零配置才是最友好的。</p>
<h3>登录锁定升级</h3>
<p>v2.0 的登录锁定是固定的：连续 5 次失败，锁定 15 分钟。但这样有个问题——攻击者等 15 分钟就能继续尝试。</p>
<p>v2.1.0 改成了线性叠加：第一次锁定 5 分钟，第二次 10 分钟，第三次 15 分钟……每次锁定时间递增。这样暴力破解的成本会越来越高。</p>
<h3>CI 构建修复</h3>
<p>加了 ClawBot（微信龙虾）渠道之后，CI 构建突然挂了。排查了一下，发现是两个问题：</p>
<ol>
<li>ClawBot 依赖 <code>nodemailer</code>，但没有加到 <code>package.json</code> 里。本地开发的时候因为 <code>node_modules</code> 里有缓存所以没问题，CI 环境是干净安装就报错了。</li>
<li>ClawBot 的类型定义有问题，TypeScript 编译不过。</li>
</ol>
<p>修了这两个问题之后 CI 就通了。这种「本地没问题，CI 就炸」的情况我在做博客的时候也遇到过，算是老朋友了。</p>
<h2>这个项目教会我的</h2>
<p>回头看，TimeMark 教会我的东西比博客项目多得多。</p>
<p><strong>全栈开发的完整链路。</strong> 从前端 UI 到后端 API，从数据库设计到 Docker 打包，从 CI/CD 到镜像发布。每一环都自己来，虽然累，但对整个开发流程的理解深了很多。</p>
<p><strong>Docker 多阶段构建。</strong> 为了减小镜像体积，我学了 multi-stage build。第一阶段用 Node.js 编译 TypeScript，第二阶段只拷贝编译产物和运行时依赖。最终镜像从 1GB+ 压缩到了几百 MB。</p>
<p><strong>安全意识。</strong> 以前写代码不太在意安全，觉得「又没人攻击我」。但 TimeMark 要存用户的通知渠道密钥，这些东西泄露了是真的会出问题的。所以我认真学了加密、鉴权、防暴力破解这些知识。</p>
<p><strong>架构重构的勇气。</strong> v1.x 到 v2.0 的重写，等于把后端推翻重来。当时很纠结，毕竟 v1.x 已经能用了。但用户反馈摆在那里，不改不行。这次经历让我明白：代码不是写完就结束了，根据反馈持续改进才是常态。</p>
<p><strong>CI/CD 自动化。</strong> 用 GitHub Actions 实现了自动构建和推送 Docker 镜像。每次 push 代码，Actions 会自动编译、打包、推送到 Docker Hub。再也不用手动 <code>docker build</code> 然后 <code>docker push</code> 了。</p>
<p><strong>用户反馈的价值。</strong> 如果不是 NAS 用户告诉我「太重了」，我可能永远不会去做 v2.0 的重写。闭门造车做出来的东西，和真实用户需要的东西，往往差距很大。</p>
<h2>写在最后</h2>
<p>从博客到 TimeMark，每个项目都让我成长了一截。博客教会我前端开发和部署，TimeMark 教会我全栈开发和 Docker。</p>
<p>从 v1.x 到 v2.0 再到 v2.1.0，每次迭代都是在用户反馈的基础上打磨。这个项目不会「完成」，它会一直跟着需求进化。</p>
<p>下一个项目会是什么？还不确定。但我知道，只要一直做下去，一直学下去，总会越来越好的。</p>
<p>如果你也是学生，也想做点什么，别犹豫，直接开始。第一版肯定很烂，没关系。我的第一版也很烂，三个容器吃 800MB 内存那种烂。但做着做着，就会越来越好。</p>
<p>继续写代码，继续踩坑，继续成长。</p>
]]></content:encoded>
			<pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
			<author>1127251096@qq.com (XFffff)</author>
			<category>Docker</category><category>TypeScript</category><category>NAS</category><category>全栈</category><category>开源</category>
      <enclosure url="https://blog.the37777777.top/static/images/covers/timemark-docker.webp" length="0" type="image/webp" />
		</item>
	
		<item>
			<guid>https://blog.the37777777.top/blog/202602/my-blog-journey</guid>
			<title>从零到一：一个高中生的博客搭建与优化之旅</title>
			<link>https://blog.the37777777.top/blog/202602/my-blog-journey</link>
			<description>记录一个高中生从零开始搭建个人博客的完整历程，从技术选型到部署上线，从深色模式渲染问题到图片加载优化，每一步都是成长。</description>
			<content:encoded><![CDATA[<h2>写在前面</h2>
<p>大家好，我是 XFffff，一名高中生。这是我第一次尝试搭建自己的个人博客。说实话，一开始我对前端开发了解得不多，只是觉得有个自己的博客很酷，可以记录学习和生活。没想到这个过程比我想象的要复杂得多，但也更有趣。</p>
<p>这篇文章记录了我从零开始搭建博客的完整过程，包括遇到的各种问题和解决方法，特别是今天（2月15日）一整天的性能优化历程。希望能帮到和我一样想做博客的同学。</p>
<h2>第一步：选择技术方案</h2>
<p>一开始我很迷茫，不知道该用什么技术。在网上搜索了很久，看了很多教程，最后决定用这套方案：</p>
<table>
<thead>
<tr>
<th align="left">组件</th>
<th align="left">选择</th>
<th align="left">为什么选它</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>框架</strong></td>
<td align="left">Next.js 15</td>
<td align="left">听说很流行，而且可以生成静态网站</td>
</tr>
<tr>
<td align="left"><strong>样式</strong></td>
<td align="left">Tailwind CSS 3.4</td>
<td align="left">不用写太多 CSS，直接用类名就行</td>
</tr>
<tr>
<td align="left"><strong>内容</strong></td>
<td align="left">MDX</td>
<td align="left">可以用 Markdown 写文章，简单方便</td>
</tr>
<tr>
<td align="left"><strong>部署</strong></td>
<td align="left">Cloudflare Pages</td>
<td align="left">免费！而且速度快</td>
</tr>
<tr>
<td align="left"><strong>包管理</strong></td>
<td align="left">pnpm</td>
<td align="left">比 npm 快，节省空间</td>
</tr>
</tbody></table>
<p><strong>为什么不用 Vercel？</strong></p>
<p>虽然很多教程都推荐 Vercel，但我选择了 Cloudflare Pages，因为：</p>
<ul>
<li>完全免费，没有流量限制</li>
<li>全球 CDN，访问速度快</li>
<li>和域名管理在一起，方便</li>
</ul>
<h2>第二步：找到合适的模板</h2>
<p>作为第一次做项目，我不可能从零开始写所有代码。在 GitHub 上找了很久，最后发现了 <a href="https://github.com/mk965/mengke.me">mengke.me</a> 这个开源项目。</p>
<p>这个项目功能很完善：</p>
<ul>
<li>✅ 支持 Markdown 写文章</li>
<li>✅ 有深色模式</li>
<li>✅ 可以搜索文章</li>
<li>✅ 自动生成 RSS</li>
<li>✅ 手机和电脑都能正常显示</li>
</ul>
<p>我把它 Fork 到自己的 GitHub，然后开始改造。</p>
<pre><code class="language-bash"># 克隆项目到本地
git clone https://github.com/WXFffff666/my-blog.git
cd my-blog

# 安装依赖
pnpm install

# 启动开发服务器
pnpm dev
</code></pre>
<p>打开 <code>http://localhost:3434</code>，看到页面成功显示，心里特别激动！</p>
<h2>第三步：个性化定制</h2>
<h3>修改个人信息</h3>
<p>第一件事就是把模板里的信息改成自己的。主要修改这几个文件：</p>
<p><strong>1. 个人信息（<code>data/author-info.ts</code>）</strong></p>
<pre><code class="language-typescript">export const AUTHOR_INFO = {
  name: &#39;XFffff&#39;,
  description: &#39;一个热爱学习的高中生&#39;,
  email: &#39;my-email@example.com&#39;,
  identity: &#39;Student | Learning&#39;,
  address: {
    city: &#39;Guilin, China&#39;,
    flag: &#39;flag-china&#39;,
    timeZone: 8,
  },
  social: {
    github: &#39;https://github.com/WXFffff666&#39;,
  },
}
</code></pre>
<blockquote>
<p><strong>注意</strong>：不要填真实姓名、手机号、详细地址这些敏感信息！</p>
</blockquote>
<p><strong>2. 网站信息（<code>data/site-metadata.ts</code>）</strong></p>
<pre><code class="language-typescript">export const SITE_METADATA = {
  title: &#39;XFffff 的个人博客&#39;,
  author: &#39;XFffff&#39;,
  siteUrl: &#39;https://blog.the37777777.top&#39;,
  locale: &#39;zh-CN&#39;,
  language: &#39;zh-CN&#39;,
}
</code></pre>
<h3>写第一篇文章</h3>
<p>在 <code>data/blog/202602/</code> 目录下创建 <code>Hello_World.mdx</code>：</p>
<pre><code class="language-markdown">---
title: &#39;Hello World - 我的第一篇博客&#39;
date: &#39;2026-02-12&#39;
tags: [&#39;随笔&#39;]
draft: false
summary: &#39;这是我的第一篇博客文章！&#39;
---

## 你好，世界！

这是我的第一篇博客文章。从今天开始，我要记录自己的学习和成长。

希望能坚持下去！
</code></pre>
<p>保存后刷新页面，文章就出现了！那一刻真的很有成就感。</p>
<h2>第四步：部署到 Cloudflare Pages</h2>
<p>本地运行成功后，就要部署到网上了。这一步遇到了不少问题。</p>
<h3>配置静态导出</h3>
<p>Cloudflare Pages 需要静态文件，所以要修改 <code>next.config.js</code>：</p>
<pre><code class="language-javascript">const nextConfig = {
  output: &#39;export&#39;, // 静态导出
  images: {
    unoptimized: true, // 必须禁用图片优化
  },
}
</code></pre>
<h3>遇到的第一个错误</h3>
<p>运行 <code>pnpm build</code> 时报错：</p>
<pre><code>Page &quot;/snippets/[...slug]&quot; is missing &quot;generateStaticParams()&quot;
</code></pre>
<p><strong>原因</strong>：项目里有个 <code>snippets</code> 目录，但我还没有添加任何内容。</p>
<p><strong>解决</strong>：直接删除 <code>app/snippets/</code> 目录，等以后有内容了再加回来。</p>
<h3>创建 Cloudflare Pages 项目</h3>
<ol>
<li>登录 <a href="https://dash.cloudflare.com/">Cloudflare Dashboard</a></li>
<li>左侧菜单 → <strong>Workers 和 Pages</strong></li>
<li>点击 <strong>创建</strong> → 选择 <strong>Pages</strong> 标签页</li>
<li>连接 GitHub 仓库</li>
</ol>
<p><strong>重要配置：</strong></p>
<table>
<thead>
<tr>
<th align="left">配置项</th>
<th align="left">值</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>框架预设</strong></td>
<td align="left"><code>Next.js (Static HTML Export)</code></td>
</tr>
<tr>
<td align="left"><strong>构建命令</strong></td>
<td align="left"><code>pnpm build</code></td>
</tr>
<tr>
<td align="left"><strong>构建输出目录</strong></td>
<td align="left"><code>out</code></td>
</tr>
</tbody></table>
<h3>绑定自定义域名</h3>
<p>Cloudflare 会给一个 <code>xxx.pages.dev</code> 的临时域名，但我想用自己的域名。</p>
<ol>
<li>进入 Pages 项目 → <strong>自定义域</strong></li>
<li>添加域名 <code>blog.the37777777.top</code></li>
<li>Cloudflare 自动添加 DNS 记录</li>
<li>等待 5-10 分钟生效</li>
</ol>
<p>访问自己的域名，博客成功上线了！那一刻真的超级激动！</p>
<h2>第五步：深色模式渲染优化（今天的重头戏）</h2>
<p>博客上线后，我发现深色模式有严重的渲染问题：<strong>快速滚动时，文字会闪烁消失，非常卡顿</strong>。这个问题困扰了我一整天，但最终找到了解决方案。</p>
<h3>问题现象</h3>
<ul>
<li>在深色模式下快速滚动页面</li>
<li>部分文字会闪烁、消失</li>
<li>有些文字清晰，有些文字模糊</li>
<li>Footer 的文字清晰，但首页的文字模糊</li>
</ul>
<h3>排查过程</h3>
<h4>尝试 1：移除 PageTransition 的 transform</h4>
<p>我发现 <code>PageTransition</code> 组件使用了 <code>translate-y-2</code>，这会创建 GPU 渲染层。</p>
<pre><code class="language-tsx">// 修改前
&lt;div className=&quot;translate-y-2 opacity-0&quot;&gt;

// 修改后
&lt;div className=&quot;opacity-0&quot;&gt;
</code></pre>
<p><strong>结果</strong>：❌ 还是会闪烁</p>
<h4>尝试 2：移除所有动画的 transform</h4>
<p>检查了所有动画，发现很多地方使用了 <code>transform</code>：</p>
<pre><code class="language-css">/* CSS 中的动画 */
@keyframes fade-in-up {
  from {
    opacity: 0;
    transform: translateY(20px); /* ← 问题所在 */
  }
}
</code></pre>
<p>全部改成只用 <code>opacity</code>：</p>
<pre><code class="language-css">@keyframes fade-in-up {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}
</code></pre>
<p><strong>结果</strong>：❌ 还是有问题</p>
<h4>尝试 3：移除 Greeting 的 bg-clip-text</h4>
<p>发现 Greeting 组件使用了 <code>bg-clip-text</code> 和渐变动画：</p>
<pre><code class="language-tsx">// 修改前
&lt;div className=&quot;bg-clip-text text-transparent animate-text-shimmer&quot;&gt;

// 修改后
&lt;div className=&quot;text-violet-600&quot;&gt;
</code></pre>
<p><strong>结果</strong>：❌ 还是不行</p>
<h4>尝试 4：对比原作者项目（找到根本原因！）</h4>
<p>我仔细对比了原作者的项目，发现了关键差异：</p>
<p><strong>我的配置：</strong></p>
<pre><code class="language-tsx">// layout.tsx
&lt;body className=&quot;dark:bg-black&quot;&gt;  // 纯黑 #000000

// background.tsx
&lt;div className=&quot;dark:bg-[#000000]&quot;&gt;  // 纯黑

// tailwind.config.js
dark: {
  bg: &#39;#050505&#39;,  // 极致深黑
}
</code></pre>
<p><strong>原作者的配置：</strong></p>
<pre><code class="language-tsx">// layout.tsx
&lt;body className=&quot;dark:bg-dark&quot;&gt;  // 深灰色

// tailwind.config.js
dark: &#39;#1f1f1f&#39;,  // 深灰色，不是纯黑！
</code></pre>
<p><strong>发现问题</strong>：纯黑背景（#000000 或 #050505）在某些浏览器和操作系统上会导致字体渲染问题！</p>
<h3>最终解决方案</h3>
<p>将所有深色背景从<strong>纯黑</strong>改为<strong>深灰色 <code>#1f1f1f</code></strong>：</p>
<pre><code class="language-tsx">// layout.tsx
&lt;body className=&quot;dark:bg-dark-bg&quot;&gt;

// background.tsx
&lt;div className=&quot;dark:bg-[#1f1f1f]&quot;&gt;

// tailwind.config.js
dark: {
  bg: &#39;#1f1f1f&#39;,  // 深灰色
}
</code></pre>
<p><strong>结果</strong>：✅ 完美解决！深色模式滚动完全流畅，不再闪烁！</p>
<h3>为什么深灰色不会糊？</h3>
<ul>
<li><code>#1f1f1f</code> 虽然看起来也很黑，但它不是纯黑</li>
<li>这个颜色值在浏览器渲染引擎中不会触发某些特殊的优化路径</li>
<li>原作者也是用这个颜色，证明它是稳定可靠的</li>
</ul>
<p><strong>教训</strong>：有时候问题的根源不在代码逻辑，而在一些看似无关紧要的细节（比如背景颜色）。</p>
<h2>第六步：图片加载优化</h2>
<p>解决了深色模式问题后，我发现快速滚动时，下面会出现白条（内容还没渲染出来）。这是图片加载慢导致的。</p>
<h3>优化方案</h3>
<p><strong>1. 预加载首屏图片</strong></p>
<pre><code class="language-tsx">// latest-posts.tsx
{
  posts.map((post, index) =&gt; &lt;BlogCard key={post.slug} post={post} priority={index &lt; 3} /&gt;)
}
</code></pre>
<p>前 3 篇文章的图片使用 <code>priority={true}</code>，浏览器会优先加载。</p>
<p><strong>2. 添加渐变占位符</strong></p>
<pre><code class="language-tsx">// blog-card.tsx
&lt;div className=&quot;relative h-48 w-full overflow-hidden bg-gradient-to-br from-gray-100 to-gray-200 dark:from-[#121212] dark:to-[#1a1a1a]&quot;&gt;
  &lt;Image src={coverImage} alt={title} priority={priority} /&gt;
&lt;/div&gt;
</code></pre>
<p>图片加载前显示渐变背景，视觉上更平滑。</p>
<p><strong>3. 优化图片淡入效果</strong></p>
<pre><code class="language-tsx">// image.tsx
&lt;NextImage
  className={clsx(&#39;transition-opacity duration-300&#39;, loaded ? &#39;opacity-100&#39; : &#39;opacity-0&#39;)}
  onLoad={onLoad}
/&gt;
</code></pre>
<p>图片加载完成后淡入显示，更自然。</p>
<p><strong>结果</strong>：✅ 快速滚动时不再出现明显的白条！</p>
<h2>第七步：导航栏交互优化</h2>
<p>博客基本完善后，我开始优化细节。发现导航栏 hover 时有个放大动画，但放大过程中会模糊。</p>
<h3>问题分析</h3>
<p>导航栏使用了 <code>hover:scale-[1.005]</code>，scale 变换会导致字体模糊。</p>
<h3>尝试的方案</h3>
<h4>方案 1：移除 scale，改用阴影 + 上移</h4>
<pre><code class="language-tsx">&lt;nav className=&quot;hover:shadow-xl hover:-translate-y-0.5&quot;&gt;
</code></pre>
<p><strong>结果</strong>：✅ 完全不会模糊，但上移有点突兀</p>
<h4>方案 2：保留 scale，添加 GPU 优化（最终方案）</h4>
<pre><code class="language-tsx">&lt;nav
  className=&quot;hover:scale-[1.005]&quot;
  style={{
    willChange: &#39;transform&#39;,
    transform: &#39;translateZ(0)&#39;,
    backfaceVisibility: &#39;hidden&#39;,
    WebkitFontSmoothing: &#39;subpixel-antialiased&#39;,
  }}
&gt;
</code></pre>
<p><strong>GPU 优化原理：</strong></p>
<ul>
<li><code>willChange: &#39;transform&#39;</code> - 提示浏览器会有变换</li>
<li><code>transform: translateZ(0)</code> - 强制创建 GPU 渲染层</li>
<li><code>backfaceVisibility: &#39;hidden&#39;</code> - 隐藏背面，提升性能</li>
<li><code>WebkitFontSmoothing: &#39;subpixel-antialiased&#39;</code> - 亚像素级抗锯齿</li>
</ul>
<p><strong>结果</strong>：✅ 保留了放大效果，模糊感大幅减少！</p>
<h2>经验总结</h2>
<h3>1. 技术选型很重要</h3>
<ul>
<li>Next.js 静态导出性能很好，适合博客</li>
<li>Cloudflare Pages 免费额度够用，速度也快</li>
<li>找一个好的开源模板可以节省很多时间</li>
</ul>
<h3>2. 遇到问题不要慌</h3>
<ul>
<li>先对比原作者的代码，找出差异</li>
<li>逐步修复，每次只改一个地方</li>
<li>分别测试电脑端和移动端</li>
<li>善用浏览器开发者工具</li>
</ul>
<h3>3. 性能优化要系统思考</h3>
<ul>
<li>深色模式问题可能是背景颜色导致的</li>
<li>图片加载要预加载首屏内容</li>
<li>GPU 优化可以减少 transform 的模糊感</li>
<li>不要过度使用动画和特效</li>
</ul>
<h3>4. 细节决定体验</h3>
<ul>
<li>背景颜色不要用纯黑，用深灰色</li>
<li>图片要有占位符，避免白屏</li>
<li>动画要流畅，不能卡顿</li>
<li>移动端和电脑端要分别优化</li>
</ul>
<h3>5. 学会阅读源码</h3>
<ul>
<li>遇到问题时，对比原作者的代码</li>
<li>理解每一行代码的作用</li>
<li>不要盲目复制粘贴</li>
<li>学会举一反三</li>
</ul>
<h2>后续开发：从初版到液态玻璃</h2>
<p>博客上线之后，我以为可以歇一歇了。结果完全停不下来——每天都能想到新功能要加，新 bug 要修。从 2 月到 4 月，这个博客经历了一次又一次的迭代，最终变成了现在的样子。</p>
<h3>功能大爆发</h3>
<p>上线后的两个月里，我一口气加了 11 个新功能。说实话，有些功能是我自己想要的，有些是看到别人的博客觉得「这个好酷，我也要」。</p>
<p><strong>阅读进度条 + TOC 增强。</strong> 文章顶部加了一个进度条，滚动的时候能看到读了多少。目录（TOC）也做了增强，在手机上变成了一个抽屉式的侧边栏，不会挡住正文。</p>
<p><strong>相关文章推荐 + 系列导航。</strong> 每篇文章底部会推荐相关的文章，算法是根据标签重合度打分的。还加了「文章系列」功能，比如这篇和 TimeMark 那篇就属于「我的开发之旅」系列，可以按顺序浏览。</p>
<p><strong>Sandpack 可运行代码块。</strong> 这个是我最喜欢的功能之一。文章里的 JavaScript/TypeScript/HTML/CSS 代码块可以直接运行！用的是 CodeSandbox 的 Sandpack 组件，懒加载的，不影响页面性能。</p>
<p><strong>OG 图片自动生成。</strong> 分享文章到社交媒体的时候，会自动生成一张好看的封面图。这个是构建时生成的，不需要运行时渲染。</p>
<p><strong>Service Worker + 离线支持。</strong> 加了 Service Worker 做缓存，断网了也能看之前访问过的页面。顺便还做了一个友链申请表单。</p>
<p><strong>键盘快捷键 + Changelog + Snippets 筛选。</strong> 按 <code>?</code> 可以查看所有快捷键，加了一个 Changelog 页面记录版本更新，Snippets 页面支持按标签筛选了。</p>
<p>这些功能加起来，提交了十几个 commit。每次加完一个功能都要跑一遍 <code>pnpm build</code>，确保静态导出没问题。Cloudflare Pages 是纯静态部署，任何需要服务端的功能都不能用，这个限制反而让我学会了很多静态站点的技巧。</p>
<h3>代码规范化重构</h3>
<p>功能加多了之后，项目结构开始变得混乱。CSS 文件散落在各处，图片目录没有统一规范，有些文件放错了位置。</p>
<p>我花了两天时间做了一次大重构：</p>
<ul>
<li>把 <code>css/</code> 目录统一改名为 <code>styles/</code></li>
<li>图片目录按用途分类：<code>covers/</code>（封面）、<code>banners/</code>（横幅）、<code>posts/</code>（文章配图）</li>
<li>移除了没用的测试文件和示例文件</li>
<li>把文档统一放到 <code>docs/</code> 目录</li>
</ul>
<p>重构的过程很枯燥，但做完之后整个项目清爽了很多。以后找文件也方便了。</p>
<h3>中文标签 404 修复</h3>
<p>这个 bug 困扰了我好一阵子。博客的标签系统支持中文标签（比如「技术」「前端」），在本地开发的时候一切正常，但部署到 Cloudflare Pages 之后，点击中文标签就 404。</p>
<p>排查了半天，发现是 <code>encodeURI</code> 的问题。之前代码里对标签做了 URL 编码，但 Cloudflare Pages 的静态文件路径不需要编码。移除了多余的 <code>encodeURI</code> 调用就好了。</p>
<p>顺便还做了一个标签显示名映射（<code>tag-names.json</code>），这样标签页上能显示原始的中文名称，而不是 URL 编码后的乱码。</p>
<h3>液态玻璃 UI 改造</h3>
<p>这是最大的一次改动。</p>
<p>起因是我看到了 Apple 的液态玻璃（Liquid Glass）设计风格，觉得特别好看。之前博客用的是「黑曜石玻璃」风格——深空灰背景加噪点纹理，虽然也不错，但看久了觉得有点沉闷。</p>
<p>我决定把整个博客改成液态玻璃风格。</p>
<p>一开始我想用 <code>liquid-glass-react</code> 这个 npm 包，但研究了一下发现它已经 10 个月没更新了，性能也不太好，还没有可访问性支持。最后决定自己用纯 CSS 实现。</p>
<p>液态玻璃的核心就是 <code>backdrop-filter: blur()</code> 加上半透明背景。我设计了 4 个层级：</p>
<pre><code class="language-css">/* standard - 普通组件 */
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px) saturate(1.8);

/* elevated - 突出组件（导航栏、卡片） */
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(24px) saturate(2);

/* subtle - 低调组件（标签、徽章） */
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(12px);

/* input - 输入框 */
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(16px);
</code></pre>
<p>背景色从深空灰改成了天蓝色 <code>#bfdbfe</code>（浅色模式）和 <code>#1f1f1f</code>（深色模式）。</p>
<p>改造过程中踩了不少坑。一开始用了 SVG 滤镜做扭曲效果，结果在某些浏览器上会出现奇怪的变形和闪烁。后来去掉了 SVG 扭曲，只保留了 CSS 的 blur 和 saturate，效果反而更好。</p>
<p>还有一个问题是白色边框。液态玻璃组件之前有白色边框和阴影，在天蓝色背景上看起来很突兀。我把所有组件的白色边框和阴影都去掉了，改用更微妙的透明度变化来区分层次。</p>
<p>最后还要把 6 个代码块相关的 MDX 组件也适配液态玻璃风格，确保代码块在新设计系统下看起来协调。</p>
<p>整个改造涉及了 26 个组件，提交了好几个 commit。虽然工作量很大，但最终效果我很满意。</p>
<h3>滑动 Pill 导航指示器</h3>
<p>液态玻璃改造完之后，我觉得导航栏还可以更酷一点。</p>
<p>于是加了一个滑动的玻璃药丸（Pill）指示器。当你切换导航项的时候，一个半透明的玻璃药丸会平滑地滑动到当前选中的位置。动画用了 spring easing（弹簧缓动），有一种很自然的弹性感。</p>
<p>这个效果实现起来不难，但调参数花了不少时间。弹簧的刚度、阻尼、质量都会影响动画的感觉，太硬了像机械，太软了像果冻。最后调到一个我觉得刚好的状态。</p>
<h3>kbar 搜索位移修复</h3>
<p>这个 bug 是最后修的，也是最烦人的。</p>
<p>博客用了 kbar 做搜索（按 <code>Ctrl+K</code> 打开）。kbar 打开的时候会给 body 加 <code>overflow: hidden</code>，防止背景滚动。但这样做有个副作用：滚动条消失了，页面内容会往右移动一点点（大概 15-17 像素），看起来就像页面「抖」了一下。</p>
<p>我前前后后试了好几种方案：</p>
<ol>
<li>用 <code>padding-right</code> 补偿滚动条宽度 —— 不行，因为 sticky header 的居中计算会受影响</li>
<li>动态测量滚动条宽度再补偿 —— 太复杂，而且不同浏览器滚动条宽度不一样</li>
<li>最终方案：直接用 CSS 覆盖 kbar 的行为</li>
</ol>
<pre><code class="language-css">body[style*=&#39;overflow: hidden&#39;] {
  overflow-y: scroll !important;
}
</code></pre>
<p>简单粗暴但有效。让 body 始终保持 <code>overflow-y: scroll</code>，滚动条一直在，就不会有位移了。kbar 的遮罩层本身就会阻止用户滚动，所以不影响功能。</p>
<p>有时候最简单的方案就是最好的方案。</p>
<h2>写在最后</h2>
<p>从开始搭建到现在，已经过去好几天了。特别是今天（2月15日），我花了一整天时间优化深色模式的渲染问题。这个过程中遇到了很多挫折，有时候真的很想放弃。但每次解决一个问题，那种成就感是无法形容的。</p>
<p>作为一个高中生，这是我第一次完整地做一个项目。虽然代码写得不够好，但我学到了很多：</p>
<ul>
<li>学会了如何阅读文档和源码</li>
<li>学会了如何调试和定位问题</li>
<li>学会了如何系统地优化性能</li>
<li>学会了如何坚持下去</li>
</ul>
<p>最重要的是，我有了一个属于自己的博客，可以记录学习和成长。</p>
<p>如果你也想做一个博客，不要害怕，大胆去试！遇到问题很正常，解决问题的过程就是成长的过程。</p>
<p>后来我把浏览量统计功能去掉了，博客现在是一个纯静态站点，部署在 Cloudflare Pages 上，简单又稳定。</p>
<p>希望这篇文章能帮到和我一样的初学者。如果你有问题，欢迎交流！</p>
<hr>
<h2>相关资源</h2>
<ul>
<li><strong>我的博客</strong>：<a href="https://blog.the37777777.top">https://blog.the37777777.top</a></li>
<li><strong>原项目地址</strong>：<a href="https://github.com/mk965/mengke.me">https://github.com/mk965/mengke.me</a></li>
<li><strong>Next.js 官方文档</strong>：<a href="https://nextjs.org/docs">https://nextjs.org/docs</a></li>
<li><strong>Cloudflare Pages 文档</strong>：<a href="https://developers.cloudflare.com/pages/">https://developers.cloudflare.com/pages/</a></li>
</ul>
<h2>下一步计划</h2>
<ul>
<li>✅ 添加了 Giscus 评论系统</li>
<li>✅ 做了 TimeMark 生日提醒系统（我的第一个 Docker 项目！）</li>
<li>✅ 添加了 11 个新功能（进度条、系列导航、Sandpack、OG 图片等）</li>
<li>✅ 完成了液态玻璃 UI 改造</li>
<li>✅ 修复了中文标签 404 问题</li>
<li>✅ 代码规范化重构</li>
<li>✅ 完成 v2.0 全站增强（50+ 项改进，15 个 MDX 组件，Pagefind 搜索）</li>
<li>📝 持续写文章，记录学习</li>
<li>🎨 继续打磨液态玻璃细节</li>
<li>🚀 探索更多有趣的项目</li>
</ul>
<p>加油！💪</p>
]]></content:encoded>
			<pubDate>Sun, 15 Feb 2026 00:00:00 GMT</pubDate>
			<author>1127251096@qq.com (XFffff)</author>
			<category>技术</category><category>成长</category><category>Next.js</category><category>Cloudflare</category><category>前端</category><category>性能优化</category><category>液态玻璃</category>
      <enclosure url="https://blog.the37777777.top/static/images/covers/my-blog-journey.webp" length="0" type="image/webp" />
		</item>
	
			</channel>
		</rss>
	