为什么要写这篇
2 月博客上线的时候,我写过一篇 从零到一:博客搭建之旅。那篇文章讲的是「怎么把站搭起来」。
半年多过去了,我又做了 TimeMark、两款 Web 游戏、知屋阅读书房,还有 8 月初刚推出的光子遥控——一个自研 14 种红外协议编码器的安卓 App。博客本身也从 v1.0 迭代到 v2.2。如果只用一句话概括这段时间,大概是:
从「跟着教程和框架走」,慢慢变成「敢自己设计架构、敢砍过度设计、敢为真实场景做取舍、敢啃底层协议」。
这篇不是某一次升级的 changelog,而是把所有项目串成一条线的复盘。技术细节会写(而且这次附上真实代码),但更重要的是:每个项目为什么做、做错了什么、学到了什么。
时间线:六个阶段
回看 git 记录和 Changelog,这大半年大致可以分成六段:
| 阶段 | 时间 | 主线 | 关键词 |
|---|---|---|---|
| ① 建站 | 2 月 | 个人博客 v1.0 | Next.js、Cloudflare Pages、Contentlayer |
| ② 打磨 | 4 月 | 液态玻璃 UI + TimeMark 立项 | 设计系统、Docker 初体验 |
| ③ 扩张 | 5 月 | 博客 v2.0 + Web 游戏 + TimeMark v2 | Pagefind、Vanilla JS、单容器重构 |
| ④ 节点 | 6 月 | 高考 | 项目暂停,Moment 记录 |
| ⑤ 工程化 | 7 月 | 博客 v2.1 + 知屋 1.0 | 构建管线、安全审查、全栈书房 |
| ⑥ 新篇 | 8 月初 | 光子遥控 + 博客 v2.2 | Kotlin、Compose、红外协议、安全加固 |
下面按项目和阶段展开。
一、个人博客:从 v1.0 到 v2.2
博客是我所有项目的「大本营」——文章在这里发,其他项目的开发过程也在这里记录。
v1.0:先跑起来(2 月)
技术栈很早就定了:Next.js 15 + Contentlayer2 + Tailwind + Cloudflare Pages 静态导出。
选静态导出是有意为之:没有服务端,部署简单,攻击面小。评论用 Giscus(GitHub Discussions),搜索先用 kbar 匹配标题。
v1.0 解决的是「有没有」的问题。深色模式、RSS、Sitemap、基础安全头(CSP、HSTS)都在这一版落地。
那会儿我对前端几乎一无所知,是照着 mengke.me 这个开源项目一步步改造的。Fork → 换文案 → 换配色 → 加功能,就像在别人的地基上盖自己的房子。后来我才慢慢意识到,「会改别人的代码」和「会设计自己的架构」之间,隔着半年时间的距离。
v1.1–v1.2:功能补全 + 液态玻璃(4 月)
4 月是博客改动最密集的一个月之一,commit 数量能到几十条。主要方向:
交互与阅读
- 阅读进度条、增强 TOC(移动端抽屉)
- 相关文章评分算法、系列导航(上一篇/下一篇/进度)
- 键盘快捷键帮助面板、Changelog 页、友链申请表
- Service Worker 离线支持、构建期 OG 图生成
液态玻璃设计系统
全站 UI 从手写 glass 样式迁移到统一的 liquid-glass 四层体系。核心是 CSS 变量 + backdrop-filter,不依赖任何第三方库:
: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;
}四个层级对应不同场景:liquid-glass(卡片/列表)、liquid-glass-elevated(导航栏/模态框)、liquid-glass-subtle(次要区块)、liquid-glass-input(表单,带 focus 光晕)。深浅色模式靠 .dark 下的变量覆盖,一套 CSS 两套皮肤。
还做了滑动 Pill 导航指示器(spring easing 药丸动画),以及中文标签 404 的 Cloudflare Pages 路由兼容。
项目结构规范化
css/ → styles/,JSON 数据迁入 data/,图片目录统一为 covers/、banners/、posts/。写了 CONTENT_GUIDE.md,以后发文章有章可循。
这个月我最深的体会是:好看的 UI 不是玄学,是 CSS 变量的分层管理。把颜色、模糊度、透明度抽象成变量之后,「换皮肤」就只是改几个数字的事。
v1.3:SEO、About 与视觉(5 月初)
- AI 爬虫权限(GPTBot、ClaudeBot 等)与
llms.txt自动生成 - About 页重设计:Profile 卡片 3D tilt、流动背景动画
- 一言(Hitokoto)迁入 Footer,30 秒自动刷新
- Moments 数据源统一为单一
moments.json
v2.0:从「能用」到「好用」(5 月底)
这是博客迄今最大的一次功能爆发,50+ 项改进、70+ 文件、9 次集中 commit。详细过程见 博客大升级 v2.0,这里只列骨架:
| 方向 | 代表能力 |
|---|---|
| MDX 组件库 | Callout、Tabs、Steps、Accordion 等 15 个 |
| 搜索 | Pagefind 构建期全文索引,支持中文 |
| 阅读 | 字体档位、专注模式、位置记忆、TTS、难度标签 |
| 分享 | 微信/QQ/微博等 7 渠道 + 选中文字分享 |
| 视觉 | View Transitions、终端 404、暗色模式过渡动画 |
| 移动 | 滑动手势切文、PWA 安装提示、J/K 键盘导航 |
v2.0 的教训也很明确:不要过度工程化(曾想用本地 AI 做语义搜索,最后 Pagefind 够用);Sandpack 在线运行代码块因加载不稳定被移除;trusted-devices.json 误提交敲了一次安全警钟。
v2.1:好维护、好审计(7 月)
高考后的暑假,博客进入「工程化」阶段——不大改 UI,而是让构建更快、边界更清晰:
构建期 Mermaid
文章里的图表在 Contentlayer 编译时用 JSDOM + Mermaid 预渲染成 SVG,亮/暗双主题各一份,DOMPurify 清理输出。读者侧零 JS 渲染、零闪烁。失败则回退客户端组件,不阻断发布。
构建并发
run-with-concurrency.ts 按 CPU 核心数限制图片优化与 OG 生成的并行度(2–6 路),避免 Promise.all 打满内存。
构建前安全自检
新增 scripts/security-audit.ts,在每次构建前自动跑一遍。核心逻辑很朴素——就是检查清单 + 正则扫描:
// scripts/security-audit.ts(节选)
const required = [
'Content-Security-Policy',
'Strict-Transport-Security',
'X-Frame-Options',
'X-Content-Type-Options',
'Referrer-Policy',
'Permissions-Policy',
]
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
]检查项包括:_headers 是否含全套安全头、工作区是否存在 .env 等敏感文件、源码中是否出现密钥模式、Service Worker 是否只缓存同源 GET。有错误就 process.exit(1) 阻断构建——把「安全」从自觉变成强制。
内容与元数据
- 全文 RSS(而不只是摘要)
schema-dts生成类型安全的BlogPostingJSON-LDremark-smartypants排版插件
又踩过的坑
- kbar 打开搜索时导航 Pill 位移:根因是滚动条被隐藏导致布局宽度变化。最终解法是
ResizeObserver监听容器尺寸重算指示器位置:
// components/header/index.tsx(节选)
const updateIndicator = () => {
const active = containerRef.current?.querySelector('[data-active]')
if (active) {
indicator.style.width = `${active.offsetWidth}px`
indicator.style.transform = `translateX(${active.offsetLeft}px)`
}
}
const observer = new ResizeObserver(() => updateIndicator())
observer.observe(containerRef.current)Image组件误删overflow-hidden:Logo 和 Profile 卡片圆角变方,已恢复。
v2.2:安全加固 + 新功能(8 月初)
做完光子遥控之后,我回头给博客做了一轮「年度审计」——用 AI 帮我把整个站点的安全、依赖、性能、SEO 从头过了一遍,一次提交 55 个文件的改动。这大概是第一次用「审计」而不是「加功能」的视角来对待自己的项目:
安全加固
- 供应链 CVE 修复:通过
pnpm-workspace.yaml的 overrides 把 postcss、sharp、picomatch、ws 强制升级到安全版本,pnpm audit --prod高危清零 - CSP 大收紧:
img-src限制为'self' data: blob: https:,frame-src只保留视频平台白名单并补上 vimeo.com,新增frame-ancestors 'none'(防点击劫持),HSTS 加preload - JSON-LD 里的
</script>转义(防止标题注入破坏页面结构) - pagefind 搜索摘要加 DOMPurify 净化(防存储型 XSS)
- Service Worker 缓存策略升级:缓存名 bump 到 v2,HTML 请求加
response.ok校验(防缓存 404 页面) search.json精简:把 filePath、structuredData 等冗余字段从输出剥离,避免暴露内部路径- CodeQL workflow 的 GitHub Action 全部 pin 到完整 SHA(供应链安全)
- 清理
GITHUB_API_TOKEN环境变量(已弃用)和一堆死代码
性能
- LCP 图片加
fetchpriority="high"(首页头像、文章封面提前加载) - Giscus 评论区改为 IntersectionObserver 懒加载 + 点击按钮兜底(首屏不再加载评论 iframe)
- 文章正文加
content-visibility: auto(浏览器跳过屏外区块的渲染) - 给实际用到的外部域名加 preconnect(giscus.app、hitokoto 等)
SEO
- OG 图升级为多尺寸(1200×630 + 1方形)
- BlogPosting 结构化数据补
wordCount、inLanguage、dateModified - About 页新增 ProfilePage JSON-LD(作者信息对搜索引擎更友好)
新功能
- Photos 照片墙页面:8 月新页面,支持 4 种媒体类型(静态照片 / Live Photo / 动图 / 视频),瀑布流 + lightbox + 长按播放,媒体直接打包进仓库
- 搜索升级:kbar 集成 fuse.js 模糊搜索,中文错字、拼音都能命中(原来只能精确匹配标题)
- 分享扩展:从 7 渠道扩到 32 平台(内嵌 17 个常用 + 更多面板 14 个)
依赖升级
14 项依赖安全升级(react 19.2.8、next 15.5.22、prettier 3.9.6 等),全部逐组升级、每步构建回归,最后 pnpm audit --prod 0 high/critical。
这次审计让我意识到:项目做久了,「维护」本身就是一种能力。不加新功能、只做减法,反而让整个站点更健康。
二、TimeMark:第一个 Docker 全栈项目
TimeMark 是智能事件提醒系统——农历生日、纪念日,多渠道通知(微信、QQ、钉钉、邮件等三十多种)。
完整开发故事见 TimeMark:我的第一个 Docker 项目。复盘里只提炼架构演进,因为这是半年里最重要的技术认知之一。
v1.x:三容器的「豪华配置」
第一版:PostgreSQL + Redis/Bull + 应用,三个容器、约 800MB 内存。
选型时觉得「专业」——正经数据库、业界标准队列。NAS 用户反馈很直接:低功耗机器上跑不动,一个提醒工具何必这么重。
v2.0:大道至简
两周重写后端:
| 项目 | v1.x | v2.0 |
|---|---|---|
| 数据库 | PostgreSQL 容器 | sql.js(单文件 SQLite) |
| 任务调度 | Redis + Bull | Croner(纯 JS cron) |
| 部署 | 3 容器 | 1 容器 |
| 内存 | ~800MB | ~256MB |
v2.1 又给所有环境变量加了内置默认值,docker compose up -d 后零配置即用——因为家庭内网场景下,「强迫用户生成随机密钥」本身就是设计错误。还有云平台版(Vercel + PostgreSQL + Serverless),给没有 NAS 的场景用。
从 TimeMark 学到的
- 先想清楚用户场景,再选技术——家庭用户要的是简单可靠,不是架构炫技
- monorepo + 共享类型——前后端共用
Event等类型定义,改一处两边生效 - 踩坑密度高——Docker Hub 用户名改六次、镜像名拼写、GHCR 路径……都是真实发布才会遇到的问题
- 换数据库不是换驱动——PostgreSQL → SQLite 时
TO_CHAR()、布尔值、日期排序全都不一样,SQL「标准」在不同数据库里各有怪癖 - 通知渠道用适配器模式——35+ 渠道统一抽象成一个接口,新增渠道只写一个 adapter,这个设计让我后来做红外协议编码器时受益很大
三、Web 游戏:敢不用框架
高考前不到一个月,我用 纯 Vanilla JavaScript 做了两款游戏,零依赖、零框架。详见 从弹幕射击到经典扫雷。
星域战机(弹幕 STG)
- 纯 JS 约 11,500 行,含配置近两万行
- 20 流派 × 200 技能 × 20 武器 × 40 道具,数据驱动
- 引擎与内容分离:新流派只改
config.js,不动引擎
核心技术点:Canvas 渲染、requestAnimationFrame 游戏循环、对象池复用子弹/粒子、Web Audio API 音效。
对象池是弹幕游戏不卡顿的关键——屏幕上几百颗子弹飞,如果每颗都 new,GC 会疯掉。整个游戏运行中 new 只发生在最开始:
class ObjectPool {
constructor(factory, initialSize) {
this._items = []
this._factory = factory
for (var i = 0; i < (initialSize || 0); i++) {
this._items.push(factory())
}
}
pop() {
return this._items.length > 0 ? this._items.pop() : this._factory()
}
push(obj) {
this._items.push(obj)
}
}还有游戏循环的「死亡螺旋」——切标签页再切回来,浏览器暂停了 requestAnimationFrame,回来时 deltaTime 飙到几秒,所有物体瞬间飞出屏幕。解法是一行 Math.min(rawDt, 50),超过 50ms 的帧一律按 50ms 算:游戏可能短暂变慢,但不会崩。
经典扫雷
- 像素级还原 Windows XP/7 扫雷
- BFS 洪水填充展开空白区、无尽模式、成就、PWA 离线
- 三天完成,验证「小项目也能打磨细节」
为什么做游戏
博客和 TimeMark 都站在框架肩膀上。我想证明:不依赖 React/Next,也能做复杂交互。 对象池、BFS、状态机——这些在框架里常被封装,自己写一遍才真正理解。后来做光子遥控的红外协议编码器时,这种「从底层自己造」的感觉又回来了——原来框架和库帮你藏起来的细节,才是真正值钱的知识。
四、知屋:从阅读器到 1.0 书房
知屋是私人阅读与笔记项目,和博客的静态架构完全不同:
静态页面(Astro) + Serverless API(Hono) + Redis 会话 + 对象存储 + Git LFS 书库
博客是「写出来给人看」;知屋是「用起来管自己的书与笔记」。闭源、需登录,不对外公开仓库与部署地址。
7 月集中迭代做了什么
阅读与内容
- EPUB/PDF 阅读、划线批注、三栏布局阅读器
- 从 PagePulse 迁移教材目录、FSRS 间隔复习、闪卡与词汇
- 书架按章节正文含量自动分级(书籍 vs 学习资料)
- 内置教材封面本地化、章节纲要逐节正文
AI
- 从本机 ONNX 逐步迁到云端流式代理(用户 Key 加密存 Redis,不进部署环境变量)
- 多会话对话 UI、停止/重试、提示词缓存
- 后期精简 Agent 提示词与权限边界
存储
- Vercel Blob → Cloudflare R2 优先,统一
cloud-store抽象层——换存储厂商只改一个实现,上层代码不动 - 私有书库 Vault(Git LFS)存 PDF/笔记/进度,与前端代码仓分离,无公开 raw 链接
安全(三轮加固)
- localStorage 会话 → HttpOnly Cookie
- 人机验证(Turnstile)、CORS 绑定正式域名
- 本地 security-probe 自检脚本
- 修复登录重复弹窗、OpenList 兼容性等
客户端
- Android / Windows 原生壳(加载线上站点,WebView 行为与浏览器一致)
知屋让我第一次系统面对有用户数据的后端安全:静态博客扫一遍 _headers 就够;全栈项目要管会话、存储、AI Key、跨域、探针,完全是另一个量级。三轮 fix(security) 才稳住,每一轮都是被真实问题(或者说,被「万一被别人搞」的想象)逼出来的。
五、光子遥控:第一个原生 Android 项目
这是 8 月初刚推出的项目,也是我的第一个原生 Android 应用——之前的所有项目都在 Web 技术栈里打转。
为什么做
家里遥控器太多了:电视一个、机顶盒一个、空调一个、风扇一个,每个都长得差不多,还总找不到。市面上万能遥控 App 不少,但要么广告满天飞,要么机顶盒运营商码库不全。于是决定自己写一个。
光子遥控是一款安卓万能红外遥控器:把电视、机顶盒、空调、风扇等家电装进手机里,中国国内市场优先,完全离线可用。
技术栈
| 层面 | 技术 | 说明 |
|---|---|---|
| 语言 | Kotlin 2.1.20 | |
| UI | Jetpack Compose + Material 3 | 动态取色 / 深浅色 / 纯黑 AMOLED / 强调色 |
| 自适应 | material3-adaptive-navigation-suite | 手机 Compact 与平板 Expanded 双形态 |
| 数据库 | Room(设备 / 按键 / 宏三张表) | |
| 配置存储 | DataStore Preferences | |
| 序列化 | kotlinx.serialization | 按键动作 / 布局 / 备份 |
| 红外发射 | ConsumerIrManager + USB + 音频转红外 | 三路自动路由 |
| 码库解码 | IREXT(JNI 离线解码二进制码库) | MIT 协议 |
| 测试 | JUnit 4 + Mockito + Robolectric | 211 个单测全通过 |
14 种红外协议编码器
红外遥控最底层的知识:每种协议定义了载波频率、前导码、位时序。比如最经典的 NEC 协议——38kHz 载波,前导 9000/4500 µs,逻辑 0 是 562/562 µs,逻辑 1 是 562/1687 µs,每字节 LSB 先发。
我把这 14 种协议(NEC 家族 / RC5 / RC6 / SONY / SAMSUNG / SHARP / JVC / KASEIKYO / PIONEER / RAW)全部自研实现,每个协议一个文件,接口统一:
// ir/core/ProtocolType.kt
enum class ProtocolType {
NEC, NECX1, NECX2, RC5, RC6, SONY12, SONY15, SONY20,
SAMSUNG32, SHARP, JVC, KASEIKYO, PIONEER, RAW
}以 NEC 编码器为例——输入一个 hex 码串,输出一串 mark/space 时间间隔(微秒):
// 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, '0')
require(normalized.length <= 8) { "NEC 码最多 8 位十六进制: $hex" }
// 每字节 LSB 先发:0x00FF12ED → 4 字节,每字节低位在前
val bytes = normalized.chunked(2).map { it.toInt(16) }
val bits = bytes.flatMap { b -> (0..7).map { (b shr it) and 1 } }
val list = mutableListOf<Int>()
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())
}
}这段代码看起来简单,但每个数字背后都是真实硬件的行为约束:ConsumerIrManager.transmit 会阻塞到整帧发完(NEC 补零后最长 108.8ms),所以绝不能在主线程调用;长按重复帧必须和完整帧区分开,否则长按音量键会卡死界面。
单线程发射调度器
宏、暴力找码、长按连发可能同时触发发送,而 IREXT 的 JNI 解码是共享原生状态的单例——并发调用会互相污染。所以所有红外发送和 IREXT 解码都走一个单线程队列:
// ir/transmitter/IrDispatcher.kt(节选)
class IrDispatcher(
private val transmitFn: suspend (IRPattern) -> Boolean,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
) {
private val queue = Channel<suspend () -> Unit>(Channel.UNLIMITED)
init {
scope.launch { for (task in queue) task() } // 常驻消费协程,天然串行
}
suspend fun send(pattern: IRPattern): Boolean =
onQueue { transmitFn(pattern) }
suspend fun <T> onQueue(block: suspend () -> T): T {
val deferred = CompletableDeferred<T>()
queue.send { deferred.completeWith(runCatching { block() }) }
return deferred.await()
}
}原理很简单:一个 Channel 队列 + 一个常驻消费协程,所有任务进去后逐个执行,天然串行。CompletableDeferred 让调用方能拿到结果,runCatching 保证某个任务炸了不影响队列后续任务。
机顶盒省市运营商三级筛选 + 离线码库
中国市场的机顶盒码库按「省份 → 城市 → 运营商(移动/联通/电信/广电)」组织,再结合定位自动匹配。码库来自 IREXT 的开源实现(MIT 协议,JNI 离线解码二进制码库),加上精选的 irdb CSV,完全离线内置,飞行模式下也能用。
其他亮点
- 宏:把多个设备的多步按键串成一条宏(如「电视开机 → 调音量 → 机顶盒确定」),可调步间延迟、可中途停止
- 暴力找码:协议 + hex 前缀约束,自动迭代发码,设备一响应就保存为按键
- 导入导出:Flipper
.ir、LIRC.conf、JSON 全量备份,逐条校验,坏记录跳过不中断 - 无红外扩展:USB 红外外设(RLE 帧收发、热插拔重连)和音频转红外适配器(AudioTrack 192kHz 合成 38kHz 载波)
- 211 个单元测试:协议波形(前导/位序/帧尾/总长)、数据层、调度器、序列化全覆盖
从光子遥控学到的
- 「简单」的底层往往最难——一个 NEC 编码器 40 行,但它是被真实硬件约束逼出来的:阻塞时长、重复帧、补零,全是查协议文档 + 实机测试得来的
- 共享原生状态要串行化——JNI 单例 + 并发调用 = 状态污染,一个 Channel 队列解决
- 测试先行在协议层最值钱——波形是纯函数,输入输出确定,211 个单测里协议测试占了很大比重,改起来心里有底
- 国产市场的坑——运营商机顶盒码库、省市筛选、定位匹配,这些「本地化」需求是海外开源项目永远不会替你考虑的
六、其他项目(简表)
| 项目 | 类型 | 技术要点 |
|---|---|---|
| LinkRoom | 桌面工具 | C#、EasyTier P2P 局域网联机 |
| 知屋 Vault | 私有数据仓 | Git LFS、manifest 索引,仅供知屋拉取 |
| TimeMark 云平台版 | Serverless | PostgreSQL、Cron Jobs,免 Docker |
| 小米平板系统补丁模块 | 系统模块 | Smali 反编译补丁,Magisk 生态 |
| 给设备编译驱动模块 | 系统模块 | 给自己的设备编译适配驱动(如 GPU),不是编译完整驱动 |
这些在博客 Projects 页有卡片展示;闭源项目只写能力描述,不附仓库或线上链接。
顺带说一句:上面提到的「给设备编译驱动」,是给自己的设备编译适配——比如给我的小米平板编一个能用的 GPU 驱动模块,配合 Magisk 刷进去用。这不是从零编译完整驱动(我也没那个能力),而是在开源驱动源码基础上做配置、打补丁、适配到我的设备上。折腾设备这件事,本来就不是「从零造轮子」,而是把别人造好的轮子装到自己车上,并且让它转起来。
七、横向对比:我怎么做技术选型
半年里五个主项目,架构差异很大,但选型逻辑有迹可循:
| 项目 | 渲染/部署 | 数据 | 为什么这样选 |
|---|---|---|---|
| 博客 | 静态导出 + CDN | 无(MDX 文件) | 内容站、零运维、安全面小 |
| TimeMark Docker | 单容器 | SQLite 文件 | NAS 用户要备份简单、内存省 |
| TimeMark 云 | Serverless | PostgreSQL | 无 NAS 也要能用 |
| Web 游戏 | 纯静态 HTML/JS | 无 | 练底层、零构建链 |
| 知屋 | 静态 + API | Redis + R2 + LFS | 要登录、要同步、要大文件 |
| 光子遥控 | 原生 App | Room(本地) | 要离线、要硬件能力(红外) |
一个越来越清晰的原则:用户是谁、数据存在哪、要不要登录——三个问题先答了,技术栈就不会偏太远。到了光子遥控,答案变成了:要不要碰硬件、要不要离线——要,那就原生 + 本地库 + 离线码库,别想用 Web 壳糊弄。
八、安全:三种完全不同的工作量
静态博客
攻击面主要是 XSS(MDX、Giscus)、供应链依赖、CSP 配置。v2.1 加了构建前 security-audit.ts(代码见上文),v2.2 又做了一轮完整加固(CVE overrides、CSP 收紧、JSON-LD 转义、DOMPurify、sw.js 缓存策略),检查项包括:
_headers是否含 CSP、HSTS、X-Frame-Options 等- 工作区是否存在
.env、误提交的敏感文件 - 源码中是否出现
ghp_、sk-等密钥模式 - Service Worker 是否只缓存同源 GET
pnpm audit --prod是否 0 high
已知权衡:Next.js 静态导出需要 CSP unsafe-inline;img-src * 方便文章外链图——都是可接受的残余风险。
全栈应用(知屋)
HttpOnly Cookie、Turnstile、CORS 收紧、AI Key 加密、R2 权限、安全探针脚本……三轮 fix(security) 才稳住。有后端就必须按后端的标准做,不能套用静态站的检查清单。
原生 App(光子遥控)
又是另一种维度:开源协议合规。App 内置了 IREXT(MIT)、irdb(宽松许可)、Flipper IRDB(MIT)三套第三方码库,README 里逐一声明出处;明确排除了 AGPLv3 的 mi_remote_database——选型时就把许可不兼容的库挡在门外,比事后换库便宜得多。
九、踩坑合集(跨项目)
把各项目里印象最深的坑放在一起,方便以后翻:
- 过度设计 — TimeMark v1 三容器;博客曾想用本地 AI 搜索。解法:先 MVP,再按需加。
- 像素级 UI bug — kbar 位移、圆角被
overflow-hidden误删。解法:模态框 + 居中布局要测滚动条;改 UI 组件要看回归。 - 发布细节 — Docker 镜像名拼错、Hub 用户名多次调整。解法:CI 里固定镜像名,文档写清推送步骤。
- 静态站 CSP — 想要严格 nonce,但没有服务端。解法:接受
unsafe-inline或维护 hash 列表(成本高)。 - 死代码拖延 — Sandpack 移除 Run 按钮后依赖留了几个月。解法:功能砍掉就删依赖,别「以后可能还用」。
- 安全文件误提交 — trusted-devices.json。解法:
.gitignore+ 构建前扫描。 - JNI 状态污染 — 光子遥控的 IREXT 是共享原生单例,并发解码会互相污染。解法:所有解码走单线程队列(IrDispatcher)。
- 阻塞型硬件调用 —
ConsumerIrManager.transmit会阻塞到整帧发完(最长 108.8ms)。解法:绝不在主线程调用,统一走后端调度器。 - 供应链漏洞 — 传递依赖(postcss、sharp 等)有 CVE。解法:pnpm overrides 强制升级,audit 常态化。
- 缓存了 404 — sw.js 缓存了失败响应,离线时页面错乱。解法:缓存前检查
response.ok。
十、数据印象(不追求精确)
| 维度 | 博客 | TimeMark | 游戏 | 知屋 | 光子遥控 |
|---|---|---|---|---|---|
| 时间跨度 | 2 月–8 月持续 | 4 月–5 月主开发 | 5 月约两周 | 7 月集中爆发 | 8 月初(进行中) |
| 主语言 | TypeScript | TypeScript | JavaScript | TypeScript | Kotlin |
| 体量感 | 100+ commit(半年) | 全栈 + Docker | ~1.5 万行 JS | 全栈 + 存储 | 单测 211 个 |
| 开源 | 私有仓库 | 开源 | 开源 | 闭源 | 开源 |
博客 Changelog 从 v1.0.0 记到 v2.2.0,光 5 月底 v2.0 单次升级就 50+ 项改进——这些数字说明的是迭代频率,不是炫耀代码行数。
十一、如果重来,我会怎么排期
诚实说,有些顺序我会调整:
- 博客 v2.0 可以拆成两期 — MDX 组件 + Pagefind 一期,分享/动画一期,避免一次改太多难以回归测试。
- TimeMark 应该更早做单容器 — v1 的三容器架构本可跳过,直接听 NAS 用户场景。
- 知屋 AI 路径应更早定云端 — 本机 ONNX 在 Vercel 构建体积和 Edge 跟踪防护下走了很多弯路,最后仍迁到服务端代理。
- 文档与博客同步写 — TimeMark 和游戏的专文是事后补的;边做边写能少靠 git 回忆决策原因。
- 安全审计应该定期做,而不是等大版本 — v2.2 的 55 文件大改动如果拆成每月一次的小审计,风险会更小。
反过来,不会改的选择:博客坚持静态导出、游戏坚持 Vanilla JS、TimeMark v2 砍 Redis/Postgres、知屋闭源且数据与代码分仓、光子遥控坚持自研协议编码器(而不是直接套现成库)——自己把 NEC 波形写一遍,才知道那些开源库帮你藏了多少细节。
十二、下半年想做什么
不列具体地址,只列方向:
- 博客:继续写文章,CSP 与依赖审计常态化,少堆新功能、多打磨阅读体验
- 知屋:1.0 后的文档与复习闭环,存储配额与 LFS 策略长期可维护
- TimeMark:跟用户反馈迭代通知渠道与部署体验
- 游戏:高考后若有空,给扫雷加更多主题或给 STG 做平衡性调参
- 光子遥控:继续补全 UI 细节与更多设备码组,把离线码库的维护流程跑通
- 折腾设备:继续给我的设备编译适配驱动模块、玩 NAS,把「折腾」写进博客
总结
这半年多,我不是在「学一门技术」,而是在反复经历同一条曲线:
做一个东西 → 觉得不够好用或大改 → 踩坑 → 写文档或博文 → 下一个项目带着上次的教训
博客教会我前端工程化和设计系统;TimeMark 教会我别为用技术而用技术;游戏教会我框架之下还有什么;知屋教会我有数据就要有安全模型;光子遥控教会我最底层的协议里藏着最硬的知识——以及,选型时要先把开源协议的许可看清楚。
如果你也在读书、做作业之余做 side project,我的建议只有一句:做给自己用的东西,然后把踩坑写下来。 半年后回头看,会比只记得「做过某某项目」有用得多。
更多分项细节可参阅本系列其它文章:博客搭建之旅、博客 v2.0 升级、TimeMark Docker、Web 游戏开发手记,以及站内 Changelog。
