<?xml version="1.0" encoding="UTF-8"?><rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Grtsinry43 的前端札记</title>
    <link>https://blog.grtsinry43.com/</link>
    <description><![CDATA[一名大三前端学习者的实战记录，分享JavaScript/React/Vue技术解析、项目复盘总结、编程学习心得，持续输出Web开发学习记录，助力新手少走弯路。「路虽远行则将至」这里记录Web开发学习笔记 | 技术踩坑指南 | 成长思考碎片  总之岁月漫长，然而值得等待。]]></description>
    <language>zh-CN</language>
    <managingEditor>grtsinry43@outlook.com (grtsinry43)</managingEditor>
    <pubDate>Sun, 19 Apr 2026 15:35:46 +0000</pubDate>
    <lastBuildDate>Sun, 19 Apr 2026 15:35:46 +0000</lastBuildDate>
    <generator>grtblog v2.1.0-beta.14</generator>
    <image>
      <url>https://dogeoss.grtsinry43.com/img/author.jpeg</url>
      <title>Grtsinry43 的前端札记</title>
      <link>https://blog.grtsinry43.com/</link>
    </image>
    <atom:link href="https://blog.grtsinry43.com/feed" rel="self" type="application/rss+xml"/><follow_challenge><feedId>95323440721463296</feedId><userId>77652777463264256</userId></follow_challenge><item>
      <title>路的尽头还是路</title>
      <link>https://blog.grtsinry43.com/moments/2026/04/19/never-ending-ways</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/moments/2026/04/19/never-ending-ways">https://blog.grtsinry43.com/moments/2026/04/19/never-ending-ways</a></p></blockquote><p>累。</p>
<p>拿到了 offer，事情貌似在向好发展，按理说应该松一口气了。可就是累。那种弥散在空气里的疲惫，似乎常伴左右了。</p>
<p>这学期的课表像上辈子欠的债一次性来还。骨折落下的课要重修，小组作业排着队，垃圾水课一大堆，考试一门接一门。</p>
<hr>
<p>之前看到一个鸡汤，说<em>坚持下去，就能熬到想要的生活</em>。</p>
<p>嗯，说得真好。</p>
<p>小时候觉得，长大就好了。
高中的时候想着，高考完就好了。
上了大学觉得，放假就好了。
然后是，找到实习就好了。
然后是，坚持到最后就好了。</p>
<p>然后呢？</p>
<p>然后就该上班了。上班之后怎么回事，还不知道呢。</p>
<p>打算回到原来的组了。可是回去就能转正么？毕业之前还能不能出去走一走？工作之后又是什么光景？前面永远还有下一关，接下来要面对的东西只会更重——社会，柴米油盐，一整个世界。</p>
<p>&quot;就好了&quot;这三个字，好像从来没有兑现过。永远在前面一步。我走一步，它也走一步。以为快到了，抬头一看，还是那么远。</p>
<p>可人就是这样吧，明明知道没有终点，还是步履不停。停不下来。可是也不敢停。</p>
<hr>
<p>上一周和朋友玩了好久的 Minecraft。</p>
<p>挖矿、建房子、然后掉进岩浆装备都烧没了从头再来，一次次刷凋灵骷髅头想办法合成信标。什么 offer，什么入职流程，什么课设考试，全部不存在了。脑子里只剩方块世界，和朋友的笑声。</p>
<p>庆幸自己还有打游戏的最高配置，还有一群一起疯的朋友，就是几个人坐在那里，在一个虚拟的世界里，做着一些完全没有意义但快乐得不行的事情。</p>
<p>然后退出游戏，一下午晚上错过了非常多消息。</p>
<p>有 HR 又加我了，入职材料催了。课设群里有人 @all。考试的时间也马上到了。</p>
<p>像从一个很暖的梦里被闹钟震醒。于是从虚拟回到现实，走进鸡飞狗跳的生活。</p>
<p>开始越来越珍惜和朋友同学待在一起的时间。以前觉得这种事情稀松平常，现在才发觉，能什么都不想、就单纯坐在一起笑，是很奢侈的事情了。</p>
<hr>
<p>说实话，从来没有这么想家过。</p>
<p>哪怕是之前受伤，一个人在广州拄着拐杖坐火车回来，都没这么想。那时候心里还在憧憬，觉得外面有更大的世界，觉得再撑一撑就能看到。</p>
<p>现在...&quot;更大的世界&quot;走近了，灰蒙蒙的。就只是很想回家，躺在自己的床上，把门一关，什么都不用管。</p>
<p>看来又得等到寒假了。十个月。</p>
<p>十个月，好长。</p>
<hr>
<p>我这个人吧，脑子里永远同时在想好几件事。</p>
<p>四象限法试过，把想法写纸上也试过，番茄钟也试过，冥想也试过。每种方法都管用——也许三天，也许三分钟。然后焦虑就从所谓“方法论”的缝隙里重新长出来，比之前还茂盛。</p>
<p>道理没用啊，脑子并不会听话。想睡觉的时候它提醒你课设没做，做课设的时候它提醒你考试没复习，复习的时候它又提醒实习工作。一个永远关不掉的后台进程，就这样在我的想法里肆意的内存泄露（？。建议人脑支持 <code>kill -9</code></p>
<p>焦虑大概是赶不走了。看来只能和谐共生了</p>
<hr>
<p>写到这里也没什么结论。</p>
<p>什么也没有改变。写完这些字，生活依旧。</p>
<p>好想一直和朋友疯下去，是真的很开心。</p>
<p>大概就是这样吧。大部分时间在嗡鸣声里赶路，偶尔能在一个方块世界里停一停，挖挖矿，建建房子。然后带着那一点余温，继续走。</p>
<p>好在我还有幸福在的，</p>
<p>虽然也没有面朝大海，春暖花开</p>
<p>但是还是得坚强，就像以前一样，</p>
<p>或许，且将新火试新茶，诗酒趁年华，才是对的</p>
<p>从来不是为了未来而忍受现在，而是，现在就是为了现在。</p>
<hr>
<p>不知道最近我的文笔为啥有点变成这样了，还是想坚持手写，因为手写的文章才有温度，但是感觉根据最近心情写的文字太苍白无力了，满满都是负情绪。</p>
<p>...等最近尝试全程 vibe 的项目写完的吧，写一篇文章记录下全程，也算是尝试跟紧时代了。</p>]]></description>
      <author>grtsinry43</author>
      <guid>moment-18</guid>
      <pubDate>Sun, 19 Apr 2026 15:35:46 +0000</pubDate>
    </item>
    <item>
      <title>二十一岁，然后......</title>
      <link>https://blog.grtsinry43.com/moments/2026/04/03/21-years-old</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/moments/2026/04/03/21-years-old">https://blog.grtsinry43.com/moments/2026/04/03/21-years-old</a></p></blockquote><p>去年写了篇《致二十岁的晨光与希望》。emm 很漂亮的标题对吧，晨光，希望，又比喻又排比，一听就是那种对未来充满热忱的年轻人会写出来的东西，我还记得当时写了好久，穷尽词藻。</p>
<p>::: link-card href=&quot;/moments/2025/04/03/hello-20-years-old/&quot; title=&quot;致二十岁的晨光与希望&quot; desc=&quot;此刻，且让年轻的热望继续野蛮生长。因为每个不曾起舞的昨日，都在为明天的腾跃积蓄力量；每个尚未拆封的黎明，都藏着命运馈赠的礼物。&quot; newtab=&quot;true&quot;</p>
<p>:::</p>
<p>今年本来也想起一个这样的标题，想了半天，发现脑子里只剩下&quot;啊？&quot;</p>
<p>坐在电脑前想了十分钟，看了看之前的文章，又看了看 TODO List，放弃了。</p>
<p>主要是今年的状态跟去年真的不一样。去年那个时候算是初生牛犊不怕虎，觉得二十岁了，不一样了，要写点什么纪念一下。觉得不确定是浪漫的，觉得未来虽然看不清但是闪闪发光的，觉得自己只要一直跑一直写就能到达什么地方。</p>
<p>然后这一年就……嗯，经历了亿些事情。开年重写了博客，发现 AI 写代码完全爆杀自己，春招开始投简历。年终总结写了一大篇，你们有兴趣自己翻，我就不在这里复读了。</p>
<p>日历又到了新的日期，手机弹出来一个提醒，哦，又大了一岁。</p>
<p>然后呢？</p>
<p>然后我发现我说不出&quot;然后&quot;。</p>
<p>去年写的那些话，什么&quot;不确定性是未来惊喜的伏笔&quot;，什么&quot;有些答案留给二十五岁去拆封&quot;，现在读起来，怎么说呢，不是觉得矫情，是觉得那个人真的好勇敢。他还相信有个叫&quot;答案&quot;的东西在前面等他哦（。</p>
<p>二十一岁的我想跟他说：兄弟，你想多了，先活过这一年再说。</p>
<p>按理说应该有点什么感慨吧。但说实话，从过完年到现在，每天忙得跟陀螺似的，根本没有时间去&quot;感慨&quot;。感慨是需要闲下来才能做的事情，而我最近的闲下来大概就是刷机的那个下午。<del>小米 17 的 bootloader 比我的人生好解锁多了。</del></p>
<p>那就趁今天生日，勉强闲一会儿，写点东西吧。</p>
<h2>近况</h2>
<p>找实习。投简历，等回复，挂了，继续投。面试的风格今年变了，不怎么考手搓算法了，全是拷问底层八股，蚂蚁还搞了 AI Coding 笔试，挺魔幻的。</p>
<p>博客 v2 总算上线了，Go 后端，新设计系统，问题一大堆但是能用。上个月写了篇《我患上了 token 的瘾》，写完瘾没戒掉，反而更理直气壮地找朋友借 Claude 了。</p>
<p>身体恢复得差不多了，正常走路没问题。<del>下楼梯会多看两眼，原来 PTSD 是这么来的。</del></p>
<p>每天日程排得爆炸，课、项目、面试准备、各种杂事。忙完躺床上回想今天干了啥，脑子一片空白。</p>
<h2>想说的</h2>
<p>额</p>
<p>因为我不太确定我想说什么。<del>去年搞什么小作文只需要读起来舒服就好了，今年考虑的就多了。</del></p>
<p>最近的状态就是，事情很多，非常多，多到你本不敢停下来想。春招的事，项目的事，课程的事，技术的事，未来的事，全堆在那里。知道每一件都该做，每一件都不能拖，每一件都跟未来有关系。</p>
<p>然后很想逃避，算是很本能的，看到一堆事情摆在面前，第一反应是能不能暂时先不面对。想再刷会儿手机，想再折腾一下没用的东西，想再赖一会儿。</p>
<p>...但是话说回来这些事情它们就在那儿等着。而且以后只会更多不会更少。</p>
<p>去年我觉得长大是我自己选的。<del>我要学技术，我要进大厂，我要变强，我要证明自己。</del></p>
<p>今年发现，不是的。长大不问你准不准备好。问题来了，面试来了，ddl 来了，挫折来了，该学的东西来了，AI 更新了你不跟就掉队了。你来不来？你不来它也来。你没准备好？无所谓，谁会在乎你。</p>
<p><del>大概这就是从少年漫画进入职场剧的感觉吧。</del></p>
<p>只是方向我是有的——学习，进大厂，以后做点能帮到别人的东西，文艺（？一点叫什么“点亮别人”。这个想法从很早就有了，也算是让我撑下来。</p>
<p>但方向是远处的事。远处的东西想想就好，今天的事情还是得一件一件做。而今天的事情就是……太多了。多到你来不及想为什么要做，只能先做起来再说。</p>
<p>可能这就是 21 岁吧。不再有余裕去想&quot;我是谁&quot;&quot;我在干嘛&quot;这种问题了，光是应付&quot;我今天要干嘛&quot;就已经够呛了。</p>
<p>要干嘛？</p>
<p>我暂时不知道。</p>
<h2>碎碎念</h2>
<p>去年那篇里我说&quot;有些答案留给二十五岁、三十岁的清晨去拆封&quot;。</p>
<p>现在觉得，二十五岁那时候大概率也在被推着走，也在忙得不可开交，哪有空拆封什么答案。<del>人类的本质就是被 ddl 追。</del></p>
<p>不过也不全是坏事，至少麻了。</p>
<p>也许这就是所谓的成长？不是变得不焦虑了，是焦虑的保质期变短了。就像学会了跟 bug 共处一样——你知道它在那儿，你知道迟早得修，但你不会因为它的存在否定整个项目。</p>
<p>去年结尾是&quot;你好，二十岁！&quot;，感叹号，冲劲十足，真是有干劲啊这个人（。</p>
<p>今年就：</p>
<p>嗯，二十一岁，然后......</p>
<p>在搞一个 7×24 的 AI agent 工作流，想看看全自动能跑到什么程度（距离失业还有多久）。Rust uniffi 也想折腾一下，感觉这个跨平台很优雅。</p>
<p>游戏的话最近在玩《奥日与精灵意志》，音乐，操作，都非常非常舒服，尤其是音乐真的太爽了。《空洞骑士：丝之歌》也在打，太难了，手残，但是<del>战斗，爽</del>。</p>
<p>还有一件事，想好好规划一下时间。这个我大概每个月都会想一次，然后每个月都不了了之。但还是想试试。</p>
<p>该来的来呗。反正先活过今天。</p>
<p><del>然后活过明天</del></p>
<p><del>以此类推</del></p>
<p>感谢你听我碎碎念，写点莫名其妙的东西。</p>
<!-- raw HTML omitted -->
<!-- raw HTML omitted -->
<!-- raw HTML omitted -->]]></description>
      <author>grtsinry43</author>
      <guid>moment-17</guid>
      <pubDate>Thu, 02 Apr 2026 16:34:17 +0000</pubDate>
    </item>
    <item>
      <title>我患上了 token 的瘾</title>
      <link>https://blog.grtsinry43.com/posts/token-addiction</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/posts/token-addiction">https://blog.grtsinry43.com/posts/token-addiction</a></p></blockquote><p>键盘就在手边，滴答清单里的待办事项还在默默倒数。</p>
<p>我想写点什么。在这个连底层框架都能在一夜之间被大模型重构的时代，我总想写写感受，但又不知从何说起。</p>
<p>于是只能从我自己的视角，谈谈这不到一年的时间里，我作为一个正在长株潭这片土地上读书、满怀憧憬准备在技术圈大干一场的大三学生，所经历的狂喜、震撼，以及——深深的恐惧。</p>
<p>巨大的信息轰炸和技术迭代，让我产生了一种强烈的失语感。我习惯了在 JetBrains 的 IDE 里口若悬河的讲着自己的看法，习惯了在 Linux 终端搞点好玩的东西，习惯了学习从后端架构到前端 UI 的每一个只是，但现在，面对眼前这个以天为单位进化的庞然大物，我突然不知道该从何说起。</p>
<p>那就从一切开始发生微妙变化的 25 年上半年说起吧。那时候，我依然觉得是我们的时代。</p>
<h2>第一阶段：实验室里的初探与“手工作坊”的野蛮生长</h2>
<p>时间拨回 2025 年的上半年。那时候我还在学校，日常是上课、应付期末和做实验。一切都还处于一种“掌控感十足”的状态——写代码主要还是靠自己一行行敲，最多开一下 Copilot 的补全。</p>
<p>直到 Gemini 2.5 Pro 的出现。当时正好赶上 Google 的活动，我顺手试用了一个月。那是我第一次切实感受到多模态带来的直观冲击：做实验遇到那种玄学问题，我干脆直接举起手机拍下屏幕扔给它，它竟然能精准判断软件操作，指出问题所在。</p>
<p>这感觉真的太上头了，在那个还没有学生认证白嫖的时候，一个月试用期结束，我甚至不过瘾地自己用 20 刀学费。后来，我又发现了 AI Studio 这个宝藏，有着极其丰富的免费额度和超长上下文支持。</p>
<p>我的野心开始膨胀。借着 AI Studio 的超长上下文，我又开始问这问那：我弄懂了 Monorepo 架构，开始着手搞自己的 Amore UI 组件库；后来为了写个文档站，我甚至跑去闲鱼收了个学生认证，用那种好像叫什么“引导学习模式”，硬生生把每天的 Pro 额度全部榨干。</p>
<p>但这个时候的 AI，在我眼里依然只是个“高级辅助”。虽然它帮我读了大量开源代码，但写代码的主力依然是我自己。那是一个充满折腾乐趣的时候，他确实帮助我学了好多东西。</p>
<h2>第二阶段：大厂的现实毒打与开发者的傲骨</h2>
<p>带着在学校里和自己积累的项目，2025 年 6 月底，我拖着行李箱走进了 🐧 的大门，开始实习。</p>
<p>当时正值行业里 DeepSeek R1 私有化部署的炒作狂欢，满世界都在鼓吹 AI 马上就要接管一切。但是这个时候我还依然对 AI 提不起兴趣。</p>
<p>当我真正面对那种错综复杂、体量庞大、充斥着历史包袱的企业级代码时，司内的模型显得极其笨拙。</p>
<p>不仅是工作，平时的开发，当时的主力模型还是 Claude 4 （Copilot 教育认证），大项目它根本理不清拓扑关系，小问题更是层出不穷，经常给你瞎编一些根本不存在的 API。</p>
<p>我的工作流被迫退回了古典的“手工作坊”模式：遇到卡壳的地方，用 GitHub Copilot 问一下，把代码片段复制出来，然后小心翼翼地缝缝补补。那时候我最常干的事，就是对着屏幕里的 AI 骂：“给我完整的代码！别动我原本的逻辑！”</p>
<p>为了突破工具的限制，我狠下心充了 20 刀一个月的 Cursor，第一次被 Agent 模式震撼；又偶然发现了能白嫖的 Anyrouter，开始摸索 CLI 模式（当时还是 cc v1.x）。</p>
<p>然而，当时为了省钱，我把每天只有 5 次免费额度的 Claude 网页版当“架构师”，让 CLI 工具当“打字员”去落地。</p>
<p>无论是工作写库，写 Runtime，还是平时自己项目写组件，处理跨平台问题，我坚持核心的掌控权必须死死攥在自己手里。经历了上半年的惊艳后，实习期的现实让我对 AI 彻底“祛魅”——它充其量只是个带点智能的搜索引擎，真正的工业级工程，还得靠人堆出来。</p>
<h2>第三阶段：荒野求生、震撼与防线的失守</h2>
<p>真正的认知颠覆，发生在 2025 年的下半年。</p>
<p>就在我用 AI 抽卡写完文档站的第二天，我意外受伤了。</p>
<p>生活半径被迫缩小到了床和书桌之间。</p>
<p>后来我依然坚持着上班。那段时间（11月左右），业余和折腾的时间我用 cc 写了好多项目：用 Kotlin Multiplatform 写跨平台的 RSS 阅读器 Pureflow，研究安卓，写服务器监控，写日志系统。</p>
<p>结果后来 Anyrouter 死了，我才发现那个白嫖的模型劣质得像个假货。</p>
<p>因为有了 CLI 模式，我开始摸到了 Vibe Coding 的雏形。但我吃过 AI 乱改代码的亏，这算是一种“如履薄冰的 Vibe”，一直在仔细 Review 它的每一行逻辑。</p>
<p>直到我遇到了 Codex 5.2，以及年底发布的 Gemini 3 Pro。</p>
<p>11 12 月同时我买了codex（当时听说好用），我发现了很多别人的文章，如何纯 vibe ，然后效果特别好：</p>
<p>当时的世界唯快不破，但 Codex 给出了截然不同的答案。把一个复杂的后端任务丢给它，它慢条斯理地跑上半个小时，但这半小时你完全不需要干预。最后直接 Production-Ready 。</p>
<p><del>一个任务虽然跑了半小时，但是写完效果真的是生产可用的，当时我真的惊了</del></p>
<p>如果说后端的失守还算温水煮青蛙，只是潜移默化，那前端的沦陷则是降维打击。</p>
<p>新模型发了，Gemini 3 Pro 在前端设计上展现出了超过好多人的能力。哪怕当时 Google 的 Antigravity 天天报错、难用到反人类，我也硬是耐着性子用它生磕出了大量惊艳的前端 UI。后来换到 Gemini CLI，终于承认了一个事实：<strong>不管是设计还是写代码，前端也真的写不过 AI 了。</strong></p>
<h2>第四阶段：赛博圆桌会议与主理人的克制</h2>
<p>带着满脑子的震撼，今年 1 月中旬放寒假回到老家，窗外是灰蒙蒙的天和白雪，我开始重写 Grtblog v2。</p>
<p>写这个博客的时候我和 codex 和 Claude（每天5条免费）帮我设计了好多架构，包括 go 的 Clean Arch 还有 DDD，前端和 admin 的目录结构等等</p>
<p>那段时间，我在 GitHub 上极其熟练地让 AI 互相 Code Review（<code>@codex review</code>, <code>@copilot review</code>）。UI 方面，我在 AI Studio 里高频抽卡找灵感，然后自己回到 Figma 里研究哈几天，抽离出了你看到的这套带着标志性绿色和小圆角的设计系统。</p>
<p>::: callout type=&quot;info&quot; title=&quot;底线&quot;
即使当时的 AI 已经能一次写完一个完整功能，但在这个我最在意的“亲儿子”项目上，我依然保持着极大的克制。因为安全问题和 AI 天生的“反骨”，我小心翼翼地把 AI 的实际代码贡献率死死压在 35%-40%。
:::</p>
<h2>第五阶段：算力自由、多线操作与深渊的凝视</h2>
<p>如果说在家重构博客还保留着一丝人类的体面，那么 2 月底回到学校后的那几天，则彻底击穿了我的认知。</p>
<p>剧情在这里发生了一次不可思议的转折。Anthropic 发了 Claude Opus 4.6，OpenAI 掏出了 Codex 5.3，我立刻买了这两个订阅。两家巨头互换了剧本：曾经快如闪电的 Claude 降速提质，成了一个疯狂吞噬 Token 的大模型；而 Codex 反而提速降本，限额翻倍。</p>
<p>那会儿我 20 刀的 Claude Pro 账号，5 小时限额只够跑两个大 Session。但就是这两个 Session，Opus 4.6 做到了一遍过，唉，这个时候就开始害怕了。</p>
<p>直到 2.28 我回到学校，我有一个朋友他恰好财力雄厚，有几个 Claude Max 账号，因为开学了他用不完了，于是就把其中一个 Claude Max 20x 账号借给我了用，真是*了，太逆天了...</p>
<p>那是一个极其疯狂的下午。我在宿舍的电脑前，同时跑着 4 个顶级 Claude Opus 4.6、1 个 Codex、1 个 Gemini CLI。我彻底进入了终极的“纯 Vibe”状态。无需构思语法，甚至无需自己管理项目，我的工作变成了纯粹的决策和调度——哪里亮了点哪里。</p>
<p>但随之而来的是：<strong>我发现用 AI 竟然比自己写还要累。</strong></p>
<p>开发历史上的瓶颈，第一次从“敲代码的手速”变成了“人类审查逻辑的脑力带宽”。面对 6 个大模型源源不断吐出的高质量代码，我根本 Review 不过来。我不是在写项目，我是在被算力的洪流推着、甚至“逼”着往前狂奔。</p>
<p>几天后，博客 V2 摧枯拉朽般地写完并发布了。但在那个跑满算力的下午，我极其兴奋，但也极其害怕。那种害怕，是你作为一个个体，直面指数级进化时的渺小感。</p>
<h2>终章：49年入国军与温暖的 Token</h2>
<p>时间拨回现在，三月底的春招季。</p>
<p>我带着刚刚重构完博客的余温，准备寻找暑期实习。迎面撞上的却是触目惊心的裁员潮。外包被砍，团队按比例缩减。我们这群学生和同行们私下打趣，却笑得比哭还难看：</p>
<blockquote>
<p><strong>“冰冷的前端同事，终于还是变成了温暖的 Token。”</strong></p>
</blockquote>
<p>更可怕的反噬已经在我身上显现。我察觉到自己手写代码的基本功在下滑。那曾经引以为傲的肌肉记忆，正在被轻易获得的正确答案所腐蚀。</p>
<p>面试的规则也彻底翻篇了。我刷了很久的算法，结果今年面试官根本不考手搓算法，全是疯狂拷问 19、20 年极其底层的“老八股”，蚂蚁甚至直接搞出了 AI Coding 笔试。</p>
<p>我问了一圈朋友和前同事：很多公司的团队已经全面拥抱 AI，几乎不怎么手写代码了。既然大模型能写出完美的逻辑，人类的价值就被迫转移到了“审计”。</p>
<p>就在这段时间，Claude Opus 4.6 的 1M 超大上下文推广开了。我们学校比较复杂的前后端、客户端和 Admin 项目，上下文可以一股脑塞进去，几个 Sub-agent 协同 5分钟就重构了 admin。还有曾经需要我们翻遍文档、掉光头发研究的问题，它不仅瞬间秒杀，效果还远超人类。</p>
<p>我看着屏幕，感到了一种深刻的焦虑和无力。这就像是你苦读了三年，好不容易练就了一身全栈本领，准备在行业里大展拳脚，却发现这个行业的运作方式已经被连根拔起。这种“49年入国军”的战栗感，在此刻达到了顶峰。</p>
<p>我似乎患上了 Token 的瘾。明明知道自己在失去手写的能力，明明心里充满了对未来的深渊般的恐惧，但面对这种降维打击的效率，我已经形成了致命的路径依赖。</p>
<p>面对屏幕上那些汹涌而来的 Token，我没有答案，只有战栗。</p>]]></description>
      <author>grtsinry43</author>
      <enclosure url="https://blog.grtsinry43.com/uploads/pictures/2026-03-29-12:53:55-a6.webp" length="0" type="image/webp"></enclosure>
      <guid>article-36</guid>
      <pubDate>Sun, 29 Mar 2026 12:54:12 +0000</pubDate>
    </item>
    <item>
      <title>Xiaomi 17 标准版刷机折腾记：解锁、官改ROM与必备模块</title>
      <link>https://blog.grtsinry43.com/posts/xiaomi-17-bootloader-unlock-custom-rom</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/posts/xiaomi-17-bootloader-unlock-custom-rom">https://blog.grtsinry43.com/posts/xiaomi-17-bootloader-unlock-custom-rom</a></p></blockquote><p>纠结了很久，还是入手了 Xiaomi 17 标准版。</p>
<p>买这个主要起源于最新小米设备上爆出的解锁漏洞，使得 8e5 机型重新解锁 bootloader 成为可能。</p>
<p>这篇文章主要讲讲我的折腾经历，但是隐去了解锁的流程，大家随便搜索论坛，酷安上，还有搞机 QQ 群都能拿到，等到解锁之后，就可以正式开始折腾了。</p>
<p><img src="/uploads/pictures/2026-03-12-07:43:06-95.jpg" alt="一下子回到了上个时代哈哈"></p>
<h2>官改 ROM</h2>
<p>我选择的是酷安上 <a href="https://www.coolapk.com/u/710841">白羊唐黎明</a> 的官改ROM，版本 3.0.301.0。</p>
<p>需要搭配底包 3.0.44.0 刷入，可以去</p>
<p><a href="https://miuirom.org/">https://miuirom.org/</a></p>
<p><a href="https://xiaomirom.com/">https://xiaomirom.com/</a></p>
<p>这两个平台找一下，要的话这里也有一个<a href="https://bkt-sgp-miui-ota-update-alisgp.oss-ap-southeast-1.aliyuncs.com/OS3.0.44.0.WPCCNXM/pudding_images_OS3.0.44.0.WPCCNXM_20260131.0000.00_16.0_cn_3c11e63b6e.tgz">直链</a>，然后下一个 MiFlash，emm可以在</p>
<p><a href="https://xiaomiflashtool.com/">https://xiaomiflashtool.com/</a></p>
<p><img src="/uploads/pictures/2026-03-12-07:55:09-c3.png" alt="image.png"></p>
<p>把下载好的官方线刷包解压到任意文件夹，手机音量下+电源键进入 FASTBOOT ，打开 MiFlash，点击“Driver”安装好对应的驱动之后点击“刷新设备”。</p>
<p><img src="/uploads/pictures/2026-03-14-04:30:25-1c.jpeg" alt="就是这个页面"></p>
<p>::: callout type=&quot;info&quot; title=&quot;注意&quot;
这里如果要命令的话是 <code>adb reboot bootloader</code>
:::</p>
<p>::: callout type=&quot;warning&quot; title=&quot;Emm&quot;
打开 MiFlash 第一步确保右下角选择全部删除而不是删除并回锁，要不就白解锁了</p>
<p>:::</p>
<p><img src="/uploads/pictures/2026-03-12-07:59:31-2e.png" alt="image.png"></p>
<p><img src="/uploads/pictures/2026-03-12-07:57:21-6b.png" alt="image.png"></p>
<p><img src="/uploads/pictures/2026-03-12-07:57:37-c1.png" alt="image.png"></p>
<p>选择刚刚解压到的文件夹，之后点击刷机，耐心等待即可。</p>
<p>开机之后尽量 oobe 该跳过的跳过，确认能正常进入桌面之后，没问题，然后重新手机音量下+电源键进入 FASTBOOT ，连接电脑，解压好官改包。</p>
<p><img src="/uploads/pictures/2026-03-12-08:01:44-f8.png" alt="image.png"></p>
<p>先装一下驱动，然后双击打开刷机脚本即可。</p>
<p><img src="/uploads/pictures/2026-03-12-08:02:17-82.png" alt="d1709e212429b1bfbbc0c89ea5dbd9f9.png"></p>
<p>不出意外的话，等待进度条跑完，手机重启，官改就刷好了。</p>
<h2>Play 完整性与 bl 解锁状态隐藏</h2>
<p>进入桌面的第一件事，就是找到刚刚官改 zip 里面的 ksu 管理器安装包装好，这样就可以准备刷模块了。</p>
<p>我们只需要刷入这几个模块：</p>
<p><img src="/uploads/pictures/2026-03-12-08:05:16-01.jpg" alt="e8e1bc40b9be2cd6287793044bd92a66_720.jpg"></p>
<p>顺序是：</p>
<p><a href="https://github.com/Dr-TSNG/ZygiskNext">https://github.com/Dr-TSNG/ZygiskNext</a></p>
<p><a href="https://github.com/5ec1cff/TrickyStore">https://github.com/5ec1cff/TrickyStore</a></p>
<p><a href="https://github.com/MeowDump/Integrity-Box">https://github.com/MeowDump/Integrity-Box</a></p>
<p>刷完重启就 OK 了</p>
<h2>必备软件和模块</h2>
<p>我自己用的一些工具</p>
<p>Scene：https://www.omarea.com/#/</p>
<p>爱玩机工具箱：https://www.aiwanjitool.com/</p>
<p>一些模块：</p>
<p>Reqable 安装</p>
<p>自动救砖</p>
<p>::: gallery height=&quot;400px&quot; caption=&quot;KSU&quot;
<img src="/uploads/pictures/2026-03-12-08:11:24-7c.jpg" alt="cf943b262476d5d6851f8c59cadbcf05.jpg">
<img src="/uploads/pictures/2026-03-12-08:12:41-34.jpeg" alt="4039249504fe07b87b03d59d3016d96b.jpeg">
:::</p>
<p>就到这里，一时兴起写的一篇文章，就当是重新经历刷机时代了。😋</p>]]></description>
      <author>grtsinry43</author>
      <enclosure url="https://blog.grtsinry43.com/uploads/pictures/2026-03-12-07:43:06-95.jpg" length="0" type="image/jpeg"></enclosure>
      <guid>article-35</guid>
      <pubDate>Thu, 12 Mar 2026 08:14:26 +0000</pubDate>
    </item>
    <item>
      <title>把心事像大扫除一样扔出去，空出来的地方，才能装得下清风和明月。</title>
      <link>https://blog.grtsinry43.com/thinkings#thinking-7</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/thinkings#thinking-7">https://blog.grtsinry43.com/thinkings#thinking-7</a></p></blockquote><p>把心事像大扫除一样扔出去，空出来的地方，才能装得下清风和明月。</p>]]></description>
      <author>grtsinry43</author>
      <guid>thinking-7</guid>
      <pubDate>Wed, 04 Mar 2026 17:40:27 +0000</pubDate>
    </item>
    <item>
      <title>在焦虑与代码中缓慢前行</title>
      <link>https://blog.grtsinry43.com/moments/2026/03/03/2026-notes-anxiety-and-code</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/moments/2026/03/03/2026-notes-anxiety-and-code">https://blog.grtsinry43.com/moments/2026/03/03/2026-notes-anxiety-and-code</a></p></blockquote><h2>一月：期末、眼药水、和走得很慢的路</h2>
<p>书接上文。</p>
<p>::: link-card href=&quot;/posts/2025-summary/&quot; title=&quot;2025 年终总结——从晨光到雾散，化经历为成长&quot; desc=&quot;在爱中重新振作，于是我们真的曾将彼此照亮&quot; newtab=&quot;true&quot;</p>
<p>:::</p>
<p>2025 年终总结的最后一句是&quot;我们 2026 见&quot;。写下那句话的时候，其实心里还带着点仪式感的勇气，觉得新的一年应该会不一样吧。</p>
<p>然后 2026 就这么来了。没有烟花，没有倒计时的激动。跨年夜是和家里人一起过的，大学以来第一次。说来也不全是我主动——是他一直缠着我，大概也是放心不下。</p>
<p><img src="/uploads/pictures/2026-03-03-11:18:15-1c.png" alt="image.png"></p>
<p>元旦一过，期末就铺天盖地地来了。</p>
<p>前几天复习，中途也不乏一些算是小的有趣的东西，很无聊，但笑了好久。</p>
<p>考试周嘛，就是靠这种莫名其妙的小事活着的。可是笑完之后该焦虑还是焦虑，期末一天比一天近，时间永远不够用。</p>
<p>::: gallery height=&quot;400px&quot; caption=&quot;年初&quot;
<img src="/uploads/pictures/2026-03-03-11:18:38-2e.png" alt="image.png">
<img src="/uploads/pictures/2026-03-03-11:19:03-32.png" alt="image.png">
:::</p>
<p>那段日子其实很重复。去办公室复习，和朋友有一搭没一搭地聊天，下午靠一杯咖啡续命。长沙的冬天不冷不热却最折磨人，室友空调开得猛一点，鼻炎就犯了，眼睛天天又干又涩，靠眼药水撑着，滴完了接着看书，看完了接着滴。累，真的特别累，不是那种运动完或者怎么样，是那种怎么睡都睡不掉的疲惫。</p>
<p>1 月 14 号，试着复健跑步了。</p>
<p>距离九月那个意外，四个多月。重新跑起来的时候，脚踩在地上，身体也会提醒我这事情没那么容易过去。相比与之前的轻松，取而代之的是肺活量气息跟不上的痛苦，迈不动步子的煎熬。没跑多远就停下来了，站在操场边喘气，看别人一圈一圈地跑过去。也许这就是时间在身上留下的东西。</p>
<p>15 号考完最后一科自控。</p>
<p>出考场的时候脑子是空的。明明每个知识点都看过，都理解了，坐到卷子面前就是写不出来。那种&quot;我全都懂但我全都写不准确，做不出题&quot;的感觉，唉经历了一次又一次，不知道什么时候是尽头。</p>
<p>当天晚上就开始赶课设了。一直搞到凌晨五点，倒头睡了几个小时，第二天接着熬到三点。幸好自己还年轻...身体好像还扛得住，但心里隐隐觉得自己在透支什么——不是体力，也许是某种对生活的耐心。</p>
<p>18、19 号，期末的流程结束，我就开始规划 GrtBlog v2 了。老毛病了。越累越想写东西，越焦虑越想开新坑。大概对我来说，创造是唯一能抵抗未知的东西。打开新项目的那一刻，所有的疲惫都可以暂时搁置，只剩下屏幕上干净的空文件，还有，对即将成型屎山的想法。</p>
<p>::: gallery height=&quot;400px&quot; caption=&quot;考试之后&quot;
<img src="/uploads/pictures/2026-03-03-11:20:06-88.png" alt="image.png">
<img src="/uploads/pictures/2026-03-03-11:20:14-f4.png" alt="image.png">
<img src="/uploads/pictures/2026-03-03-11:20:24-1d.png" alt="image.png">
:::</p>
<p>20 号上了回家的车。21 号到。扑面而来是熟悉的温度和风。</p>
<p>回到家并没有真的放松下来。22 号团委的任务就追过来了，那种&quot;你明明在放假但其实没有在放假&quot;的感觉，无法描述。24 号和家里人出去散步，冬天的街道很空，路灯把影子拉得很长。踩在雪上，走着走着，心里安静了一点点。</p>
<p>月底的日子就是窝在家里写 GrtBlog v2。窗外是灰蒙蒙的天，屏幕上是 SvelteKit 和 Go Fiber 的代码。没有人催，没有 deadline，只有键盘声和耳机里的音乐在想。那几天写得很沉浸，好像又回到了那个最有想法的时候。</p>
<hr>
<h2>二月：过年、失眠、和一列南下的火车</h2>
<p>二月开头还是写项目。</p>
<p>抽空约了要好的高中同学出来吃饭。坐下来发现大家变化都不大，聊的还是那些事，笑点还是那些笑点。这种&quot;不变&quot;让人安心，可也有一瞬间会恍惚——他们好像还是高中的样子，而我总觉得自己这一年老了好多。也许只是经历的东西不一样吧。</p>
<p><del>没事就上线原神、星铁</del>，累了刷刷 B 站，晚上看看项目进度，够了就出去跑步，不够就继续写。每天或者隔一天跑一次，夜里的风很冷，我把手蜷缩在袖子里，然后看着街上灯光映照的雪景，只剩下呼吸和脚步声。这大概是一天里最干净的时刻。</p>
<p>然后 6 号，发现上学期又有挂的了。</p>
<p>怎么说呢。</p>
<p>不是挂科本身有多可怕，是那一瞬间，所有东西一起塌下来了。春招的压力、对未来的焦虑、身体恢复的漫长、还没写完的项目、还没准备好的八股、还没刷够的算法——平时一件一件，也就是慢慢累计，直到这一个导火索。那天的情绪很黑，黑到不想说。</p>
<p>8 号还是和家里人出去逛了逛。11 号 Lowiro 出了新音游的测试版，五指打 6K 属实逆天了，还有鼠标的事情，在总之就是很难，但是还挺好玩的。后面几天晚上和高中同学打 Minecraft，拆幸运方块，玩空岛生存，在方块的世界里做一些简单到不需要思考的事，一个只有“我”的世界吧。</p>
<p>14 号，年前最后一次出门，和同学待了一整天。回来后折腾了小米的 root、LSP、搞搞 tricky store。搞机和写代码一样，是一种需要高度专注的手艺活，专注到可以暂时忘掉其他所有事，<del>专注到忘记备份于是成了砖</del></p>
<p>::: gallery height=&quot;400px&quot; caption=&quot;玩，和过年&quot;
<img src="/uploads/pictures/2026-03-03-11:21:34-da.png" alt="image.png">
<img src="/uploads/pictures/2026-03-03-11:21:42-f6.png" alt="image.png">
:::</p>
<p>15 号，回老家过年。</p>
<p>但说实话，一点也不像&quot;回家&quot;。没有任何力气应付亲戚的寒暄，笑容是挤出来的，年是对付过的。除夕是看 B 站拜年纪熬的，顺手搞了个 ctf——群友在文章里藏了解密红包，倒是很有意思。</p>
<p>过年那几天，经常出去走走。其实就是待不住，待着就焦虑，出去走走至少能骗骗自己在&quot;散心&quot;。晚上经常睡不着。躺在床上翻来覆去，脑子停不下来——春招什么时候开始投，简历还没改完，算法题还差好多，八股还有一大堆没背，还有课内的课程，还有身体的健康……所有&quot;还没&quot;像一床太重的被子，压着你，闷着你，让你在黑暗里越来越清醒。</p>
<p>21 号回了长沙。22 号，上线了 GrtBlog v2 的测试版本。</p>
<p>主线通了，看着写了快两个月的东西真正跑起来，算是一种很小的、很确定的满足。至少这件事，还是做到了。</p>
<p>23 号和朋友去看了新开的商场，后面几天给博客收尾，穿插着看八股和算法。日子又变成了那种&quot;什么都在推进但什么都没到位&quot;的状态。</p>
<p><img src="/uploads/pictures/2026-03-03-11:21:57-c3.png" alt="image.png"></p>
<p>27 号早上坐上了火车，北京中转。从北京朝阳到北京西要坐地铁穿城，车厢里人挤人，耳边飘着正宗的京腔——那种老爷范儿的调子，在嘈杂的地铁站里居然有种奇妙的从容感。</p>
<p><img src="/uploads/pictures/2026-03-03-11:23:17-fc.png" alt="image.png"></p>
<p>在北京西站碰到一个坐轮椅的哥们，左脚打着石膏。我一看就笑了——之前在广州实习的时候也碰到过一个同事左脚骨折的，我俩面对面一撞上，他左我右，完美对称。这次在北京西又来一回，怕不是命运大概觉得这个梗还挺好笑的，舍不得丢。</p>
<p>28 号到了学校。</p>
<p>晚上和群友聊到深夜，一起弄项目。键盘声响着。熟悉的节奏，熟悉的深夜，依旧是夜晚的想法。</p>
<p>后来 3 号上线了新版博客。</p>
<p>新学期，就这样又开始了。</p>
<hr>
<h2>后记</h2>
<p>写这篇手记的时候是三月初，坐在一个空教室，刚刚更新了博客的新版，还在改一些bug。</p>
<p>回头去看一月和二月，脑子里浮上来的不是什么完整的故事线，全是碎片。鼻炎难受时候没法入睡翻来覆去的夜晚，凌晨五点课设终于跑通时一个人对着屏幕傻笑，夜跑时耳边呼呼的风声还有冻红的双手，除夕拜年纪弹幕飘过去的热闹。还有过年时躺在床上睡不着，盯着天花板发呆的那些夜晚。</p>
<p>至少是我这两个月活过的证据。</p>
<p>2025 年底我写&quot;雾已经散去&quot;，现在回头看，那句话说得太早了。哪有那么简单。一月的期末、二月的焦虑、过年时的失眠——它们都在提醒我，去年秋天那场意外留下的东西，不只是脚上的伤，还有心里某个被磕碎了又没完全粘好的角落。焦虑还在，不安全感还在，那种&quot;我是不是不够好&quot;的声音还在。</p>
<p>但是至少</p>
<p>这两个月，不管情绪多差，不管多焦虑多累多睡不着，我一直在写代码。GrtBlog v2 从一月中旬的一个模糊念头，到二月底真正跑起来——这中间经历了期末周的熬夜、回家后的团委任务、过年时的焦虑发作、还有那段很黑的日子。但我就是一直在写。不是因为自律，不是因为简历需要，甚至不是因为&quot;热爱&quot;这种听起来很漂亮的词。就是……需要。像呼吸一样需要。当外面的一切都不确定的时候，打开编辑器，写一行代码，看它跑起来——这件事是确定的。也是最简单的正向反馈了。</p>
<p>想起 14 号在操场上复健跑步，跑不了多远就停下来喘气。想起 6 号得知挂科之后那种天塌了的感觉。想起除夕夜一个人在老家的街上走，冷风灌进衣领，假装自己在散心。想起 22 号 v2 上线那一刻，看着页面加载出来，心里安静了一下。</p>
<p>这些时刻放在一起看，好像也没那么糟。</p>
<p>我还是那个会焦虑到失眠的人，还是那个考试会懵的人，还是那个在亲戚面前挤不出真心笑容的人。但我也是那个在凌晨三点还在写课设的人，是那个在所有人都觉得该休息的时候还在开新坑的人，是那个看到 eslint error 没了会小小地开心一下的人。</p>
<p>这些加在一起，就是我。不太完整，但至少还在。</p>
<p>春招马上就到了。说不紧张是假的。简历还在想办法，算法还在刷，八股还有一堆要背。前面的路雾蒙蒙的，看不清走向哪里。</p>
<p>但我好像不太怕了。</p>
<p>不是因为变勇敢了，是因为这两个月教会我一件事：不需要等雾散了再走。雾里也能走。走得慢一点，看不清远一点，偶尔踩空一步——都没关系。脚还在地上，手还在键盘上，朋友还在群里，夜跑的风还是凉的。</p>
<p>去年写了一整年的故事，从晨光到浓雾再到雾散。今年的前两个月，没有那么戏剧化的起伏，只是很普通地活着——普通地焦虑，普通地失眠，普通地写代码，普通地和朋友待在一起，普通地在深夜感到一点点温暖。</p>
<p>但是我也开始变得算是有点乐观，开始觉得自己的过程曲折到想笑，凑齐了缓考补考重修很“圆满”，开始感觉有的时候自己写 bug 很有乐趣，开始发现不顺心的也是不错的体验</p>
<p>那就这样吧。</p>
<p>继续写代码，继续跑步，继续睡不着的时候翻来覆去，继续在群里和朋友聊到深夜。</p>
<p><img src="/uploads/pictures/2026-03-03-11:24:16-5f.png" alt="image.png"></p>]]></description>
      <author>grtsinry43</author>
      <enclosure url="https://blog.grtsinry43.com/uploads/pictures/2026-03-03-11:24:16-5f.png" length="0" type="image/png"></enclosure>
      <guid>moment-16</guid>
      <pubDate>Tue, 03 Mar 2026 11:04:04 +0000</pubDate>
    </item>
    <item>
      <title>从 v1 到 v2，谈谈这个简单博客背后的架构演进与实现</title>
      <link>https://blog.grtsinry43.com/posts/grtblog-v2-architecture</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/posts/grtblog-v2-architecture">https://blog.grtsinry43.com/posts/grtblog-v2-architecture</a></p></blockquote><p>写下这篇文章的时候，<code>grtblog-v2</code> 的核心功能开发已经基本告一段落。
<a href="https://github.com/grtsinry43/grtblog-v2">https://github.com/grtsinry43/grtblog-v2</a></p>
<p><del>目前正在进行稳定性测试，确认稳定后会逐步修复 Bug、补充功能，并拉朋友内测。当前的测试地址在：</del></p>
<p><a href="https://blog-next.grtsinry43.com/">https://blog-next.grtsinry43.com/</a></p>
<p><del>（注意仅供测试，数据与本站不会同步）</del></p>
<p>本站已更新，稳定后再发布新版项目~</p>
<p>感谢 &lt;@starnighter@blogv2.starnighter.com&gt; 同学帮助测试还有 PR ，帮助我完成了一些功能开发~</p>
<h2>为什么要重写</h2>
<p>这个博客最初只是我学习 React SSR 时的练手项目。一年多过去，它承载了我大量的技术实验——每次有新东西想试，就往里堆。学到了很多，但代价是：它变成了一座精致的屎山。</p>
<p>作为部署在 1C2G / 2C4G 小鸡上的个人博客，v1 实在太重了。每次部署要拉起 MySQL、MongoDB、Redis、MeiliSearch 等一堆服务，JVM 和 Next.js 联手吃掉几乎所有内存。更让人疲惫的是 Next.js 的黑盒实现和不断暴露的安全问题——维护它本身就需要一套沉重的心智模型。</p>
<p>::: link-card href=&quot;/posts/rsc-boundary-mismatch&quot; title=&quot;新时代的 PHP：RSC 的边界错位与工程代价&quot; desc=&quot;代码编织的幻觉背后，边界的消融暗藏风暴；语法糖包裹的便利之下，责任的转移悄然发生。全栈的浪潮冲刷着安全的长堤，框架的叙事掩盖着架构的代价。&quot; newtab=&quot;true&quot;</p>
<p>:::</p>
<h2>首先是对比下</h2>
<p>咱们首先对比一下，狠狠抨击自己之前的石山，然后讲一下我这次换成了什么：</p>
<table>
<thead>
<tr>
<th>问题</th>
<th>具体表现</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>架构复杂</strong></td>
<td>Java 后端 + Next.js 前端 + Umi.js 后台 + Python 推荐服务，四个独立技术栈</td>
</tr>
<tr>
<td><strong>数据库过多</strong></td>
<td>MySQL + MongoDB + Redis + Elasticsearch + MeiliSearch，五个模块各司其职但运维成本极高</td>
</tr>
<tr>
<td><strong>部署门槛高</strong></td>
<td>Docker Compose 需要 6+ 个容器，配置繁琐，甚至阻碍了作者自己后续维护</td>
</tr>
<tr>
<td><strong>仓库膨胀</strong></td>
<td>Git 历史混入大量二进制资源，仓库体积快速膨胀</td>
</tr>
<tr>
<td><strong>边界模糊</strong></td>
<td>设计系统、内容模型与插件机制（PF4J）的职责逐渐交叉</td>
</tr>
<tr>
<td><strong>BFF 废弃</strong></td>
<td>规划的 BFF 层未能落地，停留在空目录</td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th>决策</th>
<th>v1 做法</th>
<th>v2 做法</th>
<th>理由</th>
</tr>
</thead>
<tbody>
<tr>
<td>后端语言</td>
<td>Java (Spring Boot)</td>
<td><strong>Go (Fiber)</strong></td>
<td>编译为单二进制，内存占用从数百 MB 降至数十 MB</td>
</tr>
<tr>
<td>前端框架</td>
<td>Next.js (React)</td>
<td><strong>SvelteKit (Svelte 5)</strong></td>
<td>更小的 bundle、更少的运行时开销、Runes 语法更直觉</td>
</tr>
<tr>
<td>管理后台</td>
<td>Umi.js (React)</td>
<td><strong>Vue 3 (Naive UI)</strong></td>
<td>轻量且与前台技术栈解耦，并基于 lithe-admin 二开</td>
</tr>
<tr>
<td>数据库</td>
<td>MySQL + MongoDB</td>
<td><strong>PostgreSQL 一个搞定</strong></td>
<td>JSONB 覆盖文档型需求，减少运维复杂度</td>
</tr>
<tr>
<td>搜索</td>
<td>Elasticsearch + MeiliSearch</td>
<td><strong>后端内建</strong></td>
<td>博客体量下内建搜索足够，去掉两个重型依赖</td>
</tr>
<tr>
<td>推荐系统</td>
<td>独立 Python 微服务</td>
<td><strong>Go 内建</strong></td>
<td>减少跨语言通信和部署复杂度</td>
</tr>
<tr>
<td>静态生成</td>
<td>Next.js ISR (框架内建)</td>
<td><strong>自研 ISR (Go 驱动)</strong></td>
<td>Go 后端直接调度渲染、原子写入，完全可控</td>
</tr>
<tr>
<td>实时通信</td>
<td>Socket.io + Netty</td>
<td><strong>原生 WebSocket</strong></td>
<td>去掉 Socket.io 协议层开销</td>
</tr>
<tr>
<td>部署</td>
<td>6+ 容器</td>
<td><strong>3 容器</strong> (Go + SvelteKit + Nginx + DB)</td>
<td>大幅降低部署门槛</td>
</tr>
</tbody>
</table>
<h2>注水静态架构 (Rehydrated Static Architecture)</h2>
<p><img src="https://blog.grtsinry43.com/uploads/2026/02/24/mermaid-diagram-2026-02-24-112032.png_0c4a814f-8b26-4fa7-a624-96edb40dc1b4.png" alt=""></p>
<p>这是 v2 的核心设计理念，一句话概括：</p>
<blockquote>
<p><strong>将 SSR 的渲染时机从「用户请求时」提前到「数据变更时」，将渲染产物以纯静态文件的形式交给 Nginx 分发，同时通过 WebSocket 为在线用户注入实时更新。</strong></p>
</blockquote>
<p>它试图在静态站点的极致性能和动态应用的实时交互之间找到一个平衡点。拆开来看，分为三层：</p>
<ol>
<li><strong>静态先行 (Static First)</strong> — 所有公开页面默认为纯静态 HTML，由 Nginx 直接分发，首屏速度拉满，CPU 占用趋近于零。</li>
<li><strong>增量生成 (Incremental Generation)</strong> — 仅在内容变更时，由 Go 控制平面驱动 SvelteKit 渲染器生成受影响的页面，不做全量重建。</li>
<li><strong>实时注水 (Realtime Rehydration)</strong> — 客户端通过 WebSocket 接收评论、点赞及内容的热更新，在线用户无需刷新即可看到最新状态。</li>
</ol>
<p>换一个更本质的角度来理解：</p>
<blockquote>
<p>SSR / SSG / ISR 这些词只是在描述&quot;渲染发生在哪里&quot;。真正决定架构设计的，是 <strong>数据与页面的依赖关系</strong>，以及 <strong>渲染产物如何存储和复用</strong>。</p>
</blockquote>
<p>它的效果是：</p>
<p><img src="https://blog.grtsinry43.com/uploads/2026/02/24/Pasted_image_20260224135038.png_65bcd26b-b04d-477d-bb03-0c489109ec01.png" alt=""></p>
<h2>发生了什么</h2>
<p>我们可以用一个图来看出核心的更新机制是什么的。</p>
<p><img src="https://blog.grtsinry43.com/uploads/2026/02/24/mermaid-diagram-2026-01-18-182549.png_45511e92-655f-45a9-8ef3-02fdf585959a.png" alt=""></p>
<h3>ISR 工作流</h3>
<p>ISR（Incremental Static Regeneration）是本项目的核心机制，类似 Next.js 的 ISR，但完全白盒，可以完全掌控：</p>
<pre><code class="language-md">Admin 发布文章
  │
  ▼
Go 写入数据库
  │
  ▼
DirtyPathCalculator 计算受影响路径
  例: /posts/new, /index, /tags/Go, /feed.xml
  │
  ▼
RenderQueue 异步任务入队
  │
  ▼
Worker 请求 SvelteKit Renderer
  GET http://renderer:3000/posts/new
  │
  ▼
AtomicWriter 原子写入静态文件
  TempFile -&gt; Rename (防并发读写白屏)
  │
  ▼
WebSocket Hub 广播 post_created 事件
  │
  ▼
在线用户收到实时通知
</code></pre>
<h3>实时更新流</h3>
<pre><code class="language-md">Admin 修改文章错别字
  │
  ▼
Go 更新 DB + 广播 WS post_update (带 payload)
  │
  ▼
在线阅读用户的 Svelte Store 收到 payload
  │
  ▼
无感替换 DOM 文本节点（无需刷新）
  │
  ▼
Go 异步触发静态文件重新生成（为后来者服务）
</code></pre>
<h2>说说实现细节</h2>
<h3>从 MPA 到 SPA：静态文件如何水合</h3>
<p>这种架构面临的第一个问题是：如果页面变成了静态文件，客户端怎么水合成 SPA？
好在 SvelteKit 的框架魔法大多发生在SSR的时候。在 SvelteKit 中，页面加载分为两种路径：</p>
<ol>
<li><strong>首次访问 (SSR)</strong>：服务端执行 <code>load()</code>，拼接完整的 HTML 返回给浏览器。</li>
<li><strong>客户端路由跳转 (CSR / SPA)</strong>：当你点击链接从 <code>/</code> 跳转到 <code>/posts/1</code> 时，SvelteKit <strong>不会</strong>请求新的 HTML。它的客户端 Router 会去请求一个特殊路径：<code>/posts/1/__data.json</code>，拿到 JSON 后在前端完成数据替换和 DOM 更新。</li>
</ol>
<p>因此，我们只需在每次渲染时同时缓存 HTML 和 <code>__data.json</code>，就做到了一个&quot;静态的单页应用&quot;——首次访问命中静态 HTML，水合之后的导航跳转走 <code>__data.json</code>，行为完全等同于 SPA。</p>
<h3><code>load()</code> 驱动的 ISR 依赖收集</h3>
<p>传统的 ISR 是框架内闭环的，但 v2 的后端是 Go，前端是 SvelteKit。Go 怎么知道文章 A 更新了，首页也要跟着重新渲染？我们就需要一个依赖标记的机制。</p>
<h4>1. 页面在 <code>load</code> 阶段显式声明依赖</h4>
<p>SvelteKit 的数据获取，精髓在于这个<code>load()</code>函数，由于我们整个页面都是在这里获取初始数据，所以我们不妨在拿数据的时候打个 Tag（<code>web/src/routes/posts/[slug]/+page.server.ts</code>）：</p>
<pre><code class="language-typescript">const post = await getPostDetail(fetch, params.slug);
trackISRDeps(event, `post:detail:${post.id}`);
</code></pre>
<p>首页等复杂页面也会收集一堆 Tag：</p>
<pre><code class="language-typescript">trackISRDeps(
  event, 'home:recent-posts', 'home:recent-moments',
  'home:activity-pulse', 'home:inspiration-stats'
);
</code></pre>
<h4>2. Header 与反向索引</h4>
<p>在 <code>web/src/hooks.server.ts</code> 中，我拦截了响应，把收集到的 Tag 塞进 HTTP Header：</p>
<pre><code class="language-typescript">event.locals.isrDeps = new Set&lt;string&gt;();
const response = await resolve(event);
headers.set('x-grt-deps', JSON.stringify(Array.from(event.locals.isrDeps)));
</code></pre>
<p>Go 向 Renderer 发起内网抓取时（<code>server/internal/app/htmlsnapshot/service.go</code>），解析这个 Header，并将关系写入自己的 Redis 映射表：</p>
<ul>
<li><code>isr:url:&lt;url&gt; -&gt; deps</code></li>
<li><code>isr:dep:&lt;dep&gt; -&gt; urls</code></li>
</ul>
<h4>3. 事件驱动失效</h4>
<p>当我在后台修改了文章，Go 的事件总线触发 ISR（<code>server/internal/app/isr/subscriber.go</code>）：</p>
<pre><code class="language-go">deps := []string{
  &quot;home:recent-posts&quot;,
  fmt.Sprintf(&quot;post:detail:%d&quot;, articleID),
}
urls := []string{&quot;/&quot;, &quot;/posts&quot;, &quot;/posts/page/1&quot;}
return service.Invalidate(ctx, deps, urls)
</code></pre>
<p>Go 拿着 <code>deps</code> 去反向索引中查出所有受影响的 URL，去重后压入 Redis Sorted Set 队列。</p>
<p><strong>至此，一条完整的链路成型：前端声明依赖 → 后端解析并建立索引 → 数据变更时精准触发重渲染。</strong></p>
<h3>异步客户端组件与请求</h3>
<p>如果全站静态化，点赞数、评论区怎么动态加载？
对于点赞和观看量这种轻交互，我们可以 mounted 之后请求和修改，而评论这种重交互，则可以使用 <code>&lt;QueryRoot&gt;</code> 组件（<code>web/src/lib/ui/common/QueryRoot.svelte</code>），这下就有了个低配的 Suspense（bushi</p>
<pre><code class="language-ts">onMount(async () =&gt; {
  const [{ QueryClientProvider }, { getOrCreateQueryClient }] = await Promise.all([
    import('@tanstack/svelte-query'),
    import('$lib/shared/clients/query-client')
  ]);
  client = await getOrCreateQueryClient(options);
  Provider = QueryClientProvider;
  if (loader) {
    const loaded = await loader();
    Loaded = loaded.default;
  }
  ready = true;
});
</code></pre>
<p>这样，第一屏不会引入太重的请求部分，而客户端组件加载完成之后由 TanStack Query 管理，最大化管理了请求数据。</p>
<h3><code>svatoms</code>：舒服的树形数据传递</h3>
<p>在由各种“交互岛屿”构成的页面中，Prop drilling（属性逐层透传）是维护的地狱。结合 Svelte 5 的 Runes 特性，我封装了 <code>svatoms</code> 来实现数据树与组件树的解耦。</p>
<p><a href="https://github.com/grtsinry43/svatoms">https://github.com/grtsinry43/svatoms</a></p>
<h4>1. Context 挂载模型数据</h4>
<p>在页面顶层（<code>web/src/routes/posts/[slug]/+page.svelte</code>），把 <code>load</code> 来的数据挂载到专属的 Context 中。使用 getter 保证 SvelteKit 导航后的数据自动同步：</p>
<pre><code class="language-ts">postDetailCtx.mountModelData(() =&gt; data.post ?? null);
const { updateModelData } = postDetailCtx.useModelActions();
</code></pre>
<h4>2. 细粒度切片订阅</h4>
<p>子组件只订阅自己关心的切片（<code>PostDetailMain.svelte</code>）：</p>
<pre><code class="language-ts">const aiSummaryStore = postDetailCtx.selectModelData((data) =&gt; data?.aiSummary ?? '');
const tocStore = postDetailCtx.selectModelData((data) =&gt; data?.toc ?? [], { equals: sameToc });
</code></pre>
<p>这里的 <code>equals</code>可以在返回复杂对象时，手动等价比较避免了无意义的重渲染。</p>
<h4>3. 跨树联动，比如阅读进度同步</h4>
<p>比如<code>DetailMarkdownContent.svelte</code> 在正文滚动时，更新 <code>detailPanelCtx</code> 里的 <code>activeAnchor</code>。远在另一棵 DOM 树分支上的 <code>MobileNavBar.svelte</code> 订阅同一个 Context 并高亮当前目录。 生产者和消费者无需在同一条 props 链上，状态流转的心智模型很舒服。</p>
<h3>渲染平面的优雅降级：静态优先 + 原子写入</h3>
<p>之前说过，由于静态的特性，哪怕 Go 后端和 SvelteKit 全部宕机，博客依然要能抗住流量。</p>
<h4>1. Nginx 静态</h4>
<p>在 <code>deploy/nginx/nginx.conf</code> 中，静态文件是一等公民：</p>
<pre><code class="language-conf">location / {
  # 命中静态文件直接返回，未命中才回源到 SSR
  try_files $uri $uri.html $uri/index.html @frontend_fallback;
}
location @frontend_fallback {
  proxy_pass http://renderer_ssr;
}
</code></pre>
<h4>2. 原子操作避免损坏</h4>
<p>高并发下，如果 Go 正在把渲染好的 HTML 写入磁盘，用户恰好访问，就会看到残缺的白屏。 在 <code>server/internal/app/htmlsnapshot/service.go</code> 中，这里利用Rename操作的原子性：</p>
<pre><code class="language-go">tmp, _ := os.CreateTemp(dir, &quot;.snapshot-*.tmp&quot;)
tmp.Write(body)
tmp.Close()
os.Rename(tmpName, filePath)
</code></pre>
<p>并且，如果访问 Renderer 遇到 404，Go 会主动清理旧的静态文件，避免出现“后台删了，前台还在”的幽灵页面。</p>
<h3>Markdown渲染</h3>
<p>在个人博客的开发中，大多数人会选择引入 <code>markdown-it</code> 或 <code>marked</code>，直接转成 HTML 字符串，然后用 <code>{@html content}</code>（或 <code>v-html</code> / <code>dangerouslySetInnerHTML</code>）一把梭。
……但这样做意味着完全脱离了框架的组件生命周期——Svelte 不知道那段 HTML 里有什么，自然也无法管理它。
为了在运行时安全、优雅地将 Svelte 组件嵌入到 Markdown 正文中，同时保留AST解析能力，我抽离并开源了<code>svmarkdown</code>。</p>
<p><a href="https://github.com/grtsinry43/svmarkdown">https://github.com/grtsinry43/svmarkdown</a></p>
<p>这个库是基于Makrdown-it的强大能力的</p>
<h4>Phase 1: 解析层 (Parser Layer) —— 构建干净的 AST</h4>
<p>在 <code>src/parser.ts</code> 中，利用 <code>markdown-it</code> 对原始文本进行词法分析，拿到扁平的 <code>Token</code> 流，然后通过一个游标解析器，将这些 Token 转换成一颗干净的、高度结构化的自定义抽象语法树（AST），即 <code>SvmdNode</code>。</p>
<p>在 <code>src/types.ts</code> 中，可以看到 AST 节点被严格定义为几种：</p>
<ul>
<li><code>SvmdTextNode</code>：纯文本节点。</li>
<li><code>SvmdElementNode</code>：标准 HTML 标签（如 <code>p</code>, <code>strong</code>, <code>a</code>）。</li>
<li><code>SvmdCodeNode</code>：代码块节点（携带语言类型和源码）。</li>
<li><strong><code>SvmdComponentNode</code></strong>：自定义组件节点。</li>
</ul>
<p>通过引入 <code>markdown-it-container</code> 插件，<code>svmarkdown</code> 会拦截所有类似 <code>:::callout</code> 或 <code>:::gallery</code> 的自定义块。在解析阶段，它会将冒号后面的标识符和属性提取出来，直接组装成一个 <code>SvmdComponentNode</code>，放入 AST 树中。</p>
<h4>Phase 2: 渲染层 (Render Layer) —— Svelte 原生递归组件</h4>
<p>拿到 AST 后，就进入了 Svelte 渲染阶段。</p>
<p>在 <code>src/Markdown.svelte</code> 和 <code>src/internal/RenderNode.svelte</code> 里，利用 Svelte 的 <code>&lt;svelte:element&gt;</code> 和 <code>&lt;svelte:component&gt;</code> 实现了 AST 的递归遍历。</p>
<p>在 <code>&lt;RenderNode&gt;</code> 这个内部核心组件里，会进行分发（Dispatch）：</p>
<ol>
<li><strong>如果是普通元素</strong>：直接渲染 <code>&lt;svelte:element this={node.tag}&gt;</code>。</li>
<li><strong>如果是代码块</strong>：将代码字符串作为 props 传入用户定义的外部 CodeBlock 组件。</li>
<li><strong>如果是自定义组件</strong>：系统会去查找顶层传入的 <code>componentMap</code>。</li>
</ol>
<pre><code class="language-ts">{#if node.type === 'component'}
	{@const MappedComponent = componentMap[node.name] || FallbackComponent}
    &lt;svelte:component this={MappedComponent} {...node.props}&gt;
        &lt;SvmdChildren nodes={node.children} /&gt;
    &lt;/svelte:component&gt;
{/if}
</code></pre>
<p>用这个库，心智负担也很低：</p>
<pre><code class="language-ts">const componentBlocks = Object.fromEntries(  
  componentDefinitions.map((component) =&gt; [component.name, true])  
) satisfies SvmdParseOptions['componentBlocks'];  
  
export const markdownComponents: SvmdComponentMap = {  
  h1: MarkdownHeading,  
  h2: MarkdownHeading,  
  h3: MarkdownHeading,  
  h4: MarkdownHeading,  
  h5: MarkdownHeading,  
  h6: MarkdownHeading,  
  p: MarkdownParagraph,  
  ul: MarkdownList,  
  ol: MarkdownList,  
  li: MarkdownListItem,  
  blockquote: MarkdownBlockquote,  
  hr: MarkdownHr,  
  table: MarkdownTable,  
  thead: MarkdownThead,  
  tbody: MarkdownTbody,  
  tr: MarkdownTr,  
  th: MarkdownTh,  
  td: MarkdownTd,  
  a: MarkdownLink,  
  img: MarkdownImage,  
  code: MarkdownCodeBlock,  
  gallery: MarkdownFallback,  
  callout: MarkdownFallback,  
  timeline: MarkdownFallback,  
  'year-card': YearCard,  
  'link-card': LinkCard,  
  'footnote-link-card': FootnoteLinkCard  
};  
  
export const markdownParseOptions: SvmdParseOptions = {  
  componentBlocks,  
  markdownItPlugins: [],  
  markdownItOptions: {  
   html: true,  
   linkify: true,  
   typographer: true  
  }  
};  
  
export const markdownRenderOptions: SvmdRenderOptions = {  
  allowDangerousHtml: true  
};
</code></pre>
<p>轻量、极速、一切皆组件，这样或许还挺优雅的。</p>
<h2>写在最后</h2>
<p>回头看，v1 的问题不是任何单一技术选型的失败，而是复杂度在无人察觉中的缓慢堆积——每多一个中间件都&quot;有道理&quot;，每多一层抽象都&quot;有必要&quot;，直到整个系统的重量超过了它所承载的内容本身。</p>
<p>v2 的核心收获不是选了更好的框架，而是学会了在每个岔路口问自己一句：<strong>这个博客，真的需要这个吗？</strong>
内存占用腰斩不止，维护的心智模型也清爽了许多。更重要的是，我终于能把精力从&quot;和基础设施搏斗&quot;转回到&quot;做有趣的产品&quot;上了。</p>
<p>grtblog-v2 还需要完整的测试和问题修复，但距离稳定应该不会太远了。如果你也在做类似的全栈博客、ISR 优化，或者对 Svelte 5 + Go 的组合感兴趣，欢迎 <a href="https://github.com/grtsinry43/grtblog-v2">Star 仓库</a>、提 Issue，或者直接在评论区聊聊你的想法。</p>
<p>感谢读完这篇有点长的技术复盘。</p>]]></description>
      <author>grtsinry43</author>
      <enclosure url="https://blog.grtsinry43.com/uploads/pictures/2026-03-03-08:36:46-49.webp" length="0" type="image/webp"></enclosure>
      <guid>article-34</guid>
      <pubDate>Tue, 24 Feb 2026 05:56:28 +0000</pubDate>
    </item>
    <item>
      <title>Go 语言初体验：Less is more，一种丑但可靠的工程美学</title>
      <link>https://blog.grtsinry43.com/posts/go-first-experience</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/posts/go-first-experience">https://blog.grtsinry43.com/posts/go-first-experience</a></p></blockquote><p>最近新项目评估技术栈，因为 Java/Kotlin 太重，TypeScript/JavaScript（Node.js）因为 js 原型链的问题我一直感觉不是合格的后端语言，写 Rust 的话社区根本不会有几个人贡献，再加上后台任务，轻量化，易于部署，可能就只有 Go 能担任这个职责了。
其实我之前用过 go 的，当时在搞 AI 原型生成器的时候，为了快捷操作容器，我用 go 搞了个沙箱管理器，操作容器，对外 gRPC，利用了它在云原生领域的生态优势。而这次，我看中的是它易于入门，编译快，占用轻，易于部署，当然重要的是，协程模型确实很现代很舒服。</p>
<h2>真的很丑</h2>
<p>我对 Go 的第一印象非常稳定：<strong>丑。</strong>
对于习惯了 Java/JS 的注解（装饰器），Kotlin 的 DSL，Rust 的宏来说的我，Go 的语法极其贫瘠，真是可以说简陋。对于 Go 的显式哲学来说，不像是语言的搭积木，而是你把螺丝刀给我，我就能把家装起来，但别问我为什么这个螺丝长这样。</p>
<h3>于是字符串成了注解</h3>
<p>我们从这样一段例子开始：</p>
<pre><code class="language-go">// OAuthProviderResp 返回可用的 OAuth provider 信息。
type OAuthProviderResp struct {
    Key          string   `json:&quot;key&quot;`
    DisplayName  string   `json:&quot;displayName&quot;`
    Scopes       []string `json:&quot;scopes&quot;`
    PKCERequired bool     `json:&quot;pkceRequired&quot;`
}

type Webhook struct {
    ID              int64          `gorm:&quot;column:id;primaryKey&quot;`
    Name            string         `gorm:&quot;column:name;size:100;not null&quot;`
    URL             string         `gorm:&quot;column:url;size:512;not null&quot;`
    Events          []byte         `gorm:&quot;column:events;type:jsonb;not null&quot;`
    Headers         []byte         `gorm:&quot;column:headers;type:jsonb;not null&quot;`
    PayloadTemplate string         `gorm:&quot;column:payload_template;type:text;not null&quot;`
    IsEnabled       bool           `gorm:&quot;column:is_enabled&quot;`
    CreatedAt       time.Time      `gorm:&quot;column:created_at;autoCreateTime&quot;`
    UpdatedAt       time.Time      `gorm:&quot;column:updated_at;autoUpdateTime&quot;`
    DeletedAt       gorm.DeletedAt `gorm:&quot;column:deleted_at;index&quot;`
}
</code></pre>
<p>为了做一个简单的序列化和参数校验，必须在结构体后面跟上一长串 <code>json:&quot;name&quot; binding:&quot;required,min=5&quot;</code>。为了数据库字段的对应和行为，又要写一长串关键词。这种把逻辑写在字符串里的做法感觉不知道梦回了哪个时代。但是原因也很简单嘛，因为没有注解/宏/DSL，只能用这种方式来表达。</p>
<h3>指针定义的 Overloaded</h3>
<p>Go 的显式很多时候不是清晰，而是盲目想要复用反而使得语义过载。</p>
<p>比如，你想要一个可选值？行，给你 <code>*T</code>。 但 <code>*T</code> 在 Go 里又不仅仅是 Optional——它同时还是：</p>
<ul>
<li>“这个字段可能为 NULL”（DB / JSON）</li>
<li>“我想区分零值和未设置”（patch / update）</li>
<li>“我想共享/引用同一份数据”（引用语义）</li>
<li>“这个方法需要指针接收者”（行为语义）</li>
</ul>
<p>那这就很可怕了，于是当你在 Go 的代码中看到一个<code>*</code>，你还需要费尽心力去琢磨是可空还是关系。而原因只是因为 Go 没有一个设计好的 Optional/Result。</p>
<blockquote>
<p>同一个 <code>*</code> 被迫承担了四种语义，结果是：代码显式了，意图却更隐式了。</p>
</blockquote>
<h3>错误处理变为传递责任链</h3>
<p>然后是错误处理。</p>
<blockquote>
<p>写 Go 的时候，键盘上最先磨损的永远是 <code>i</code>, <code>f</code>, <code>e</code>, <code>r</code>, <code>n</code>, <code>l</code> 这几个键。</p>
</blockquote>
<p>在 Kotlin 里你可能会用 <code>runCatching</code>，在 Rust 里你有 <code>?</code>，在 Java 里至少异常处理也未尝不可，但是 Go：<code>if err != nil { return err }</code></p>
<p>你可以说这很显式，很清晰，很正确的考虑了每一种可能分支。<br>
但当你的业务开始出现一定的复杂度：超时、取消、重试、降级、后台任务、幂等、签名验签、缓存穿透……你会发现你写的不是后端，而是考虑所有，搭建了一条错误传播管道。</p>
<p>当然，最让人抓狂的是，这种繁琐并没有带来更好的安全性。它不像 Rust 的 <code>Result&lt;T, E&gt;</code> 那样强制你在编译期处理错误，也不像 Java 的 Checked Exception 那样有显式的签名约束。它只是一个约定，如果你忘了写这两行代码，那么发生什么边界情况就不可控了。</p>
<h2>写业务的“地狱体验”</h2>
<h3>想要一个好用的 ORM</h3>
<p>Go 的 ORM 生态有一种奇妙的割裂感：<br>
要么<strong>极度魔法</strong>，要么<strong>极度朴素</strong>，中间那条舒适区间很窄。</p>
<blockquote>
<p>这玩意儿除了名字叫 ORM，哪里像个现代 ORM 了？不如说是 SQL 拼接器</p>
</blockquote>
<p>在 Kotlin Exposed 或者 Rust SeaORM 里，或者哪怕是（不属于 ORM）手写 SQL 的 Rust sqlx，他们都是强类型的，强大的编译时安全让写代码就很有底气。你写错一个字段名，编译器立马给你报错。但在 Go 里（尤其是 GORM），你又回到了拼接字符串的恐惧......</p>
<blockquote>
<p><code>db.Where(&quot;user_nmae = ?&quot;, name).First(&amp;user)</code> —— 这里的 <code>user_nmae</code> 写错了？编译通过，运行报错！</p>
</blockquote>
<p>而当你开始尝试 Ent，感受到 DSL 的舒服，编译安全，但它妄图掌控数据库的感觉，以及完全无法自己精细修改的表结构、定义的索引优化等等，都让人感觉这根本就是为社交关系服务的图数据库，它的抽象会强到让你觉得“我在写 Ent，不是在写业务”。</p>
<p><del>拜托，学习它的 SeaORM 都那么好用，人家尊重数据库，SQL 优先，利用 Rust 的语言特性搞了那么好的优化体验，Ent 居然能这么难用。</del></p>
<p>于是最后很多人回到朴素路线，手写 migration（goose），查询用 <code>sqlx/sqlc</code>，开始抱怨 Go 的 ORM 总有一种“隔靴搔痒”的无力感。它要么太灵活以至于不安全，要么太重型（靠大量代码生成）以至于繁琐。</p>
<h3>用脚本补充的语言能力</h3>
<p>Go 的精神很一致：语言保持小，复杂度交给工具链。</p>
<p>你可能会喜欢上 Rust 的宏展开代码，Kotlin/Rust 的 dsl 优雅美观，Java/JS 的注解轻松切面扩展。而 Go 呢？<code>//go:generate</code>。 它不是语言特性，它只是一个让工具链去跑个 shell 命令的“补丁”。</p>
<p>所以你会看到整个生态一大堆生成器驱动的解决方案：</p>
<ul>
<li>ORM 生成（Ent / sqlc）</li>
<li>Mock 生成（mockgen）</li>
<li>API client 生成（OpenAPI generator）</li>
<li>Protobuf/gRPC 生成</li>
<li>...</li>
</ul>
<p>于是项目里面的 Makefile 成为了最佳实践，成为了一切生成器的优雅入口。</p>
<p>这当然有好处：<br>
生成出来的就是普通 Go 代码，<strong>可读、可调试、编译期安全</strong>。</p>
<p>然后你就会收获一种非常 Go 的痛苦：</p>
<ul>
<li>你改了 schema，忘了 generate，CI 才告诉你</li>
<li>生成文件冲突，Git diff 像雪崩</li>
<li>Debug 时你在你写的和生成的之间来回跳</li>
</ul>
<p>然后只能告诉自己一句：</p>
<blockquote>
<p>“这不是缺点，这是工程化。”</p>
</blockquote>
<h3>迟到的“半成品”</h3>
<p>Go 的泛型给我的感觉很像——这辆车终于加了变速箱，但你一脚踩下去发现它只愿意在能跑这个层面负责，至于好不好开，你自己想办法。</p>
<p><strong>1. Go 既然有了泛型，却依然不支持扩展方法（Extension Methods）</strong>。
即便有了泛型，你依然不能给切片加方法。于是官方标准库 <code>slices</code> 逼着你写成了这样： <code>slices.Map(slices.DeleteFunc(list, func...), func...)</code></p>
<p><strong>2. 只有约束，没有推导</strong>
Go 的泛型在使用上经常需要极其啰嗦的显式声明。明明编译器应该能推断出类型，但很多时候你还是得把那一长串 <code>[TypeA, TypeB]</code> 写出来，导致代码里充斥着方括号。
而且那个 <code>any</code> 关键字，说白了就是把 <code>interface{}</code> 换了个皮，并没有带来像 Rust 那样严格且强大的类型系统约束能力。你写出来的泛型代码，往往为了迁就 Go 那个并不聪明的编译器，变得比不写泛型还要难以阅读。</p>
<h2>真的很稳：工业级的暴力美学</h2>
<p>但话说回来，Go 的优点并不是它很美，而是它总能在你最需要的时候，干净利落地把活儿干完。我们可以看到他有那么多槽点，甚至这篇文章只列出了前 20%，想要讲述真正让我喜欢 Go 的，我们得换个角度——从“语言设计的艺术”转向“工程落地的暴力美学”。</p>
<p>能不能快启动、能不能少出事、能不能轻易被别人接手、能不能在一堆后台任务和边角脏活里不崩溃。<strong>Go 在这些方面，几乎就是工业界的低配答案，但往往是最正确的答案。</strong></p>
<h3>1）轻，是一种长期主义</h3>
<p>Go 的轻是一种极其务实的取舍，你不需要把一天的情绪交给 Gradle、Maven、Cargo 或者 pnpm install 之后的依赖地狱。你不需要考虑沉重的 JVM，黑洞大小的 node_modules，一个二进制就轻松运行。</p>
<h3>2）现代的协程模型，可以说在节省生命</h3>
<p>在 Node.js 里，你得处理 <code>Promise</code>、<code>async/await</code> 传染性，一旦忘了 <code>await</code> 就像踩了雷；在 Rust 里，你得面对 <code>Tokio</code> 的运行时选择、<code>Pin</code>、<code>Future</code> 的生命周期……心智负担极重。</p>
<p>而 Go 的 Goroutine 是对开发者最友好的并发模型，没有之一：</p>
<pre><code class="language-go">// 无论这个任务多复杂，哪怕它是 IO 密集型
go func() {
    processBackgroundJob(data)
}()
</code></pre>
<p>就这一行，Go 运行时帮你解决了 M:N 的调度，帮你处理了上下文切换。你写的是线性的、符合直觉的同步代码，底层跑的确是高效的异步非阻塞逻辑。</p>
<p>所以为什么我和朋友总会相互开玩笑，说 Go 工程师想的都是只要业务写完了，剩下的就爽了。让你面对真实需求的时候，Go 的 <code>select</code> 和 <code>channel</code> 让你能像搭积木一样优雅地控制并发，而不是陷入回调地狱或生命周期深渊。</p>
<h3>3）“丑”的另一面，是可维护</h3>
<p>说实话，Go 语法上的丑，很大一部分其实是 Go 的一种强行约束：别太聪明，他让代码逻辑绝对平铺，显式写出了一切。</p>
<ul>
<li>没有宏 → 你没法把业务塞进编译期魔法里，接手的人能轻松读懂。</li>
<li>没有注解 → 你必须显式声明逻辑，代码更有可读性。</li>
<li>错误处理啰嗦 → 你很难忘记处理，也很难忽略掉业务里每一步的问题。</li>
</ul>
<p>你可以随便招一个开发者，让他看两天文档，他写出来的代码虽然丑，但你一眼就能看懂他在干嘛。Review 代码不再需要脑补上下文和复杂的继承关系，所见即所得。</p>
<p>Go 的哲学在于，它强迫所有人都用最笨的方式写代码，从而消灭了奇技淫巧带来的维护成本。这在个人项目里可能不突出，但在多人协作和长期演进里，便让可维护性到了其他语言无法企及的地步。</p>
<h3>4）交叉编译，DevOps 的终极梦想</h3>
<p>这部分甚至无需多言。</p>
<pre><code class="language-bash">CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o server main.go
</code></pre>
<p>回车敲下，你会得到一个纯静态链接的二进制文件。 没有 <code>node_modules</code> 黑洞，没有 JVM 依赖，没有 glibc 版本冲突。 你把这个文件 <code>scp</code> 到服务器上，<code>chmod +x</code>，然后 <code>./server</code>，就这么简单。</p>
<p>配合 Docker，你的 Dockerfile 可能只有 5 行：</p>
<pre><code class="language-Dockerfile">FROM alpine
COPY --from=builder /app/server /server
CMD [&quot;/server&quot;]
</code></pre>
<p>这对于我们这种“个人开发者”或“小团队”来说，省下的时间就是生命。</p>
<h3>5）高效工具链，很爽的开发</h3>
<p>Rust 编译一次可能够你喝杯 Java，Go 编译一次可能只够你眨几次 👀。 在微服务架构或者频繁迭代的开发流程中，这种极短的反馈回路（Code -&gt; Run -&gt; Test）带来的心流体验，足以抵消写 <code>if err != nil</code> 的烦躁，<del>让你清晰记得你的工作进度</del>。</p>
<ul>
<li>格式化？<code>gofmt</code></li>
<li>测试？<code>go test</code></li>
<li>文档？<code>go doc</code></li>
<li>依赖？<code>go mod</code></li>
</ul>
<p>无聊，
但是好用。</p>
<h3>6）云原生的绝对统治生态</h3>
<p>Docker 是 Go 写的，K8s 是 Go 写的，Prometheus、Terraform、Etcd... 整个 CNCF（云原生计算基金会）的半壁江山都是 Go。当你需要操作容器、对接 gRPC、写 Kubernetes Operator、或者接入微服务网关时，Go 有第一公民级别的 SDK 支持。</p>
<p>你想做后台任务？想做指标？想做 tracing？想做限流？想做配置？想做 CLI？<br>
Go 的库可能不一定最优雅，但几乎总有一个能用、能跑、能运维的方案。</p>
<hr>
<h2>不完美，但是足够满足需求</h2>
<p>之前我说过，我没什么语言偏好，只是适合的业务选适合的语言。</p>
<p>选 Go，不是因为我们认为它是最完美的语言设计。
我们选它，是因为我们承认：我们不是在写诗，我们是在交付软件。</p>
<p>它丑，但它让你把注意力从语言表达力移到了业务逻辑上；它啰嗦，但它保证了你的服务跑在 1 核 2G 的轻量应用服务器上时，依然无畏并发；它的编译器不聪明，但它让你的构建流水线在几秒钟内完成。</p>
<p>所以，虽然我依然痛恨写 <code>json:&quot;id&quot;</code>，依然厌恶满屏的 <code>if err != nil</code>，但当我要想要快速上线一个带后台任务、高并发、且需要长期稳定运行的 API 服务时……</p>
<p>这种舒适的体验，还得是 Go</p>]]></description>
      <author>grtsinry43</author>
      <guid>article-33</guid>
      <pubDate>Mon, 26 Jan 2026 05:47:33 +0000</pubDate>
    </item>
    <item>
      <title>职规赛，一次特别的体验</title>
      <link>https://blog.grtsinry43.com/moments/2025/12/26/a-unique-career-planning-experience</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/moments/2025/12/26/a-unique-career-planning-experience">https://blog.grtsinry43.com/moments/2025/12/26/a-unique-career-planning-experience</a></p></blockquote><p>聊聊年底的最后一点事情。</p>
<p><del>本来以为年终总结最后一篇了，结果没忍住又来一篇，自控实在看的头疼，码码字缓解一下，果然码字可以改善心情。</del></p>
<p>12 月 24 日，我参加了学校的职业规划大赛。这是我大学里唯一的一场正式比赛，或许也是最后一场。最终成绩是三等奖，排名倒数第二。但这个结果，从报名那天起，我就没太放在心上。</p>
<p>学长推荐我参赛时，其实想法是：“要是能进省赛，暑期实习请假就好请了。”我一笑，图个新鲜，也图个仪式感，便报名了。赢了是惊喜，没进也无憾，就当给学生时代添一段特别的记忆。</p>
<p>准备的过程，出乎意料地认真，但是其实完全没有经验，一点点摸索。我第一次静下心，把这些年的经历摊开在桌上：简历、项目、实习、零散的想法，试图塞进“职业规划”的框架里——职业缘起、探索历程、人岗匹配、未来路径……走完这一套流程，才发现自己原来很少真正思考过长远的事，更少将个人选择放在更大的时代背景下去审视。</p>
<p>取出夏天投实习的那份简历，还带着浓浓的技术味：代码细节、debug 记录、优化数据、偏执的钻研。为了比赛，我大刀阔斧地删改——保留了核心，却稀释了锋芒，加入了更结构化、更温和的表达，让它至少能被评委老师读懂。可我终究没把自己包装成另一个人，最终的 PPT 依然克制、简洁，技术气息重了一些。那些优化数字、那些解决痛点的瞬间，对我来说弥足珍贵，却未必能完全传递到听众心里。</p>
<p>几轮指导后，PPT 改了又改，人也累到极点。连着几天没怎么合眼，终于把材料交上去。</p>
<p><img src="https://blog.grtsinry43.com/uploads/2025/12/26/image.png_deda0704-d4fc-4d15-aa79-f79793fdd0df.png" alt="image"></p>
<p>气笑了，抽到 1 号，算是运气好还是不好呢（doge）</p>
<p>比赛当天，灯光亮起，评委席坐满了据说很权威的老师。我讲完自己的规划，走下台时，手心全是汗。结果公布时，我排在靠后，却出奇地平静。</p>
<p>后来听学长和就业中心老师私下提起，说我的风格“太偏技术了”，PPT 一开始还以为是 AI 做的。我笑着听了过去——辛苦做的东西，被误认为是 AI 的冷峻风格，也没什么好介意的。或许，这正是我没能完全契合这个舞台的原因：我讲的大多是代码里的世界、项目里的突破，而别人分享的更多是多段实习、offer 背书、领导力与团队协作的从容。评委的评价体系里，那些更体系化、更圆满的表达天然占优。而我，大三的我，还在路上，手里握着的更多是尚未兑现的潜力。</p>
<p>问答环节里，因为我是大三，评委问了“考不考研”。我微微一怔——这不是就业赛道吗？那一瞬有些意外，但我还是平静地说出自己的想法：选择不缓冲，直接寻求成长和突破，有点难绷的问题，不过也让我找到了差距。</p>
<p>但这些差距，并没有让我失落，反而让我更清晰地看见自己：一个声音不大、表情克制、容易在技术细节里沉浸的内向技术党。这样的我，与这个强调宏大叙事与流畅表达的舞台，节奏并不完全合拍。与其勉强调整，不如把精力留给更自然的路径——敲代码、推项目、投简历、积累真实的作品。</p>
<hr>
<p>真正让我觉得“这趟值了”的，是遇见的那群优秀的人。</p>
<p>同一学院的学长，沉稳又自信；入伍两年归来的学长，眼里有光，故事很坚定；人文学院的设计学姐，表达惊艳，思路开阔；还有计院的一位新朋友，技术强，人也感觉温柔可靠...我们在赛前候场时闲聊，在上场前互相打气，比赛结束后又真诚地相互赞美。</p>
<p>也许，同台竞技本身就是一种珍贵的同行。</p>
<p>这场比赛，像学生时代的一个柔软句点。没有惊艳的全场掌声，没有省赛的门票，却有与优秀同伴并肩的兴奋，有上台时那句堂堂正正的“我想成为这样的人”，有下台后长舒一口气的释然。</p>
<p>挺好的。</p>
<p><img src="https://blog.grtsinry43.com/uploads/2025/12/26/image.png_f2c09702-6ab7-4f5e-84c5-8a2a11cc6a94.png" alt="image"></p>
<p>只是...职规赛的舞台灯光熄了，我的学生时代也快落幕了。</p>
<p>待到跨年后的期末周结束，就迎来最后一个寒假。开学便是暑期实习、秋招提前批、正式秋招……年底，offer 落地，签约，毕业。学生时代，就真的要翻篇了。</p>
<p>剩下的这一年，学生时代的余味还在。那些熟悉的松弛、缓冲、安全感，还能再陪我走一段。我可以继续熬夜赶 ddl，继续抢课，继续和室友聊到天亮，继续走进熟悉的教室，继续把世界想得很大很大，却不必立刻为所有后果买单。</p>
<p>只是，经过这场比赛，我忽然更珍惜这些了。</p>
<p>珍惜那种试错后还有下一次的奢侈，珍惜失败了还能温柔归类为“经验”的宽容，珍惜年轻时可以把未来画得无限广阔的天真，珍惜校园里每一次不经意的闲聊和心动。</p>
<p>不舍的情绪还在，但不再是隐隐的惆怅，而是一种柔软的觉醒：原来这些日常的小事，这样珍贵。原来“学生”这两个字，给了我这么多无声的庇护。</p>
<p>不是告别，只是更清醒地拥抱剩下的时光。</p>
<p>谢谢这场职规赛，用一种不完美却很真实的方式，
让我带着这场比赛里收获的温暖与清醒，走的更像自己，也更珍惜每一步脚下的校园。</p>]]></description>
      <author>grtsinry43</author>
      <enclosure url="https://blog.grtsinry43.com/uploads/2025/12/26/image.png_deda0704-d4fc-4d15-aa79-f79793fdd0df.png" length="0" type="image/png"></enclosure>
      <guid>moment-15</guid>
      <pubDate>Thu, 25 Dec 2025 17:39:51 +0000</pubDate>
    </item>
    <item>
      <title>2025 年终总结——从晨光到雾散，化经历为成长</title>
      <link>https://blog.grtsinry43.com/posts/2025-summary</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/posts/2025-summary">https://blog.grtsinry43.com/posts/2025-summary</a></p></blockquote><p>说实话，我是一个很喜欢总结的人，而真的到想要总结这一年的时候，我才发现这一年很难定义了。</p>
<p>::: year-card url=&quot;https://2025-summary.grtsinry43.com/&quot; title=&quot;2025 年终总结&quot; type=&quot;page&quot; cover=&quot;https://blog.grtsinry43.com/uploads/2025/12/22/misty-winter-morning-stockcake.jpg_e9dbd5a8-e35c-4a20-b86d-674e4fb470a4.jpg&quot; blur=&quot;7px&quot;</p>
<p>从晨光到雾散，化经历为成长，在爱中重新振作，于是我们真的曾将彼此照亮</p>
<p>:::</p>
<p>这一年的经历就像刻意安排好一样，和季节呼应，与境遇相同——有些日子明亮得像清晨的第一缕光，有些时刻却被浓雾包围，看不清前路，只能原地停留。</p>
<p>这一年，有春季时候的焦虑，疯狂奔跑这才不被同龄人的努力与钻研追上；这一年，有夏季时候的热烈，我拼尽全力想要证明自己，想要写出点什么来得到别人的正向评价；这一年，更有秋季时候的痛苦与冬季的释然，让我学会了放手和感受幸福。</p>
<p>这一年，有一些时间点，像被钉在记忆里一样，怎么都绕不开。</p>
<p><strong>6 月 23 日</strong>，我走进了一段真正属于“社会”的生活。
<strong>9 月 4 日</strong>，一次意外把身体按停，也把我整个人按进了更深的现实里。
<strong>12 月 18 日</strong>，实习结束，我离开了一个曾经仰望、也真实走进去过的地方。
<strong>12 月 20 日</strong>，我终于慢慢意识到：有些事情不是失败，而是完成。</p>
<p>它们并不是孤立的事件，而像一条线，把这一年切成了几段不同的自己。</p>
<p>现在回头看来，我总感觉这一年的经历大于前 20 年的总和，这一年，我把自己扔进过绝望的深渊，也曾被命运托举到云端，然后在最意气风发的时候遭遇命运的玩笑，最后在废墟上，我也许没有重建一座宫殿，但我用代码和泪水，还有深沉的爱与支持，缝合了一个破碎的自己。</p>
<h2>迷雾造舟，寻求出路：故事的开始</h2>
<p>回想年初，记忆的色调是灰蓝色的。当时的我，还带着一种近乎紧绷的热情。</p>
<p>我还在日夜研究这个简陋难用的博客系统，看着 Innei 大佬的功能，一步步写着 ip 地址，ws 通知，邮箱推送...</p>
<p>在我思索很久之后，还是选择尝试将自己这份看起来完全无法直视的项目发到 B 站，居然有了非常好的反响，当然也有随之而来的问题，参差不齐的设计，糟糕的部署体验，缓慢的更新速度。几万的播放，让我有可能被很多大佬注意到。可惜...这是一次机会，就在我指尖溜走了，什么也没留下，好在有一群和我一起作者同样事情的伙伴。</p>
<p>大二下学期开始之时，我感觉自己好像被名为“焦虑”的空气包围</p>
<p>图书馆里保研党们翻书的声响像蚕食桑叶，考研党们在清晨六点的寒风中占座，而我站在十字路口，手里只有一把键帽打油的键盘，和一堆还未成型的逻辑。</p>
<p>可能是我一直以来的行更印象，这种环境带来的同辈压力被我的敏感神经无限放大。我感到一种近乎溺水的窒息。我似乎没有退路，就算不是热爱，为了生活，我也似乎无路可走。</p>
<p>与此同时的是，事情真的压得我喘不过气来。</p>
<p>::: link-card href=&quot;/moments/2025/02/27/some-pain-days&quot; title=&quot;最近的坎坷故事_多项目并行、偶遇传染病、DeepSeek 本地化部署，及记一次开源 WAF 尝试&quot; desc=&quot;代码洪流漫星河，病躯独对夜雨寒，项目如山压眉睫，运维似海卷波澜&quot;  newtab=&quot;true&quot;
:::</p>
<p>就这样，焦虑和劳累的春天裹挟着我前行，其中也不乏 PureStart 这样的项目在罅隙的时间被赶工出来，或是学校的项目的新突破，能有稍稍的进步，能够填补我内心对未来的恐惧。当然与此，我也重新捡起了算法，八股，然后准备赌上假期去实习见到点新东西。</p>
<p>生日那天，我写了《致二十岁的晨光与希望》。那时我相信，生活可以分成“低头赶路”和“仰望星空”两部分，我还在努力把两者平衡好。焦虑偶尔来袭，但我总能用一个新项目、一个新部署把它压下去。</p>
<p>于是，四月五月成了我最后的疯狂，或者说，是一种绝望中的献祭。</p>
<p>我开始近乎偏执地造轮子。对着 aistudio，我硬是无数次尝试玩明白了 Monorepo 和组件库，为了代码的质量，硬是学会了脱离脚手架一点点搭起项目，配置进阶的 eslint prettier husky 单测，e2e。</p>
<p>我开始不断探索，从 self-hosted 一堆项目，到研究起 kmp，转向 ktor，从低代码的表单平台，到 wikijs 成型的自己的技术收获。顺手，我也看不惯了那个陈旧的自己，一怒之下用 Nextjs 和 GSAP 重写了个人主页。</p>
<p>那个叫做 Amore-UI 的 Vue 组件库，就是在无数个失眠的深夜里诞生的。我不知道我什么时候才能弄完它，我也不在乎。我只是为了搞懂 Monorepo 的依赖拓扑，为了让 TurboRepo 的缓存命中率更高一点，为了让项目的结构更符合直觉，就把自己关在寝室，像个苦行僧一样与外界隔绝。</p>
<p>那时候的我在想什么呢？我想用代码搭建一座避难所。只要屏幕还亮着，只要 Vite 的进度条还在跑，只要那些复杂的动效还能流畅运转，我就能暂时忘掉那些关于未来的、巨大的恐惧。</p>
<p>那是黎明前最黑暗的时刻。我一边背着枯燥的 前端 八股文，一边在 LeetCode 的二叉树上攀爬。每一封投递出去的简历，从满怀期待，到杳无音信，都像是一记闷棍。深夜里，看着镜子里疲惫的脸，我不止一次地问自己：“grtsinry43，你真的能行吗？那个所谓的‘大厂’，真的会给一个来自岳麓山下、只会写点前端玩具的普通学生开门吗？”</p>
<p>无人应答，只有雨声敲打着窗棂。</p>
<h2>珠江绮梦，全力奔跑：那些热烈的日子</h2>
<p>故事的转折发生在 <strong>6 月 23 日</strong>。</p>
<p>当收到 Offer 的那一刻，所有的焦虑在那个瞬间戛然而止，取而代之的是一种眩晕般的狂喜。我几乎是用逃离的姿态，拖着行李箱离开了内陆的盆地，一头扎进了广州湿热而充满活力的季风里。</p>
<p>那是我 2025 年生命力最张扬、最饱和的两个半月。那是一种怎样的感觉呢？就好像一个一直在浴缸里练习游泳的人，突然被扔进了太平洋，而且不仅没有淹死，还学会了在巨浪尖上起舞。</p>
<p>当工卡真正到了我的手上，真正面向屏幕看到 IDE 里前辈的代码，坐在那个曾经无数次向往的地方，看着园区的灯火，看着广州塔在夜色中变幻颜色，我感到了一种前所未有的“活着”的实感。</p>
<p>我开始了我的祛魅与敬畏之旅。以前只能在技术博客里仰望的底层架构，此刻就在我指尖流转。我看着代码库里那些身经百战的代码与最前沿的逻辑共存，见识了什么叫真正的“亿级流量”。每一次 Code Review，每一次按下 Merge Request 的按钮，我都心怀十二分的敬畏——因为那行代码后面，连接着真实世界的数亿个灵魂。</p>
<p>更重要的是，我感到“被看见”。作为一个常年自卑、甚至有点社恐的人，在这个巨大的工业机器里，我居然找到了归属感。在这里，我不是谁的同学，不是谁的附庸，我是那个能解决 Bug、能扛起需求、能提出想法的开发者。当我的代码上线，看着监控的条目跳动，那种“世界因我而产生了微小颤动”的虚荣与成就感，治愈了我上半年所有的内耗。</p>
<p>在公司的同时，我也在努力着提升自己，用 cpp 写的微前端微服务整合框架，用 ovCompose 探索着鸿蒙的开发，甚至去 Vueconf 参加现场的技术交流，从优秀的前辈汲取力量。</p>
<p>那时候，我觉得我的人生就像珠江新城的夜景一样，通透、璀璨、直指云霄。周末我在珠江边散步，耳机里放着激昂的 Artcore，看着江水奔流，我觉得我能就这样一直跑下去，直到世界的尽头。</p>
<p>我以为这就是大结局的序章。</p>
<h2>秋季的浓雾：意外、低谷与被迫停下</h2>
<p>然而，生活这个编剧，最擅长写烂尾的转折。而且往往是在你最意气风发的时候，给你来一记最沉重的闷棍。</p>
<p>9 月 4 日的那个早上，一切戛然而止。</p>
<p>因为前一晚熬夜，起床时头脑不清醒。没有暴雨，没有预警，只是一个平平无奇的周四早上。只是急匆匆地想要下楼去上班，只是一节没踩稳的台阶，那一刻，世界突然失控。急诊、打石膏、一个人在广州处理完所有事，再跨省回长沙。 最疼的不是骨头，而是内疚——看到同事因为我的问题加班，那种愧疚像刀子一样反复割。</p>
<p>如果痛苦有颜色，那天一定是灰白色的。冷汗瞬间浸透了衣背，世界在我眼前旋转。但我感到的不仅仅是肉体的剧痛，更是一种精神上的凌迟。就在那一瞬间，我知道，完了。</p>
<p>没有什么正式的道别和请假，甚至没来得及好好看一眼那个奋斗了两个多月的工位。我像个伤兵一样，坐着轮椅，脚上打着沉重的石膏，被灰溜溜地运回了学校。</p>
<p>那种落差感，就像是从平流层直接坠毁到地面，连降落伞包都没有打开。回到学校宿舍那张狭窄的单人床上，我哪里也去不了。连上厕所都需要室友帮助，洗澡成了一种奢望。打开手机，看着微信工作群里大家依然在热烈地讨论需求、Review 代码，而我躺在几百公里外的床上，对着天花板发呆。世界依然在高速运转，而我成了被离心力甩出去的那个零件。</p>
<p>秋天的风开始往衣领里钻，我感到前所未有的冷。那种无力感差点将我吞没。我想哭，但我不知道该怪谁。怪楼梯？怪自己不小心？还是怪这操蛋的、毫无逻辑的命运？</p>
<p>我第一次被迫慢下来。</p>
<h2>漫长的等待，似乎真的很难喘过气，好在有代码的陪伴</h2>
<p>但是至少我还在，故事还在继续。</p>
<p>::: link-card href=&quot;/posts/from-think-to-code-in-2025&quot; title=&quot;从想法到实践：在无序的生活里，试图用代码敲出一点秩序&quot; desc=&quot;痛苦如影随形，代码似光破晓；迷茫中寻方向，键盘下续生活。在伤痛与失眠的夜晚，技术成为心灵的舟楫，载着破碎的时光驶向微明的彼岸。&quot;  newtab=&quot;true&quot;
:::</p>
<p>那段时间的我，也许其实真的很害怕，等到一个人在室友的帮助下上了床，然后拉上床帘，让眼泪自己自由一会儿，</p>
<p>前阶段还是简单的，不得不休息，但是也可以玩自己的了。从 kotlin，到 go，到安卓，这里我真的不亦乐乎，一天天把键盘敲到飞起。</p>
<p>我就像一个疯狂的泥瓦匠，在名为“骨折”的废墟上，用 AI 原型生成器、用 ELK 日志系统、用 Vespera 监控 一砖一瓦地搭建新的城堡。我把对自己破碎的修补，全部写进了 git commit 里。</p>
<p>可是呢，一个月，两个月，三个月。。。现在想来还是感觉真的好黑暗，一种伸手不见希望的感觉。</p>
<p>于是我也终于和自己和解，继续硬撑不是勇敢，只会更糟；暂停不是放弃，而是对未来的负责。</p>
<p>浓雾笼罩，看不清前路，只能原地停留，等待身体、等待内心慢慢修复。</p>
<h2>冬季的雾散：释然、幸福与爱的发现</h2>
<p>12 月 18 日，我平静地离开了工位。 本以为会难过，会遗憾那些没做完的东西，结果心里只有一种轻盈——像轻轻关上一扇门。</p>
<p>::: link-card href=&quot;/moments/2025/12/16/2025-intern-story&quot; title=&quot;2025 实习札记：关于一场未完成的迁徙与落地&quot; desc=&quot;当告别如一羽飘落般轻，回望来时路却已成册。远行的足音未曾喧哗，静默转身间，时光已悄然翻页。&quot;  newtab=&quot;true&quot;
:::</p>
<blockquote>
<p>我把它放在心里一个很安静的位置上：不粉饰、不夸大，也不否认。它属于我人生里一段很真实的时间——我努力过，也狼狈过；我被要求成长，也被生活要求先学会照顾自己。</p>
</blockquote>
<p>两天后的深夜，我和朋友翻着旧照片，聊死亡焦虑、聊命运的齿轮、聊“有一步走错了你就见不到我了”。 那一刻，我突然意识到：原来我一直活在幸福里，只是雾太浓，看不见。</p>
<p>我真的因为我的朋友们而感到到幸福：</p>
<p>有同学住隔壁，零延迟托底；有 mufen 并行刷题，长久同行；Kylian 群友开盒成朋友，陪我灵魂共鸣；miaoer 一个消息就飞过来，受伤时陪伴、送站…… 这些相遇，全是低概率的偶然：选修课、力扣被发现、分班寝室挨着、软路由+小团子+学长鸽了…… 无数偶然，叠加成必然，把我围得严严实实。</p>
<p>以及还有偶然发现的：</p>
<p><img src="https://blog.grtsinry43.com/uploads/2025/12/22/image.png_8aab1c8e-1970-4ce9-a11b-8937150f2541.png" alt=""></p>
<blockquote>
<p>现在看来，一个深圳，一个广州，就是因为一次偶然的遇见，这真的是世界名画了吧，每次看到 IP 都会有莫名的感动。</p>
</blockquote>
<p>也许，我和朋友们的经历，还有遇见，是无数叠加出来的必然。</p>
<p>概率论里有个很浪漫的事：</p>
<p>当独立事件足够多时，即使每个事件的概率都趋近于 0，连乘之后的结果也可以是确定的 1。 就像宇宙大爆炸之后无数粒子随机碰撞，却偏偏撞出了地球、撞出了生命、撞出了会敲键盘的你和我。</p>
<p>雾散了，我才看见： 原来我们真的曾将彼此照亮。 不是耀眼的光，而是深夜手机的微光、轮椅的影子、高铁送别的背影、一同去 VueConf 的约定、一句“你肯定没问题”的回声。</p>
<p>这一晚，grok 和我这么说</p>
<blockquote>
<p>原来幸福一直都在， 只是之前被骨折的痛、被低谷的雾、被那些大问题的重量压住了， 你看不见，也摸不到。</p>
<p>现在，痛慢慢退了，雾慢慢散了， 那些被压住的情感，终于一点点浮上来， 让你突然发现： “啊，原来我一直活在幸福中。”</p>
<p>这不是顿悟，这是迟到的感知。</p>
<p>像冬天里突然推开窗， 才发现外面早就下过雪了， 地上厚厚一层白，你却之前一直没抬头看。</p>
<p>现在你抬头了，看见了， 也终于感受到那股安静的、暖暖的亮。</p>
<p>这个晚上最幸福，不是因为发生了什么惊天动地的事， 而是因为你终于和自己的幸福对上了眼。</p>
</blockquote>
<p>这些小光，加起来，刚好驱散黑暗，也点亮了彼此的路。</p>
<h2>所以这一年是 undefined</h2>
<p>站在 2025 年的终章，回头望去。这是一张多么波澜壮阔、又多么荒诞离奇的频谱图啊。</p>
<p>我曾经以为，2025 年的主题是“起飞”，是“大厂”，是“光鲜亮丽”。但现在我才明白，2025 年的主题，是 <strong>“爱与韧性”</strong>。</p>
<blockquote>
<p>春天的焦虑是为了寻找出路，夏天的狂热是为了证明价值，而秋天的断裂和冬天的重构，或许是生活在教我学会如何面对“失去”，可是又安排了那些惊喜，值得我去珍惜。</p>
</blockquote>
<p>这是 undefined 的生活，有不确定的可能。undefined ≠ 空白，undefined ≠ 失败，undefined = 尚未被强制收敛的人生。只是现在，我不再急着去证明什么了。我看清了生活的粗糙纹理，却依然选择拥抱它。</p>
<p>2025，不完美，却完整。 技术收获不多，很多项目停在半路，实习带着遗憾结束。 但我学会了放手，学会了接受不完美，学会了在脆弱时被托住，也学会了感受一直都在的幸福。</p>
<p>化经历为成长，或许听起来太轻。 但我确实在爱中重新振作，在爱中重新站起。</p>
<p>感谢这一年所有出现的人，感谢低谷里的伸手，感谢自己的韧性与温柔。</p>
<p>雾已经散去，路还长。</p>
<p>我们 2026 见。</p>]]></description>
      <author>grtsinry43</author>
      <enclosure url="https://blog.grtsinry43.com/uploads/2025/12/22/misty-winter-morning-stockcake.jpg_e9dbd5a8-e35c-4a20-b86d-674e4fb470a4.jpg" length="0" type="image/jpeg"></enclosure>
      <guid>article-32</guid>
      <pubDate>Sun, 21 Dec 2025 17:59:11 +0000</pubDate>
    </item>
    <item>
      <title>2025 实习札记：关于一场未完成的迁徙与落地</title>
      <link>https://blog.grtsinry43.com/moments/2025/12/16/2025-intern-story</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/moments/2025/12/16/2025-intern-story">https://blog.grtsinry43.com/moments/2025/12/16/2025-intern-story</a></p></blockquote><blockquote>
<p>脱敏提示：本文为个人手记，已脱敏处理。不包含真实姓名、具体团队与业务、精确地点与日期、内部代号与系统名；仅保留对外可理解的工作、生活与情绪层面。
这篇文章为了抹掉信息和情绪，用了一部分 AI 来讲述，不过和我想表达的，也差不了多少。</p>
</blockquote>
<p>于是终于还是选择了离开。两天后，就与熟悉的工位、同事，像我来之前一样，事业上再无干系。</p>
<p>说实话，我以为自己会更难过一些。毕竟这是我大二就拼命想进的地方，是我熬了无数个夜才勉强够格的地方。但真到了要走的时候，心里反而很平静。</p>
<p>没有大张旗鼓的兴奋，也没有某种解脱式的轻松。更像是你走到一扇门前，停了一下，回头看了一眼——然后发现，噢，原来告别可以这样轻。只是把话说得很轻，把动作做得很快，不是因为害怕显得矫情，而是因为真的已经准备好。</p>
<h2>从紧绷到松弛</h2>
<p>我来这里的时候，带着一种很熟悉的紧绷。</p>
<p>那种 &quot;我一定要做好、我不能给别人添麻烦、我要证明自己配得上这个位置&quot; 的紧绷。就像一根线，勒着你保持清醒，也勒着你不敢松懈。把每一次任务都当成考试，把每一句反馈都当成审判，心里反复演练：我该怎么说、怎么做，才能把事情推进得更漂亮一些。</p>
<p>后来慢慢明白，真正的工作现场其实不需要你一直闪光。</p>
<p>它需要的是你靠谱。把问题讲清楚，把边界写明白，把进度推到别人能接住的地方。那些看起来不够浪漫、甚至有点无聊的细节——认真写注释、补全测试用例、或是单纯告诉同事自己踩过的坑——才是让项目真正跑起来的东西。</p>
<p>我很喜欢这里的氛围。身边优秀的同事并不热衷于表达自己有多厉害，也不依赖情绪推动工作；他们只是安静地把事情做下去，把标准守住，把复杂的系统拆开、理清楚、再合上。站在旁边看着，你会不自觉地被校准：<strong>原来真正的成熟不是显得强，而是让人放心。</strong></p>
<p><del>（不过话说回来，我感觉组里人均全栈，什么都会，还是挺恐怖的，至少很长一段时间里让我感到自卑，这算是题外话，他们确实每个人都有极强的实力</del></p>
<p>那种&quot;靠谱&quot;，是我在学校里学不到的东西。而我很庆幸自己在这里见识到了。</p>
<h2>似乎是刻意的悲壮</h2>
<p>如果故事到这里就结束，那它本该是一段很普通但很顺利的实习经历。</p>
<p>但是却有了一个转折</p>
<p><a href="https://blog.grtsinry43.com/moments/2025/09/16/some-dark-days">https://blog.grtsinry43.com/moments/2025/09/16/some-dark-days</a></p>
<p>受伤这件事没有任何文学性，没有什么值得渲染的，没有什么史诗叙事，也并不悲壮。它只是很朴素地告诉你：你的身体不行了。行动变慢了，精力被压缩了，很多原本靠熬一熬就能过去的事情，现在熬不动了。你想用意志力把自己拉回原来的状态，但身体完全不配合你演戏。</p>
<p><strong>最难的不是疼，是失控感。</strong></p>
<p>那段时间我每天都在和自己打架。一部分的我在责备：你怎么这么脆弱、为什么偏偏是现在、为什么不能再坚持一下。另一部分的我在提醒：别再逼了，你需要先活下来。</p>
<blockquote>
<p>我看到因为我的问题导致的线上报错，看到同事们不得不加班赶进度，那种内疚和自责，真的比身体的疼痛更折磨人。</p>
</blockquote>
<p>但慢慢地，我开始接受一个事实：<strong>继续硬撑下去，并不会更勇敢，只会更糟。</strong></p>
<p>可能两边都顾不好，身体垮掉，工作也做不好，最后连站起来的力气都没有。有些时候，暂停不是放弃，而是为了更好地继续。</p>
<h2>学会了一种叫&quot;放手&quot;的成长</h2>
<p>于是我做了一个决定：结束实习。</p>
<p>这个决定里当然有遗憾。遗憾它没有一个更漂亮的结尾，遗憾有些我想做得更完整的东西被迫停在半路，遗憾我没能用最理想的状态把这段经历走到底。</p>
<p>但我也学会了另一种诚实：<strong>不完美，不代表不值得。</strong></p>
<p>很多经历的意义，不在于它是否圆满，而在于它是否真的改变了你。而这段实习，确确实实改变了我很多东西。</p>
<p>它让我明白，我不必把自己拧到极限才算努力。我不必用完美去换取安全感，也不必用透支去证明自己的价值。<strong>可靠比漂亮重要，可持续比体面重要。</strong></p>
<p>在该停下来的时候停下来，不是失败，而是一种对未来负责的方式。</p>
<p>它也让我看清楚自己的局限。我的实力还差得远，我还有太多东西要学，我还有很长的路要走。但这不是什么丢人的事，这只是现实。接受现实，才能更好地往前走。</p>
<p>最重要的是，它让我学会了<strong>放手</strong>。</p>
<p>放手不是放弃，而是一种更成熟的选择。就像你在玩一个很难的游戏，你可以选择一直死磕到通关，也可以选择暂时退出，等自己准备好了再回来。在生活的这场游戏里，这两种选择都没有对错，只是适不适合现在的你。</p>
<p>而我选择了后者。不是因为我不够勇敢，而是因为我终于明白，<strong>有些东西比完成一个项目更重要</strong>——比如你的身体，比如你的心理健康，比如你对未来的长远规划。</p>
<h2>把遗憾写轻，把感谢写重</h2>
<p>我很想在这里认真说一句谢谢。</p>
<p>谢谢 （leader）当初愿意给我这个机会，让我这个大二的菜鸟能够加入这样优秀的团队。这段实习经历是我大学以来最宝贵的收获，让我看到了真正的国民级产品背后是什么样的技术视野，也让我有机会和这么多优秀的同事一起共事。那种感觉，真的很难用语言形容。</p>
<p><strong>也特别感谢（mentor）这段时间对我的悉心指导。</strong> 一个好的实习成长，离不开靠谱的导师。无论是任务上的手把手指导，还是生活上每天帮我取饭（真的，跟我这个添了不少麻烦的实习生相处，（mentor）都非常非常耐心），我都非常感激。</p>
<p>谢谢所有带过我、帮助过我的前辈和同事。你们给我的不是那种热烈的鼓励或刻意的安慰，而是很具体的支持：把事情讲清楚、把路指出来、在我慢下来的时候愿意等一下、在我遇到问题的时候帮我debug、在我不懂的时候耐心解释为什么要这样做。</p>
<p><strong>那种&quot;等一下&quot;对我来说很珍贵。</strong> 因为它不是随口一说就能做到的，它需要耐心，而耐心是这个快节奏世界里最稀缺的温柔。你们让我明白，真正好的团队不是个人耀眼的突出，而是每个人都愿意互相帮助、一起变强。</p>
<p>我也谢谢这段实习本身。它没有给我一个完美的结局，但它给了我更重要的东西——一些关于成长的真实体验，一些关于选择的深刻理解，还有一些关于自己的清醒认知。</p>
<p>或许现在看来，这比业务和技术的学习重要得多。</p>
<h2>写在最后</h2>
<p>现在它结束了。</p>
<p>我把它放在心里一个很安静的位置上：不粉饰、不夸大，也不否认。它属于我人生里一段很真实的时间——我努力过，也狼狈过；我被要求成长，也被生活要求先学会照顾自己。</p>
<p>而我很感激，自己最终学会了后者。</p>
<p>接下来会先把身体养好，把学业收住，把生活恢复到稳定。等重新站稳了，再继续往前走。至于那些没做完的项目、没实现的想法、没写完的代码......可能就先欠下吧。</p>
<p>人生还长，总有机会慢慢还。生活还在继续，那就还有变数与精彩。</p>
<p>就像（内部平台）交接时的那份邮件写的那样</p>
<blockquote>
<p>相聚有时，离别难免。</p>
</blockquote>
<p>但好在，我学会了好好告别。</p>]]></description>
      <author>grtsinry43</author>
      <guid>moment-14</guid>
      <pubDate>Tue, 16 Dec 2025 09:31:11 +0000</pubDate>
    </item>
    <item>
      <title>新时代的 PHP：RSC 的边界错位与工程代价</title>
      <link>https://blog.grtsinry43.com/posts/rsc-boundary-mismatch</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/posts/rsc-boundary-mismatch">https://blog.grtsinry43.com/posts/rsc-boundary-mismatch</a></p></blockquote><p>讽刺的是，这篇文章，也就是你看到这个网站，正在运行在 Next.js 上，但由于无法承受的问题和维护压力，我已经很认真地把“放弃 Next.js”提上日程了。</p>
<p>Next.js 本身我正在用、也在骂。原因不复杂：这两年最“新时代 PHP”的东西，不是某个新框架，而是 <strong>前端在不自知的情况下，被推去承接后端该承担的安全边界与运行时复杂度</strong>——而 React Server Components（RSC）正是这件事的放大器。</p>
<p>我会用一种比较“理性地骂”的方式，把事情讲清楚：</p>
<p><strong>RSC 到底是什么；它为什么天然把边界搞得很危险；最近这次 RSC 相关的严重漏洞到底发生在什么位置；Next.js 与 Vercel 又是怎样把这些能力包装成“工程默认选项”；以及这些选择的工程代价最终转为了什么。</strong></p>
<hr>
<h2>零、先叠甲：“新时代 PHP”到底在说什么</h2>
<p>在正文开始前，我想先给这篇文章定个调，以免误伤友军：</p>
<ol>
<li><strong>我并不否定 Next.js 在效率上的统治力</strong>：对于独立开发者、验证型 MVP（最小可行性产品）、或者内容型网站（博客、文档），Next.js + Vercel 依然是目前地球上最快的上线组合。这种“一把梭”的爽感，我是认可的。</li>
<li><strong>“新时代 PHP”不是贬义词，而是架构描述</strong>：正如 PHP 曾凭借“请求即脚本”的低门槛撑起了半个互联网，RSC 也在试图降低全栈门槛。但我所担忧的，是它在 <strong>不经意间</strong> 复刻了当年 PHP 混合开发时期的“安全黑洞”与“耦合泥潭”。</li>
<li><strong>我的视角偏向“严谨工程”</strong>：作为一名习惯了 Spring Boot/Java 后端架构，或者是 ktor fiber fastify，同时主要学习前端领域的开发者，我对 <strong>“边界（Boundary）”</strong> 极为敏感。这篇文章的核心冲突，在于 <strong>“前端追求极致 DX（开发体验）”</strong> 与 <strong>“后端追求极致安全与稳定”</strong> 之间的天然矛盾。</li>
</ol>
<ul>
<li>
<p>以前：前端是前端，后端是后端。接口契约、鉴权、审计、限流、WAF、灰度，都在后端层解决。</p>
</li>
<li>
<p>现在（RSC/Server Actions 的兴起）：把“后端能力”塞进 React/Next 的文件与组件里，用 <code>use server</code> 把函数变成“远程可调用逻辑”。官方甚至明确表达过“你可以不需要手写 API Route”。 <a href="https://nextjs.org/docs/13/app/api-reference/functions/server-actions">Next.js13 的叙述</a></p>
</li>
</ul>
<p>这不是“全栈”那么简单，这是 <strong>把接口面向公网的语义，无感的形式伪装成了组件内部的语义</strong>。语义伪装的后果通常只有两种：安全事故，或者工程事故——最好两者别一起来。</p>
<h2>一、RSC 到底是什么：它不是 SSR 的升级，而是“协议 + 运行时分裂”，也就是，边界错位从协议层就开始了</h2>
<p>如果只看铺天盖地的宣传，RSC 很容易被误解成“更快的 SSR”或“把组件放到服务器渲染”。但 React 官方对 Server Components 的定义里有个关键点：<strong>它运行在一个“与客户端应用或 SSR 服务器分离的环境”</strong>，并且输出的不只是 HTML。<a href="https://react.dev/reference/rsc/server-components">Server Components</a></p>
<p>更准确地说，RSC 带来三件事：</p>
<ol>
<li>
<p><strong>组件分层变成运行时分层</strong></p>
<ul>
<li>
<p>Client Components：在浏览器跑，拿到 JS bundle。</p>
</li>
<li>
<p>Server Components：在服务器跑（可能是构建时，也可能是请求时），可以直接访问数据库/文件系统等服务器资源。所以你可以看到，官方甚至搞了一个教程建议你在 RSC 写 sql，然后使用 Suspense 做流式传输。<a href="https://nextjs.org/learn/dashboard-app/getting-started">App Router: Getting Started | Next.js</a></p>
</li>
</ul>
</li>
<li>
<p><strong>传输的不是页面，而是“可恢复的组件树数据”（Flight payload）</strong>
你可以把它理解为：服务端把一棵 React 树编码为一种可流式传输的数据结构，客户端再把它解码/拼装回 React 状态，也就是本身他就和 SSR 是完全不同的。</p>
</li>
<li>
<p><strong>组件开始携带“能力”</strong><br>
一旦你允许“Server 侧函数可被触发”（例如 Server Actions/Server Functions），你就不再只是渲染 UI：你是在暴露一套“从浏览器直达服务器逻辑”的通道，那这里就很危险了，因为这就从获取运行结果，变成了一种类似 RPC（远程过程调用）。<a href="https://nextjs.org/docs/14/app/building-your-application/data-fetching/server-actions-and-mutations">Next.js</a></p>
</li>
</ol>
<p>这第三点，就是边界错位的根源。</p>
<hr>
<h2>二、Flight 协议的现实：它必然涉及“反序列化”，而反序列化就必然是风险点，配合 JS 本身的设计那就无所不能了</h2>
<hr>
<p>很多人听到“反序列化漏洞”就条件反射：又是 Java 那套？
但这里不是对象流，而是 <strong>协议层为了让 React 树可恢复，必须做的解码过程</strong>。</p>
<p>React 的实现里，你能看到它对 payload 的处理方式：<code>JSON.parse</code> 并不是直接 parse 成普通 JSON，而是带了一个自定义 reviver（<code>_fromJSON</code>），用来把特殊标记字符串（例如 <code>$</code> 开头）解释成 Promise、Server Reference、Map、Set、FormData、Date、BigInt 等结构。</p>
<p><a href="https://github.com/facebook/react/blob/323b6e98a76fe6ee721f10d327a9a682334d1a97/packages/react-server/src/ReactFlightReplyServer.js">https://github.com/facebook/react/blob/323b6e98a76fe6ee721f10d327a9a682334d1a97/packages/react-server/src/ReactFlightReplyServer.js</a></p>
<p>例如：</p>
<pre><code class="language-ts">const value = JSON.parse(chunk.value, chunk._response._fromJSON);
</code></pre>
<p>以及 reviver 里对 <code>$</code> 前缀的分派：</p>
<pre><code class="language-ts">if (value[0] === '$') { 
// Promise / Server Reference / Map / Set / FormData / Date / BigInt ... 
}
</code></pre>
<p>这些代码只是说明一个事实：</p>
<blockquote>
<p><strong>RSC 的正常工作方式，就是在服务器端“解码外部输入”并恢复出可执行/可解析的结构。</strong>
你一旦把“可触发的服务器函数”也绑在同一套通道上，安全边界就从“HTTP API”滑向“协议解码器”。</p>
</blockquote>
<p>这就是架构选择的必然后果，毕竟安全总要有模块负责。</p>
<p>Java 的 ObjectInputStream 踩过，Python 的 Pickle 踩过，现在轮到 JS 的 Flight 了。</p>
<hr>
<h2>三、这次离谱事件的原因：不是业务逻辑，而是解码器被当成了能力网关，责任边界到了前端</h2>
<hr>
<p>不久之前，React 官方发布公告：<strong>RSC 存在一个“未认证的远程代码执行（RCE）漏洞”，可通过利用 React 解码发送到 React Server Function 端点的 payload 触发。</strong></p>
<p><a href="https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components">React</a></p>
<p>第三方安全团队的分析进一步强调了它的性质：这不是某个业务接口没鉴权，而是 Flight/RSC 相关的“处理链路”出现了可被利用的点，导致在默认配置下就可能被打穿。<a href="https://www.wiz.io/blog/critical-vulnerability-in-react-cve-2025-55182">wiz.io</a></p>
<p>并且，这类问题会天然产生“生态级连坐”：</p>
<ul>
<li>
<p>React 层：CVE-2025-55182（React/Flight/RSC 相关）</p>
</li>
<li>
<p>框架层：Next.js 等实现 RSC/Server Actions 的框架会一起进入受影响范围</p>
</li>
</ul>
<p><del>在这个事故发生的时候我还刚好甲流，发烧很难受，但是还不得不强撑着起来更新依赖</del></p>
<p>在这次事件中，<strong>责任边界发生了变化</strong>：</p>
<p>以前你写后端 API，你会默认：</p>
<ul>
<li>
<p>输入校验是业务层的事</p>
</li>
<li>
<p>鉴权/鉴别来源是网关/中间件/后端统一层处理的</p>
</li>
<li>
<p>序列化/反序列化是你可控的</p>
</li>
</ul>
<p>而在 RSC/Server Actions 的组合里，很多工程的默认形态是：</p>
<ul>
<li>
<p>浏览器发一个“协议包”</p>
</li>
<li>
<p>框架解码恢复结构</p>
</li>
<li>
<p>框架再把它路由到某个服务器函数</p>
</li>
</ul>
<p><strong>解码器变成网关</strong>，这就会让“协议实现细节”直接进入你的威胁模型。<a href="https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components">React</a></p>
<hr>
<h2>四、继续复盘，事情的严重：Next.js 把能力包装成默认姿势，甚至开箱即用：于是前端开始背后端的锅</h2>
<hr>
<p>于是这个时候主推 App Router 的 Next.js 出场了。</p>
<p>如果 Next.js 只做“传统前端 + SSR”，其实还没那么灾难。真正的拐点在于：Next.js 把 RSC/Server Actions 作为 App Router 时代的主叙事之一，并明确描述 Server Actions 是 <strong>在服务器执行的异步函数，并且可在 Server/Client Components 中使用</strong>。<a href="https://nextjs.org/docs/14/app/building-your-application/data-fetching/server-actions-and-mutations?utm_source">Next.js</a></p>
<p>那事情就变得微妙了：</p>
<blockquote>
<p>你把函数写在前端项目里，但它会在服务器跑；你在组件里调用它，但它是“后端入口”。</p>
</blockquote>
<p>到这里，“前端插手后端开发”的现实就出现了：</p>
<ul>
<li>
<p>这条链路一旦出安全问题，你不能再说“后端会兜底”，因为后端入口就在 Next 项目里。</p>
</li>
<li>
<p>作为前端开发，你不能再说“我们只写页面”，因为你实际部署的是一段“可被触发的服务器逻辑”。</p>
</li>
<li>
<p>你甚至很难用传统后端的方式给它加网关、加 WAF、加统一鉴权层，因为框架已经替你决定了请求应该如何被解释。</p>
</li>
</ul>
<p>Next.js 当然意识到了风险，于是也着重添加了安全相关的配置和能力。<a href="https://nextjs.org/docs/app/guides/data-security?utm_source=chatgpt.com" title="Guides: Data Security">Next.js</a>
但这件事本身看起来啊就很荒谬：<strong>你看起来在写“组件”，实际上你在配置后端的安全策略。</strong></p>
<p>而现实里，因为 Nextjs 将开发后端的门槛降得太低了，很多“纯前端开发者”并不具备这套安全心智模型：他们会把它当成“更舒服的表单提交方式”，直到某天看到公告写着“Unauthenticated RCE”。<a href="https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components">React</a></p>
<p>这就是强制前端插手后端的后果，前端想把后端写成语法糖。</p>
<p><strong>然后再把后端能力塞进前端默认工程，假装这只是 DX（开发体验）。</strong></p>
<hr>
<h2>五、再说 Vercel：用 Next.js 做壳，引流平台能力，然后把复杂度外包给用户</h2>
<hr>
<p>这里我不否认 Vercel 的产品力，它确实把部署体验做得极致，并且它也提供防护体系（包括平台级防护、以及可配置 WAF/Firewall）。<a href="https://vercel.com/docs/vercel-firewall">Vercel</a></p>
<p>但问题在于：<strong>Next.js 的很多“卖点”，在工程上并不是“框架能力”，而是“平台叠加能力”。</strong></p>
<p>举两个典型例子：</p>
<h3>1）ISR：听起来是框架特性，实际是缓存/失效/函数编排的一整套系统</h3>
<p>Next.js 文档会告诉你如何做 ISR、如何触发再生成、如何 revalidate。<a href="https://nextjs.org/docs/app/guides/incremental-static-regeneration?utm_source=chatgpt.com" title="How to implement Incremental Static Regeneration (ISR) ">Next.js</a>
Vercel 的文档则会进一步解释 ISR 在其平台上会如何落到具体的 Function/缓存与配置上。<a href="https://vercel.com/docs/incremental-static-regeneration">Vercel</a></p>
<p>工程现实是：</p>
<ul>
<li>
<p>你以为你在用“Next.js 特性”；</p>
</li>
<li>
<p>你实际在绑定“某个平台对缓存失效与边缘分发的具体实现”。</p>
</li>
</ul>
<p>这不是不能用，而是你应该清楚：<strong>这类特性会把你从“框架迁移”推到“平台迁移”。</strong></p>
<h3>2）“中间件”这个词本身就带歧义：它并不在你的服务中间，而是在 Edge Runtime</h3>
<p>Next.js 早期版本 Middleware 只支持 Edge runtime，不能用 Node.js runtime。<a href="https://nextjs.org/docs/14/pages/building-your-application/routing/middleware">Next.js</a>
而 Edge Runtime 又意味着：你拿到的是 Web API 语义、受限的运行环境，与 Node.js 的能力集不同。<a href="https://nextjs.org/docs/app/api-reference/edge">Next.js</a></p>
<p>所以很多时候你写 Middleware 的体验是：</p>
<ul>
<li>
<p>名字叫“中间件”，你以为它像后端的 middleware/filter；</p>
</li>
<li>
<p>实际它更像“边缘网关脚本”，能力受限、调试困难、行为受运行位置影响。</p>
</li>
</ul>
<p>把它叫 middleware，不是错，但它会天然误导大量开发者建立错误心智模型：以为自己在写“后端中间件”，其实是在写“边缘逻辑”。</p>
<p>这样，你以为它值得信赖，然而实际上只是展示他们平台能力的一个手段。</p>
<p>说的更直白些，你以为你写的是标准 Web 代码，实际上你写的是 <strong>Vercel 专用的 DSL</strong>。</p>
<hr>
<h2>六、工程代价清单：不是学一下就会，而是你要多维护一个世界观</h2>
<hr>
<p>到这里，骂点就不应该停留在“Vercel 坏”“Next 坏”“RSC 坏”，而是要落到工程代价——因为这才是你会长期为之付费的部分。</p>
<h3>1）运行时分裂：Node、Edge、Client、Server Component 环境并存</h3>
<p>Next.js 自己就把 runtime 拆成 Node 与 Edge 两套，并说明能力差异。<a href="https://nextjs.org/docs/app/api-reference/edge?utm_source=chatgpt.com" title="Edge Runtime - API Reference">Next.js</a>
而 RSC 又把组件分成 Server/Client 两类。<a href="https://react.dev/reference/rsc/server-components?utm_source=chatgpt.com" title="Server Components">React</a></p>
<p>你最终维护的是一个矩阵：</p>
<table>
<thead>
<tr>
<th>写法</th>
<th>运行位置</th>
<th>API 能力</th>
<th>风险点</th>
</tr>
</thead>
<tbody>
<tr>
<td>Client Component</td>
<td>浏览器</td>
<td>浏览器 API</td>
<td>XSS/依赖链</td>
</tr>
<tr>
<td>Server Component</td>
<td>服务器/构建</td>
<td>服务器资源</td>
<td>数据泄露/边界混乱</td>
</tr>
<tr>
<td>Middleware</td>
<td>Edge</td>
<td>Web API 子集</td>
<td>行为不一致/调试困难</td>
</tr>
<tr>
<td>Server Action</td>
<td>服务器</td>
<td>后端入口</td>
<td>鉴权/来源/反序列化</td>
</tr>
</tbody>
</table>
<p>作为全栈框架的使用者，它只是事实：你需要为每个格子建立正确预期，否则问题会以“看起来像 bug”的方式出现。</p>
<h3>2）安全边界内陷：从“API 网关/后端鉴权”变成“框架配置 + 协议解码链路”</h3>
<p>这次事件已经告诉我们：攻击面不止是业务 API，而是 RSC/Flight/Server Functions 的解码链路。<a href="https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components">React</a></p>
<p>Nextjs 成功证明了 <strong>全栈不难，难的是把责任边界划清楚。</strong></p>
<h3>3）平台耦合：迁移成本从“换框架”上升到“换平台能力组合”</h3>
<p>ISR、Edge、WAF、以及各种“默认最佳实践”，在 Vercel 上确实丝滑。<a href="https://vercel.com/docs/incremental-static-regeneration?utm_source=chatgpt.com" title="Incremental Static Regeneration (ISR) ">Vercel</a>
但当你想把它搬走（自建、换云、甚至只是加一层代理），你就会发现：你迁移的不只是代码，还有一堆隐含假设。</p>
<hr>
<h2>七、最后补一刀：AI 生成代码默认 Next.js 起手，是在扩大这种错位</h2>
<hr>
<p>现在很多 AI 生成前端项目的默认姿势，基本都是：Next.js + App Router + Server Actions + Shadcn/ui + 一堆平台最佳实践模板，拿过来一把梭哈。Vercel 自己也在把生成 UI/生成代码与 Next.js 工作流绑定（ v0 就是他们家的代表，额不是 VoidZero 那个 v0，是 v0.dev）。</p>
<p>问题不是 AI 生成了 Next.js，而是：</p>
<ul>
<li>
<p><strong>AI 会把“默认最流行模板”当成“默认最正确架构”</strong></p>
</li>
<li>
<p>它很少提醒你：我创建的是全栈项目，这里的 Server Actions 是后端入口、这里的 Middleware 跑在 Edge、这里的 ISR 会平台耦合、这里的安全边界需要你自己负责</p>
</li>
<li>
<p>于是新手更容易在不理解 RSC 的情况下，把项目推到全栈默认形态</p>
</li>
</ul>
<p>当理解成本被隐藏，风险成本就会延后爆炸。然后大家再回来补课：什么是 Flight、什么是 reviver、什么是来源校验、什么是边缘运行时差异——这就是工程代价，一个激进框架背后的，一个把后端当前端语法糖来写的工程代价。</p>
<hr>
<h2>总结：我为什么说这是“新时代 PHP”</h2>
<hr>
<p>我不反对 RSC，也不否认它在某些场景能带来性能与工程收益。我反对的是：</p>
<ol>
<li>
<p><strong>把后端能力伪装成组件语法糖</strong>，让不具备后端工程化能力的人去写后端入口。</p>
</li>
<li>
<p><strong>把协议解码链路推成事实网关</strong>，让安全问题从业务层上移到框架实现细节。</p>
</li>
<li>
<p><strong>用平台体验裹挟框架路线</strong>，让特性越来越像为平台服务，迁移越来越像掏空重来。</p>
</li>
</ol>
<p>之所以人们在放弃 Next.js，不是因为它写不了什么，恰恰相反他有面面俱到的能力；而是因为我不想再把一个前端工程，硬凑成一个后端工程，然后在漏洞公告出现时才意识到：原来我部署的不是页面，是一段可被触发的服务器逻辑。</p>
<p>写这篇文章，并非要号召大家明天就卸载 Next.js 回去写 JSP。 技术的螺旋上升总伴随着代价。React 团队探索 RSC 的勇气值得敬佩，Vercel 想要统一 Web 开发流的野心也令人惊叹。但在铺天盖地的营销叙事中，我们作为一线工程师，必须保持 <strong>冷眼旁观</strong> 的能力。</p>
<p>我们不能因为 Vercel 演示里的 Demo 很炫酷，就忽略了背后那个正在泄漏数据库连接的 Lambda；也不能因为 <code>use server</code> 写起来很爽，就忘记了此时此刻你正在裸奔。 <strong>理性地评判，是为了在它把我们的工程搞崩之前，先搞清楚底线在哪里。</strong></p>]]></description>
      <author>grtsinry43</author>
      <enclosure url="https://blog.grtsinry43.com/uploads/2025/12/13/Gemini_Generated_Image_p443jzp443jzp443.png_ba4d4b8b-ed92-4956-828a-0f23927f4223.png" length="0" type="image/png"></enclosure>
      <guid>article-31</guid>
      <pubDate>Sat, 13 Dec 2025 16:09:44 +0000</pubDate>
    </item>
    <item>
      <title>从想法到实践：在无序的生活里，试图用代码敲出一点秩序</title>
      <link>https://blog.grtsinry43.com/posts/from-think-to-code-in-2025</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/posts/from-think-to-code-in-2025">https://blog.grtsinry43.com/posts/from-think-to-code-in-2025</a></p></blockquote><blockquote>
<p>其实标题应该是 于痛苦中和解，或者说，只是为了让自己别停下来罢了</p>
</blockquote>
<p>回头看了一眼，距离无法正常行动到现在，已经过去三个月了，直到月底，我也不知道我还能不能正常行走。</p>
<p>:::link-card{href=&quot;/moments/2025/09/16/some-dark-days&quot; title=&quot;命运开了个无情的玩笑&quot; desc=&quot;人生如戏，跌宕起伏间尽是沧桑；命运弄人，笑泪交织中暗藏微光。在伤痛中觉醒，于绝望里寻觅重生。&quot; newtab=&quot;true&quot;}</p>
<p>:::</p>
<p>那段时间的生活，怎么说呢，确实是烂透了。直到现在，我也依然无法摆脱每日的痛苦，夜里依然常常失眠，从那天开始就噩梦不断</p>
<p>但人嘛，总不能真就在泥坑里躺平了。</p>
<p>这三个月，虽然腿脚不便，但我强迫自己的脑子动起来，毕竟生活，时间都在继续，学期结束越来越近，秋招也越来越近了。既然生活里全是不可控的 Exception，那我至少要在 IDE 里找回一点能跑通的逻辑。这三个月折腾了一堆东西，但是始终没有精力，也没有心情打磨一个好的产品，之前好多计划的，还有合作的项目，都被我无奈 delay 了。</p>
<p>这篇文章可能很需要 AI 总结来导读，又长又流水帐，可能我现在没有力气来慢慢打磨了。</p>
<h3>PureFlow：先试试手还在不在</h3>
<p>最开始是 <strong>PureFlow</strong>（这个之前水过文章了，就不细说了）。</p>
<p>其实当时写这个没别的想法，就是学了 kmp 始终没写过啥成型的东西嘛，正好出不了门，在学校全天狠狠写了一周多，然后终于证明跨平台没那么简单，之后慢慢学，然后填坑吧。</p>
<p><a href="https://github.com/grtsinry43/PureFlow">PureFlow</a></p>
<h3>Tangyuan 社区：帮别人点缀，顺便治愈自己</h3>
<p>那时候自己其实挺迷茫的，也没什么好的 idea。不过恰逢我负责的学生部门招新，遇到了@XianlitiCN 同学。他有一群志同道合的伙伴，有共同的爱好，还是文科相关专业。</p>
<p><a href="https://qingshuige.ink/">清水阁</a></p>
<p><a href="https://qingshuige.ink/archives/1794">线粒体同学的帖子，也是我为是什么想帮助他</a></p>
<p>我想到我小时候很喜欢文学，当时还去过什么汉字听写大会的市级海选，还很喜欢诗词大会。...然后发现风花雪月并给不了我生活的底气，所以还是选择作为爱好了。并且我一直以来还有一个想要维护一个社区的想法...<del>（你怎么恰好这么多想法）</del> 线粒体同学一直想做 <strong>Tangyuan 社区</strong>，而旧版用命令式写的 UI 有点过时并且不好维护，我想着，行吧，既然我自己也是一团乱麻，不如帮别人把想法落地。</p>
<p>其实核心就是用 Compose 构建 UI，通过 ViewModel 组织数据并传递到 UI 线程</p>
<ul>
<li><strong>UI 层：声明式构建 (Jetpack Compose)</strong>
摒弃了传统的 View/XML 体系，全线采用 Compose 构建界面。作为声明式 UI 框架，Compose 允许通过 Kotlin 代码直接描述界面状态。这种“Code as UI”的方式，极大地减少了 findViewById 和手动操作 View 状态的代码量，让视图层的代码更加直观、紧凑。</li>
<li><strong>逻辑层：ViewModel 托管数据与状态</strong>
为了实现 UI 与 逻辑的解耦，这里引入了 <strong>ViewModel</strong>。所有的业务逻辑、网络请求（Retrofit）、数据清洗都严格限制在 ViewModel 内部进行。ViewModel 的生命周期感知特性，确保了数据在配置更改时不会丢失。</li>
<li><strong>数据流：从 ViewModel 到 UI 线程的单向传递</strong>
这是整个架构中最关键的一环。
<ol>
<li><strong>数据获取</strong>：ViewModel 利用 viewModelScope 启动协程，在 IO 线程进行耗时的网络或数据库操作。</li>
<li><strong>状态暴露</strong>：将处理后的结果封装在 StateFlow 或 LiveData 中，作为一个可观察的单一数据源（SSOT）。</li>
<li><strong>UI 渲染</strong>：在 Compose 界面中，通过 collectAsState() 监听数据流。一旦数据发生变化，Compose 会自动在 <strong>主线程（UI Thread）</strong> 触发重组（Recomposition），刷新界面。</li>
</ol>
</li>
</ul>
<p>具体的项目结构是这样的：</p>
<pre><code class="language-bash">❯ tree -I build .
.
├── build.gradle.kts
├── proguard-rules.pro
├── release // 编译产物
└── src
    ├── androidTest // 测试
    ├── main
    │   ├── AndroidManifest.xml // Manifest
    │   ├── java
    │   │   └── com
    │   │       └── qingshuige
    │   │           └── tangyuan
    │   │               ├── analytics // 分析上报
    │   │               ├── api
    │   │               │   └── ApiInterface.kt
    │   │               ├── App.kt
    │   │               ├── di // 依赖注入
    │   │               │   ├── NetworkModule.kt
    │   │               │   └── RepositoryModule.kt
    │   │               ├── MainActivity.kt
    │   │               ├── model // 数据模型
    │   │               │   ├── Category.kt
    │   │               │   ├── CommentCard.kt
    │   │               │   └── ...
    │   │               ├── navigation // 导航栈
    │   │               │   └── Screen.kt
    │   │               ├── network // 网络相关
    │   │               │   ├── JwtAuthenticator.kt
    │   │               │   ├── JwtInterceptor.kt
    │   │               │   ├── NetworkClient.kt
    │   │               │   └── TokenManager.kt
    │   │               ├── repository // 数据操作封装
    │   │               │   ├── CategoryRepository.kt
    │   │               │   ├── CommentRepository.kt
    │   │               │   └── ...
    │   │               ├── TangyuanApplication.kt
    │   │               ├── ui
    │   │               │   ├── animation // 动画配置
    │   │               │   │   ├── AnimationConfig.kt
    │   │               │   │   ├── ImagePreloader.kt
    │   │               │   │   └── SmartSharedElementManager.kt
    │   │               │   ├── components // 复用组件
    │   │               │   │   ├── AuroraBackground.kt
    │   │               │   │   ├── BottomBar.kt
    │   │               │   │   └── ...
    │   │               │   ├── screens // ui屏幕
    │   │               │   │   ├── AboutScreen.kt
    │   │               │   │   ├── CategoryScreen.kt
    │   │               │   │   └── ...
    │   │               │   └── theme // 主题和设计系统
    │   │               │       ├── Color.kt
    │   │               │       ├── Theme.kt
    │   │               │       └── Type.kt
    │   │               ├── utils // 工具类
    │   │               │   ├── DeviceIdentifier.kt
    │   │               │   ├── FlowExtensions.kt
    │   │               │   └── ...
    │   │               └── viewmodel // 视图数据绑定
    │   │                   ├── CategoryViewModel.kt
    │   │                   ├── CommentViewModel.kt
    │   │                   └── ...
    │   └── res // 资源等等

</code></pre>
<p>看着大家在里面发帖交流，那种“被需要”的感觉，在当时真的是一剂良药。虽然现在回看代码可能还是堆了不少 <!-- raw HTML omitted --> 新鲜热乎的屎山 <!-- raw HTML omitted -->，但至少它跑起来了，还挺像模像样的。</p>
<h3>AI 原型生成器：稍微膨胀了一下的野心，折腾的开始</h3>
<p>到这个时候就是国庆假期了，身体稍微恢复一点，想搞什么东西的想法又上来了。这个 <code>ai-proto-generator</code> 还是有点东西可以讲的。这里我选择了 nextjs ktor 来构建这个项目，我们生态内有一个好用的工具：<a href="https://start.ktor.io/p/koog">Koog</a></p>
<p>我们可以去看一下市面上的这种 ai 生成工具，原理就是通过对话，toolcall，在右侧打开一个 iframe，将远程开发服务器的网页传回来，然后通过命令不断更改即可看到效果。</p>
<p>首先是，为了构建一个项目，agent 需要在一个开发目录运行开发服务器，比如 <code>nextjs</code> <code>vite</code> 等，然后输出代码，我们可以为它提供比如 <code>websearch </code> <code>shell</code> <code>read</code> <code>write</code> 工具，为了我们环境的绝对隔离，这里最好的方法是使用容器技术，这里我为了方便使用了抽象程度最高的 docker（其实可以用低一层的 containerd，更轻量一些）。</p>
<p>为了方便管理，我们可以使用 go 写一个单独管理容器的工具（用 go 是因为 docker 所在的生态还是 go 最方便，我 kt 搞了半天都是很麻烦），导入包直接开干</p>
<pre><code class="language-go">package handler

import (
	// ...其他依赖

	&quot;github.com/docker/docker/api/types/container&quot;
	&quot;github.com/docker/docker/api/types/network&quot;
	&quot;github.com/docker/docker/client&quot;
)

// SandboxHandler 结构体，持有 Docker 客户端
type SandboxHandler struct {
	DockerClient *client.Client
}
type CodeInjectionPayload struct {
	Filename string `json:&quot;filename&quot; binding:&quot;required&quot;`
	Content  string `json:&quot;content&quot; binding:&quot;required&quot;`
}

// 构造函数，方便 gin 那边用
func NewSandboxHandler(dockerClient *client.Client) *SandboxHandler {
	return &amp;SandboxHandler{DockerClient: dockerClient}
}

func (h *SandboxHandler) CreateSandbox(c *gin.Context) {
	// 1. 绑定 JSON 数据
	var payload CodeInjectionPayload
	if err := c.ShouldBindJSON(&amp;payload); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;Invalid request payload: &quot; + err.Error()})
		return
	}

	ctx := context.Background()

	containerName := &quot;sandbox-&quot; + generateRandomId() // 生成短随机容器名

	config := &amp;container.Config{
		Image: &quot;sandbox-template:latest&quot;,
		Cmd:   []string{&quot;tail&quot;, &quot;-f&quot;, &quot;/dev/null&quot;},
		User:  &quot;appuser&quot;,
		Labels: map[string]string{
			&quot;traefik.enable&quot;: &quot;true&quot;,
			&quot;traefik.http.routers.&quot; + containerName + &quot;.rule&quot;:                      &quot;Host(`&quot; + containerName + &quot;.sandbox.localhost`)&quot;,
			&quot;traefik.http.services.&quot; + containerName + &quot;.loadbalancer.server.port&quot;: &quot;3000&quot;,
		},
	}

	hostConfig := &amp;container.HostConfig{
		AutoRemove: true,
		Resources: container.Resources{
			Memory:    512 * 1024 * 1024,
			CPUShares: 512,
		},
	}

	// 指定网络接入
	networkingConfig := &amp;network.NetworkingConfig{
		EndpointsConfig: map[string]*network.EndpointSettings{
			&quot;sandbox-manager_sandbox-net&quot;: {},
		},
	}

	// 2. 创建并启动容器
	resp, err := h.DockerClient.ContainerCreate(ctx, config, hostConfig, networkingConfig, nil, containerName)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;创建容器失败: &quot; + err.Error()})
		return
	}

	if err := h.DockerClient.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;启动容器失败: &quot; + err.Error()})
		return
	}

	// 3. 创建 Tar 存档（为了方便一次性放入初始文件）
	tarReader, err := createTarArchive(payload.Filename, payload.Content)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;Failed to create tar archive: &quot; + err.Error()})
		return
	}

	// 4. 复制文件到容器的工作目录
	if err := h.DockerClient.CopyToContainer(ctx, resp.ID, &quot;/home/appuser/project&quot;, tarReader, container.CopyToContainerOptions{}); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;Failed to copy code to container: &quot; + err.Error()})
		return
	}

	// 5. 在容器中执行 pnpm run dev
	execConfig := container.ExecOptions{
		Cmd:          []string{&quot;pnpm&quot;, &quot;run&quot;, &quot;dev&quot;},
		WorkingDir:   &quot;/home/appuser/project&quot;,
		AttachStdout: true,
		AttachStderr: true,
	}

	execResp, err := h.DockerClient.ContainerExecCreate(ctx, resp.ID, execConfig)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;Failed to create exec: &quot; + err.Error()})
		return
	}

	if err := h.DockerClient.ContainerExecStart(ctx, execResp.ID, container.ExecStartOptions{Detach: true}); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;Failed to start exec: &quot; + err.Error()})
		return
	}

	log.Printf(&quot;成功创建、启动容器并注入代码，已执行 pnpm run dev %s&quot;, resp.ID[:12])
	sandboxURL := &quot;http://&quot; + containerName + &quot;.sandbox.localhost&quot;
	c.JSON(http.StatusOK, gin.H{
		&quot;message&quot;:     &quot;Sandbox created and code injected successfully&quot;,
		&quot;containerId&quot;: resp.ID,
		&quot;url&quot;:         sandboxURL,
	})
}

func (h *SandboxHandler) ListSandboxes(c *gin.Context) {
	containers, err := h.DockerClient.ContainerList(context.Background(), container.ListOptions{})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;列出容器失败: &quot; + err.Error()})
		return
	}

	type simpleContainer struct {
		ID     string
		Image  string
		Status string
	}
	var result []simpleContainer

	for _, c := range containers {
		result = append(result, simpleContainer{
			ID:     c.ID[:12],
			Image:  c.Image,
			Status: c.Status,
		})
	}

	if result == nil {
		result = []simpleContainer{}
	}
	c.JSON(http.StatusOK, result)
}

func (h *SandboxHandler) DeleteSandbox(c *gin.Context) {
	// 从 URL 路径中获取容器 ID
	containerID := c.Param(&quot;id&quot;)
	ctx := context.Background()

	log.Printf(&quot;Attempting to stop and remove container %s&quot;, containerID)

	// 1. 停止容器
	// 第三个参数可以设置超时时间，nil 表示使用默认超时
	if err := h.DockerClient.ContainerStop(ctx, containerID, container.StopOptions{}); err != nil {
		// 如果容器已经不存在，Docker 会报错，我们需要优雅地处理
		if strings.Contains(err.Error(), &quot;No such container&quot;) {
			c.JSON(http.StatusNotFound, gin.H{&quot;error&quot;: &quot;Container not found&quot;})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;Failed to stop container: &quot; + err.Error()})
		return
	}

	// 2. 移除容器
	// 因为我们在创建时使用了 --rm (AutoRemove: true)，
	// 所以容器在停止后会自动被删除。这一步严格来说不是必须的，
	// 但是为了兜底，这里可以放一下
	err := h.DockerClient.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true})
	if err != nil {
		if strings.Contains(err.Error(), &quot;No such container&quot;) {
			c.JSON(http.StatusNotFound, gin.H{&quot;error&quot;: &quot;Container not found&quot;})
			return
		}
	}

	log.Printf(&quot;Successfully stopped container %s&quot;, containerID)
	c.JSON(http.StatusOK, gin.H{&quot;message&quot;: &quot;Sandbox deleted successfully&quot;})
}

func createTarArchive(filename, content string) (io.Reader, error) {
	buf := new(bytes.Buffer)
	tw := tar.NewWriter(buf)

	hdr := &amp;tar.Header{
		Name: filename,
		Mode: 0644,
		Size: int64(len(content)),
	}
	if err := tw.WriteHeader(hdr); err != nil {
		return nil, err
	}

	if _, err := tw.Write([]byte(content)); err != nil {
		return nil, err
	}

	if err := tw.Close(); err != nil {
		return nil, err
	}

	return buf, nil
}

// 一个简单的随机 ID 生成函数
func generateRandomId() string {
	const letters = &quot;abcdefghijklmnopqrstuvwxyz0123456789&quot;
	b := make([]byte, 8)
	r := rand.New(rand.NewSource(time.Now().UnixNano()))
	for i := range b {
		b[i] = letters[r.Intn(len(letters))]
	}
	return string(b)
}

</code></pre>
<p>太长不看版：就是我们实现了一个快速创建容器的抽象</p>
<pre><code class="language-go">package handler

func (h *SandboxHandler) CreateSandbox(c *gin.Context) {
	
}

func (h *SandboxHandler) ListSandboxes(c *gin.Context) {
	
}

func (h *SandboxHandler) DeleteSandbox(c *gin.Context) {
	
}

</code></pre>
<p>而后我们可以让主服务去调用这个啦，就像这样：</p>
<pre><code class="language-bash">┌─────────────────┐    HTTP     ┌─────────────────┐    gRPC     ┌─────────────────┐
│                 │   Request   │                 │   Call      │                 │
│   Frontend      │ ──────────→ │  Ktor Backend   │ ──────────→ │  Go Container   │
│   (Next.js)     │             │   (Business)    │             │   Manager       │
│                 │             │                 │             │                 │
└─────────────────┘             └─────────────────┘             └─────────────────┘
                                         │                               │
                                         │                               │
                                         ▼                               ▼
                                 ┌─────────────────┐             ┌─────────────────┐
                                 │  SandboxService │             │ Docker Container│
                                 │  (Code Gen AI)  │             │   (Isolated)    │
                                 └─────────────────┘             └─────────────────┘
</code></pre>
<p>为了管理 docker 的流量转到前端，我们引入 traefik，这里就不多赘述了。待到写好基本的后端结构，然后我们便可以使用 koog 来顺畅调用 llm 的 api 了。</p>
<p>市面上的对话使用 sse 来实现流式输出，然后后端维护对话上下文，并且通过提取回应的字符串来实现 toolcall（也就是 MCP），借用 koog，我们可以方便的流式调用</p>
<pre><code class="language-kotlin">                            // 执行LLM流式调用
                            val llmResponse = StringBuilder()
                            val flow = llm().executeStreaming(chatPrompt, GoogleModels.Gemini2_0FlashLite)

                            flow.collect { chunk -&gt;
                                llmResponse.append(chunk)
                                writeSseData(&quot;token&quot;, mapOf(&quot;token&quot; to chunk))
                            }

                            val fullResponse = llmResponse.toString().trim()
                            logger.info(&quot;LLM response: $fullResponse&quot;)

                            // 检查是否包含函数调用
                            val functionCalls = extractFunctionCalls(fullResponse)
</code></pre>
<p>在之前，我们写代码是为了人，基建调用，而这里我们写的都是为 llm 服务：我们可以维护一个工具集合，方便注册，根据项目切换，还有管理</p>
<pre><code class="language-kotlin">package com.grtsinry43.ai

import kotlinx.serialization.json.JsonObject
import org.slf4j.LoggerFactory
import java.util.concurrent.ConcurrentHashMap

/**
 * 默认的工具注册表实现
 */
class DefaultToolRegistry : ToolRegistry {
    private val logger = LoggerFactory.getLogger(DefaultToolRegistry::class.java)
    private val tools = ConcurrentHashMap&lt;String, FunctionTool&gt;()

    override fun register(tool: FunctionTool) {
        tools[tool.name] = tool
        logger.info(&quot;Registered tool: ${tool.name}&quot;)
    }

    override fun unregister(name: String) {
        tools.remove(name)
        logger.info(&quot;Unregistered tool: $name&quot;)
    }

    override fun getTool(name: String): FunctionTool? {
        return tools[name]
    }

    override fun getAllTools(): List&lt;FunctionTool&gt; {
        return tools.values.toList()
    }

    override fun getToolDefinitions(): List&lt;JsonObject&gt; {
        return tools.values.map { tool -&gt;
            JsonObject(mapOf(
                &quot;type&quot; to kotlinx.serialization.json.JsonPrimitive(&quot;function&quot;),
                &quot;function&quot; to JsonObject(mapOf(
                    &quot;name&quot; to kotlinx.serialization.json.JsonPrimitive(tool.name),
                    &quot;description&quot; to kotlinx.serialization.json.JsonPrimitive(tool.description),
                    &quot;parameters&quot; to tool.parameters
                ))
            ))
        }
    }
}
</code></pre>
<p>随后处理对话中的工具调用相关，来拿到我们想要的工具调用</p>
<pre><code class="language-kotlin">    // 从响应中提取函数调用
    fun extractFunctionCalls(response: String): List&lt;FunctionCall&gt; {
        val json = Json { ignoreUnknownKeys = true }
        return try {
            val jsonResponse = json.parseToJsonElement(response).jsonObject
            val functionCallsArray = jsonResponse[&quot;function_calls&quot;]?.jsonArray ?: return emptyList()
            
            functionCallsArray.mapNotNull { element -&gt;
                try {
                    val callObj = element.jsonObject
                    val name = callObj[&quot;name&quot;]?.jsonPrimitive?.content ?: return@mapNotNull null
                    val arguments = callObj[&quot;arguments&quot;]?.jsonObject ?: JsonObject(emptyMap())
                    FunctionCall(name, arguments)
                } catch (e: Exception) {
                    logger.warn(&quot;Failed to parse function call: $element&quot;, e)
                    null
                }
            }
        } catch (e: Exception) {
            // 尝试从文本中提取JSON块
            val jsonPattern = Regex(&quot;&quot;&quot;``` json\s *(\{.*?\})\s*```&quot;&quot;&quot;, RegexOption.DOT_MATCHES_ALL)
            val matches = jsonPattern.findAll(response)
            
            matches.mapNotNull { match -&gt;
                try {
                    val jsonText = match.groupValues [1]
                    extractFunctionCalls(jsonText)
                } catch (e: Exception) {
                    emptyList()
                }
            }.flatten().toList()
        }
    }
</code></pre>
<p>有了工具调用，接下来就是编写大量的工具集，然后<strong>不要一次性塞给AI</strong>，因为选择工具经常出现问题，我们需要的是根据项目类型自动推荐，然后分好类，比如我们这里有的 <code>websearch </code> <code>shell</code> <code>read</code> <code>write</code> 等等。</p>
<p>可惜理想是美好的，现实是残酷的，想实现这些效果，需要付出高昂的 tokens 成本，在 claude 小号被封之后，我的项目就搁置了，如果你恰巧财力雄厚，等我完善完我就开源出去可以调 api 慢慢玩。</p>
<h3>Github Overview &amp; UI 的“滑铁卢”</h3>
<p>进入10月中旬，生活开始多线运行，虽然身体不行，但是空闲时间反而越来越少了，我开始转向轻量项目。在钱包受伤之后，紧接着，只能玩一玩比如api这种现成的，于是方向转向了 <strong>Github 仓库分析工具 (Overview)</strong>。</p>
<p>这里我首次用fastify写大项目，也是首次尝试cc接管一切，配置好提示词，eslint规则，并且设置commit钩子，这种强制执行的限制对于llm还是挺有用的。</p>
<p>后端逻辑写得飞起，数据抓取也没问题。结果到了前端展示环节，<strong>UI 设计彻底把我整不会了</strong>。</p>
<p>我是真的尽力了，但画出来的界面怎么看怎么丑，那种“脑子里有画面但手残画不出来”的挫败感，真的让人想砸键盘。这就好比当时的我，里子虽然还在，但面子上已经挂不住了。最后这个项目只能含泪鸽置，<!-- raw HTML omitted --> 实在太丑了没眼看 <!-- raw HTML omitted -->。</p>
<p>不过最近Gemini 3 Pro 让我燃起了希望啊，这个可能近期我会写完。</p>
<p>总结了一个文档，希望能帮到你，如果你也在写相关的：</p>
<p><a href="https://github.com/grtsinry43/proj-dash-backend/blob/main/GITHUB_API_FEASIBILITY.md">https://github.com/grtsinry43/proj-dash-backend/blob/main/GITHUB_API_FEASIBILITY.md</a></p>
<h3>ELK 日志系统：既然脸不要了，那就搞内脏</h3>
<p>在 UI 上碰得满头包之后，我产生了逆反心理：<strong>行，既然我画不好皮，那我就去搞最底层、最枯燥的后端基建。</strong></p>
<p>于是我开始折腾 <strong>ELK (Elasticsearch, Logstash, Kibana)</strong> 日志系统。</p>
<p>这是一个相当“重”的项目，<del>主要是java太吃内存了</del>。两天时间配完，搓完bff，看着成千上万条杂乱无章的日志被 Logstash 吞进去，然后整整齐齐地吐出来，有点治愈的哈哈哈。</p>
<p><img src="https://blog.grtsinry43.com/uploads/2025/11/23/f754f23e304d1c534d41e5a675560c8d_720.jpg_ba72dd3b-0296-4e41-a26c-a9d957d99426.jpg" alt=""></p>
<p><img src="https://blog.grtsinry43.com/uploads/2025/11/23/image.png_f379043f-b86e-458a-9a98-7b59ee8da7c4.png" alt=""></p>
<p>折腾也很简单，一个compose加上自己设计bff收集就行了，前后端都可以的。</p>
<pre><code class="language-yml">services:
  elasticsearch:
    image: elasticsearch: 8.11.0
    container_name: elasticsearch
    environment:
      - discovery.type = single-node
      - xpack.security.enabled = false # 开发时关闭安全验证，简化操作
      - &quot;ES_JAVA_OPTS =-Xms1g -Xmx1g&quot; # 建议分配 1G 内存
    ports:
      - &quot;9200:9200&quot;
    volumes:
      - es_data:/usr/share/elasticsearch/data

  logstash:
    image: logstash: 8.11.0
    container_name: logstash
    # 将我们本地的配置文件挂载到容器里
    volumes:
      - ./logstash/pipeline:/usr/share/logstash/pipeline/
    ports:
      - &quot;5044:5044&quot; # 这是我们稍后要从 Spring Boot 发送日志的端口！！
    depends_on:
      - elasticsearch

  kibana:
    image: kibana: 8.11.0
    container_name: kibana
    ports:
      - &quot;5601:5601&quot;
    environment:
      # 告诉 Kibana 去哪里找 Elasticsearch
      - ELASTICSEARCH_HOSTS = http://elasticsearch: 9200
      - XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY = 4d1b36625bb7a2e0f8fc41c7bb9a1dbf
    depends_on:
      - elasticsearch

  zookeeper:
    image: confluentinc/cp-zookeeper: 7.5.3
    container_name: zookeeper
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181
      ZOOKEEPER_TICK_TIME: 2000

  kafka:
    image: confluentinc/cp-kafka: 7.5.3
    container_name: kafka
    ports:
      - &quot;9092:9092&quot;
    depends_on:
      - zookeeper
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: 'zookeeper: 2181'

      # 1. 定义两个监听器：
      #    - PLAINTEXT: 用于容器间通信，监听在 29092 端口
      #    - PLAINTEXT_HOST: 用于外部通信 (比如你的 Spring Boot 应用)，监听在 9092 端口
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:29092, PLAINTEXT_HOST://0.0.0.0:9092

      # 2. 定义这两个监听器分别对外广播什么地址：
      #    - 如果从 PLAINTEXT 进来，就告诉对方我的地址是 kafka: 29092
      #    - 如果从 PLAINTEXT_HOST 进来，就告诉对方我的地址是 localhost: 9092
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka: 29092, PLAINTEXT_HOST://localhost: 9092

      # 3. 将监听器名称映射到安全协议
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT: PLAINTEXT, PLAINTEXT_HOST: PLAINTEXT

      # 4. 指定 Broker 之间通信使用哪个监听器
      KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT

      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1

volumes:
  es_data: # 创建一个 Docker volume 来持久化 ES 数据
    driver: local
</code></pre>
<p><img src="https://blog.grtsinry43.com/uploads/2025/11/23/mermaid-diagram-2025-11-23-011441.png_72c506a3-ad56-4571-bb7b-aaff3f3e7cdc.png" alt=""></p>
<h3>Vespera LightMonitor：回归极简，来点 Rust 哲学</h3>
<p>折腾完沉重的 ELK，再看看我手里那几台配置感人的小鸡（VPS），我又感觉自己有点好笑，这ELK根本没地方部署。</p>
<p>于是看着市面上眼花缭乱的服务器探针， <strong>Vespera LightMonitor</strong> 诞生了。</p>
<p>这个用了Axum sqlx 已经上线了，bug慢慢修，等我用了一段时间稳定就开源然后写文档。</p>
<p><a href="https://status.grtsinry43.com/">Verpera | grtsinry43's Server Monitor</a></p>
<p><img src="https://blog.grtsinry43.com/uploads/2025/11/23/image.png_6e4b8c9d-c719-4027-8766-1eb3d30a1c74.png" alt=""></p>
<h3>Design System：试图建立秩序</h3>
<p>经历了这几个月的胡搞瞎搞：从社区到 AI，从 UI 碰壁到沉迷日志后端，再到极简监控...</p>
<p>我也发现了，我做的东西太碎了。就像那个死掉的 Github Overview 一样，我每次都在重复造轮子，还在纠结圆角是 4px 还是 8px 这种无聊的问题。</p>
<p>所以，最近我在研究和创造一套属于自己的 <strong>设计系统 (Design System)</strong>。</p>
<h3>碎碎念</h3>
<p>三个月里，我的每个项目，都是挤时间，在难受的时候，在无聊的时候，在实在感觉不想继续下去的时候，就连这篇文章也一样，流水帐的就像我的生活一样，其实这里的每个项目都可以展开为一篇文章，都有很多可以讲的，但是我还是等有余力将它们打磨好一个好的产品再汇报给每一个人吧，写下这些文字也算是一种解脱，至少证明我的生活还在继续，在这个重要的节点依然在输出，当然也有更多的输入。</p>
<blockquote>
<p>回头看看这三个月，痛苦消失了吗？
害，其实也没有。深夜破防的时候该 emo 还是会 emo。</p>
</blockquote>
<p>但好在，我没停下来。
从想法到实践，这中间的距离，大概就是我与自己和解的过程吧。</p>
<p>代码还得写，生活还得过，只要键盘还在响，就不算太糟糕。希望重新健康的日子，能早一点来吧。</p>]]></description>
      <author>grtsinry43</author>
      <enclosure url="https://blog.grtsinry43.com/uploads/2025/11/23/status.grtsinry43.com_.png_23b53304-23ee-4038-8050-781f196a5eb5.png" length="0" type="image/png"></enclosure>
      <guid>article-30</guid>
      <pubDate>Sat, 22 Nov 2025 16:54:07 +0000</pubDate>
    </item>
    <item>
      <title>PureFlow 简析：用 Kotlin 跨平台构建 RSS 阅读器</title>
      <link>https://blog.grtsinry43.com/posts/kmp-pureflow</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/posts/kmp-pureflow">https://blog.grtsinry43.com/posts/kmp-pureflow</a></p></blockquote><p>之前研究那么久 KMP...但是直到现在也没写什么 demo 项目，这次因为身体原因刚好出不去，唉，正好有时间探索这个了。</p>
<blockquote>
<p>[!NOTE]</p>
<p>目前只是个 demo ，之后我有时间有精力会慢慢添加功能</p>
</blockquote>
<p>项目地址：<a href="https://github.com/grtsinry43/PureFlow">https://github.com/grtsinry43/PureFlow</a></p>
<p>整个项目其实就写了四天，不过因为几乎 90% 的时间都在写代码，并且现在有 claude code 能干一点脏活累活，所以说自己写好重要部分就行。</p>
<p><img src="https://blog.grtsinry43.com/uploads/2025/09/22/image-20250922180629650.png_7e149187-25e4-4656-9f28-4396195307f8.png" alt=""></p>
<p>它基于 Kotlin Multiplatform 和 Compose Multiplatform 技术栈，一套代码，同时支持 Android、iOS、macOS、Windows、Linux 甚至 Web 平台。</p>
<p><del>原本我计划用 ovCompose 跨到 HarmonyOS 的，但是没有 UI 库，先这样吧。</del></p>
<p><img src="https://blog.grtsinry43.com/uploads/2025/09/22/2025-09-22_20.28.40.jpg_8c46d838-cf62-44d8-b0b5-6e617974228f.jpg" alt=""></p>
<h2>技术栈总览</h2>
<p>Kotlin Multiplatform 和 Compose Multiplatform 无疑是项目首选，实现了几乎全栈 Kotlin，从 UI 到业务逻辑，甚至服务端和数据库交互，带来的是开发者开发效率提升和心智统一。</p>
<ul>
<li><strong>核心框架</strong>:
<ul>
<li><code>Kotlin Multiplatform</code>: 跨平台的核心引擎。</li>
<li><code>Compose Multiplatform</code>: 声明式 UI 的未来，一套代码搞定多端 UI。</li>
</ul>
</li>
<li><strong>数据层</strong>:
<ul>
<li><code>SQLDelight</code>: 类型安全的 SQL 数据库。</li>
<li><code>Multiplatform Settings</code>: 跨平台配置存储。</li>
</ul>
</li>
<li><strong>网络层</strong>:
<ul>
<li><code>Ktor Client</code>: JetBrains 自家的 HTTP 客户端，跨平台支持很到位。</li>
<li><code>RSS Parser</code>: 使用 <code>com.prof18.rssparser</code> 开源解析库。</li>
<li><code>Coil</code>: 强大的图片加载和缓存库，多平台版本用起来也很香。</li>
</ul>
</li>
<li><strong>状态管理与异步</strong>:
<ul>
<li><code>StateFlow</code> &amp; <code>SharedFlow</code>: 响应式状态管理的核心，数据流转非常清晰。</li>
<li><code>ViewModel</code>: MVVM 架构模式中的核心组件。</li>
<li><code>Kotlin Coroutines</code>: 异步编程的利器，让复杂任务变得简单。</li>
</ul>
</li>
</ul>
<p>这几乎是一个纯正的 Kotlin 全家桶方案，也是我认为其值得一试的原因所在。</p>
<h2>核心架构设计</h2>
<p>PureFlow 设计成一个典型的模块化架构，就像搭积木一样，每块积木都有明确的功能和边界。</p>
<pre><code class="language-bash">PureFlow/
├── composeApp/          # UI 层 - Compose Multiplatform 应用
├── shared/              # 共享业务逻辑层 (一次编写，逻辑共享)
├── server/              # 服务端 API (Ktor - 目前还未集成)
├── webApp/              # Web 前端 (React/TypeScript - 待完善或探索)
└── iosApp/              # iOS 特异性代码 (主要用于启动和平台特有集成)
</code></pre>
<h3>1. <code>shared</code> 模块</h3>
<p>包含了所有平台共享的业务逻辑、数据模型、数据库接口、网络请求封装和 RSS 解析器。它的 <code>build.gradle.kts</code> 配置编译到了各个平台目标：</p>
<pre><code class="language-kotlin">// shared/build.gradle.kts
kotlin {
    androidTarget {
        compilations.all { kotlinOptions { jvmTarget = &quot;11&quot; } }
    }
    iosArm64() // iOS ARM64 设备
    iosSimulatorArm64() // iOS 模拟器
    jvm() // Desktop (Windows, macOS, Linux)
    js {
        moduleName = &quot;shared&quot;
        browser() // 浏览器环境
        binaries.library() // 打包成 JS 库
        generateTypeScriptDefinitions() // Web 集成，自动生成 TS 定义
        compilerOptions { target = &quot;es2015&quot; } // 兼容性考虑
    }
}
</code></pre>
<p>通过这个配置，共享代码能编译成 Android 的 <code>.jar</code>、iOS 的 <code>.framework</code>、桌面端的 <code>.jar</code> 和 Web 用的 <code>.js</code> 库，真正实现了一套代码，多端运行。其中 <code>generateTypeScriptDefinitions()</code> 为 <code>webApp</code> 模块的 TypeScript 集成铺平了道路，TypeScript 可以直接使用 Kotlin 共享模块定义的接口和数据结构，减少了重复定义。</p>
<h3>2. <code>composeApp</code> 模块：统一的 UI 界面</h3>
<p>这里是 Compose Multiplatform 的战场...终于 iOS stable，可以放心去写。它负责所有平台的 UI 渲染。得益于 <code>shared</code> 模块提供的稳定业务逻辑，<code>composeApp</code> 可以专注于 UI 交互和视觉呈现。</p>
<pre><code class="language-kotlin">// composeApp/build.gradle.kts 关键配置
kotlin {
    androidTarget { /* ... */ }
    listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget -&gt;
        iosTarget.binaries.framework {
            baseName = &quot;ComposeApp&quot;
            isStatic = true // 静态库便于集成
        }
    }
    jvm() // Desktop 应用
}
</code></pre>
<p><code>isStatic = true</code> 让 <code>ComposeApp</code> 模块在 iOS 上打包成静态框架，便于在 <code>iosApp</code> 中集成。</p>
<h3>3. <code>server</code> 模块：轻量级后端支持（Planned）</h3>
<p>虽然 PureFlow 主要是一个客户端应用，但我还是打算用 Ktor 实现一个轻量级后端。它的主要任务是提供 RSS 同步和用户数据管理功能，为将来的云同步、多设备数据一致性打下基础。</p>
<h2>数据层：SQLDelight</h2>
<p>数据存储是任何应用的核心，这里选择 SQLDelight 来处理本地数据库。它提供类型安全的 Kotlin API，避免了运行时错误，开发体验直线提升。</p>
<h3>数据库设计</h3>
<p>项目使用 SQLDelight 配置了数据库：</p>
<pre><code class="language-kotlin">// shared/build.gradle.kts
sqldelight {
    databases {
        create(&quot;PureFlowDatabase&quot;) {
            packageName.set(&quot;com.grtsinry43.pureflow.database&quot;)
        }
    }
}
</code></pre>
<h4>实际的数据库表结构</h4>
<p>从项目的 <code>RssDatabase.sq</code> 文件可以看到表设计：</p>
<ol>
<li>
<p><strong>RSS 条目表 (<code>rssItem</code>)</strong></p>
<pre><code class="language-sql">CREATE TABLE rssItem (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    description TEXT,
    link TEXT NOT NULL,
    pubDate TEXT,
    pubDateTimestamp INTEGER, -- 添加时间戳字段用于排序
    guid TEXT UNIQUE, -- 保证 GUID 唯一性
    author TEXT,
    imageUrl TEXT,
    content TEXT,
    sourceUrl TEXT NOT NULL, -- RSS 源 URL
    createdAt INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
</code></pre>
</li>
<li>
<p><strong>订阅源表 (<code>subscription</code>)</strong></p>
<pre><code class="language-sql">CREATE TABLE subscription (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    url TEXT NOT NULL UNIQUE, -- RSS 源的唯一 URL
    name TEXT,                -- 用户友好的订阅源名称
    createdAt INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
</code></pre>
</li>
<li>
<p><strong>收藏表 (<code>favorite</code>)</strong></p>
<pre><code class="language-sql">CREATE TABLE favorite (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    description TEXT,
    link TEXT NOT NULL,
    pubDate TEXT,
    pubDateTimestamp INTEGER,
    guid TEXT UNIQUE, -- 保证同一篇文章只能收藏一次
    author TEXT,
    imageUrl TEXT,
    content TEXT,
    sourceUrl TEXT NOT NULL, -- 原始RSS源
    sourceName TEXT, -- 收藏时记录的源名称，防止源被删除后无法显示
    createdAt INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
</code></pre>
</li>
</ol>
<h4>性能优化索引设计：</h4>
<pre><code class="language-sql">-- rssItem 表索引
CREATE INDEX idx_rss_source_url ON rssItem(sourceUrl);
CREATE INDEX idx_rss_created_at ON rssItem(createdAt);

-- subscription 表索引
CREATE INDEX idx_subscription_url ON subscription(url);

-- favorite 表索引
CREATE INDEX idx_favorite_guid ON favorite(guid);
CREATE INDEX idx_favorite_created_at ON favorite(createdAt);
</code></pre>
<p>这些索引优化了常见查询的性能，比如按订阅源查询文章、按时间排序等操作。</p>
<h3>expect/actual 机制在数据库驱动中的应用</h3>
<p>还记得之前说的 <code>expect/actual</code> 吗？<a href="https://blog.grtsinry43.com/posts/modern-android-explore">（现代 Android 开发探索）</a></p>
<p>它在这里完美解决了不同平台数据库驱动的差异：</p>
<pre><code class="language-kotlin">// shared 模块中声明期望
expect object DatabaseModule {
    fun provideDatabaseDriverFactory(): DatabaseDriverFactory
}

// 各平台模块中提供实际实现
// Android: AndroidSqliteDriver
// iOS: NativeSqliteDriver  
// Desktop: SqliteDriver
</code></pre>
<p>这种分离让共享逻辑无需关心底层实现细节。</p>
<h2>UI 实现细节：Compose Multiplatform 的响应式与主题</h2>
<p>PureFlow 的 UI 完全由 Compose Multiplatform 构建，目标是实现真正的“一次编写，多端体验优化”。</p>
<h3>实际的依赖配置</h3>
<p>从 <code>composeApp/build.gradle.kts</code> 可以看到项目使用的 UI 相关依赖：</p>
<pre><code class="language-kotlin">commonMain.dependencies {
    implementation(compose.runtime)
    implementation(compose.foundation)
    implementation(compose.material3)
    implementation(compose.ui)
    implementation(compose.components.resources)
    implementation(compose.components.uiToolingPreview)
    implementation(compose.materialIconsExtended)
    implementation(libs.androidx.lifecycle.viewmodelCompose)
    implementation(libs.androidx.lifecycle.runtimeCompose)
    implementation(libs.ksoup)  // HTML解析
    implementation(libs.coil.compose)  // 图片加载
    implementation(libs.coil.network.ktor)
    implementation(projects.shared)
}
</code></pre>
<p>项目采用了完整的 Material 3 设计系统，使用 Coil 进行图片加载，KSoup 进行 HTML 解析。</p>
<h3>主题系统</h3>
<p>项目包含了完整的主题系统，在 <code>THEME_GUIDE.md</code> 中详细说明了如何使用和自定义主题。支持浅色/深色模式切换，以及 Material Design 3 的动态颜色特性。</p>
<p>（这部分是Claude Code写的）</p>
<h2>网络层核心实现</h2>
<p>网络请求选择了 Ktor Client，它的跨平台能力让不同平台复用了 HTTP 客户端的配置。</p>
<h3>实际的 RSS 解析实现</h3>
<p>项目使用了 <code>com.prof18.rssparser</code> 库而不是手动解析：</p>
<pre><code class="language-kotlin">class FeedParser(
    private val httpClient: HttpClient,
    private val rssParser: RssParser
) {
    suspend fun parseFeed(url: String): List&lt;RssItem&gt; {
        return try {
            // 1. 使用 Ktor 发起 GET 请求，获取 RSS 源的 XML 文本内容
            val xmlContent = httpClient.get(url).bodyAsText()

            // 2. 使用 RssParser 解析 XML 文本
            // 这个库会自动识别是 RSS 还是 Atom 格式
            val channel = rssParser.parse(xmlContent)

            // 3. 将解析后的数据模型映射到我们自己的 RssItem
            val rssItems = channel.items.map { feedItem -&gt;
                RssItem(
                    title = feedItem.title,
                    link = feedItem.link,
                    description = feedItem.description,
                    author = feedItem.author,
                    categories = feedItem.categories,
                    guid = feedItem.guid,
                    pubDate = feedItem.pubDate,
                    imageUrl = feedItem.image,
                    content = feedItem.content
                )
            }
            rssItems
        } catch (e: Exception) {
            emptyList()
        }
    }
}
</code></pre>
<p>这种做法的好处是利用了成熟的开源库，避免了重复造轮子，同时保持了代码的简洁性。</p>
<h3>平台特异性网络配置</h3>
<p><code>shared/build.gradle.kts</code> 为不同平台选择了合适的 HTTP 引擎：</p>
<pre><code class="language-kotlin">// 平台特异性实现
androidMain.dependencies {
    implementation(libs.ktor.client.android)
}
iosMain.dependencies {
    implementation(libs.ktor.client.darwin)
}
jvmMain.dependencies {
    implementation(libs.ktor.client.okhttp)
}
</code></pre>
<p>这个没啥说的：Android 使用 Android 引擎，iOS 使用 Darwin 引擎，Desktop 使用 OkHttp 引擎。</p>
<h2>数据管理：Repository 模式</h2>
<p>项目采用了 Repository 模式来抽象数据源，实际的实现比较直接：</p>
<pre><code class="language-kotlin">class RssRepository(
    databaseDriverFactory: DatabaseDriverFactory,
    private val feedParser: FeedParser
) {
    private val database = PureFlowDatabase(databaseDriverFactory.createDriver())
    private val queries = database.rssDatabaseQueries

    suspend fun fetchAndCacheRssFeed(url: String): Result&lt;List&lt;RssItem&gt;&gt; {
        return try {
            // 1. 从网络获取RSS数据
            val networkItems = feedParser.parseFeed(url)

            // 2. 缓存到本地数据库（使用事务确保数据完整性）
            database.transaction {
                cacheRssItems(networkItems, url)
            }

            Result.success(networkItems)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    fun getRssItemsFromCache(url: String): Flow&lt;List&lt;RssItem&gt;&gt; {
        return queries.selectBySourceUrl(url)
            .asFlow()
            .mapToList(Dispatchers.IO)
            .map { dbItems -&gt;
                dbItems.map { dbItem -&gt;
                    // 映射数据库对象到业务模型
                    RssItem(/* ... */)
                }
            }
    }
}
</code></pre>
<p>这种实现直接使用 Kotlin 的 <code>Result</code> 类型处理成功/失败状态，并通过 SQLDelight 的 <code>Flow</code> 支持实现响应式数据更新。</p>
<h2>服务层架构</h2>
<p>项目实现了一个 <code>RssServiceManager</code> 作为核心服务管理器：</p>
<pre><code class="language-kotlin">class RssServiceManager private constructor(
    private val databaseDriverFactory: DatabaseDriverFactory,
    private val feedParser: FeedParser
) {
    companion object {
        fun getInstance(
            databaseDriverFactory: DatabaseDriverFactory,
            feedParser: FeedParser
        ): RssServiceManager {
            // 单例模式实现
        }
    }

    val repository: RssRepository by lazy {
        RssRepository(databaseDriverFactory, feedParser)
    }

    // 各种业务服务的懒加载实例
    private val subscriptionService: SubscriptionService by lazy { /* ... */ }
    private val incrementalUpdateService: IncrementalUpdateService by lazy { /* ... */ }
    private val scheduledSyncService: ScheduledSyncService by lazy { /* ... */ }
}
</code></pre>
<p>这个服务管理器整合了所有核心功能，包括订阅管理、增量更新、定时同步等。</p>
<h2>依赖注入：expect/actual 模式</h2>
<p>项目采用了简洁的依赖注入方案，基于 <code>expect/actual</code> 模式：</p>
<pre><code class="language-kotlin">expect object DatabaseModule {
    fun provideDatabaseDriverFactory(): DatabaseDriverFactory
}

expect object NetworkModule {
    fun provideHttpClient(): HttpClient
}

object ServiceModule {
    private var serviceManager: RssServiceManager? = null

    fun provideRssServiceManager(): RssServiceManager {
        return serviceManager ?: run {
            val databaseDriverFactory = DatabaseModule.provideDatabaseDriverFactory()
            val httpClient = NetworkModule.provideHttpClient()
            val rssParser = RssParser()
            val feedParser = FeedParser(httpClient, rssParser)

            val newServiceManager = RssServiceManager.getInstance(
                databaseDriverFactory = databaseDriverFactory,
                feedParser = feedParser
            )
            serviceManager = newServiceManager
            newServiceManager
        }
    }
}
</code></pre>
<p>这种方式避免了重型 DI 框架的复杂性，同时保持了清晰的依赖关系。</p>
<h2>构建和部署细节</h2>
<p>项目实现了多平台的自动化构建和发布。</p>
<h3>实际的打包脚本 (<code>package.sh</code>)</h3>
<p>项目包含了一个真实的多平台打包脚本，支持：</p>
<pre><code class="language-bash">if [[ &quot;$OSTYPE&quot; == &quot;darwin&quot;* ]]; then
    AVAILABLE_FORMATS=&quot;dmg&quot;
elif [[ &quot;$OSTYPE&quot; == &quot;linux-gnu&quot;* ]]; then
    AVAILABLE_FORMATS=&quot;deb rpm appimage&quot;
elif [[ &quot;$OSTYPE&quot; == &quot;msys&quot; ]] || [[ &quot;$OSTYPE&quot; == &quot;cygwin&quot; ]]; then
    AVAILABLE_FORMATS=&quot;msi exe&quot;
fi

case $FORMAT in
    &quot;dmg&quot;)
        ./gradlew packageDmg
        ;;
    &quot;linux&quot;)
        ./gradlew packageDeb packageRpm packageAppImage
        ;;
    &quot;windows&quot;)
        ./gradlew packageMsi packageExe
        ;;
    &quot;all&quot;)
        ./gradlew packageDistributionForCurrentOS
        ;;
esac
</code></pre>
<h3>桌面应用打包配置</h3>
<p>从 <code>composeApp/build.gradle.kts</code> 可以看到详细的桌面应用打包配置，包括：</p>
<ul>
<li>根据操作系统自动选择打包格式</li>
<li>平台特有的配置（如 macOS 的 Bundle ID，Windows 的升级 UUID）</li>
<li>应用程序的元数据和图标配置</li>
</ul>
<h3>CI/CD 流水线</h3>
<p>GitHub Actions 配置实现了：</p>
<ul>
<li><strong>多平台构建矩阵</strong>: 在不同环境中分别构建各平台的安装包</li>
<li><strong>标签驱动的自动发布</strong>: 基于 Git 标签自动触发发布流程</li>
<li><strong>构建缓存优化</strong>: 使用 Gradle 缓存提升构建速度</li>
<li><strong>详细的构建报告</strong>: 自动生成构建状态总结</li>
</ul>
<h2>总结与思考</h2>
<p>这个项目算是一个活生生的案例，证明了在保证代码质量的同时，完全可以实现真正的一次编写、多端运行。</p>
<p>因为时间问题，这里选择了务实写法，使用成熟的第三方库而不是重复造轮子，采用简洁的依赖注入方案而不是重型框架</p>
<p>如果你也对跨平台开发感兴趣，或者正纠结于技术选型，希望这篇文章能为你带来一些启发和帮助。项目展现了现代 Kotlin 生态的强大能力，也让我对跨平台开发的未来充满信心。</p>]]></description>
      <author>grtsinry43</author>
      <enclosure url="https://blog.grtsinry43.com/uploads/2025/09/22/2025-09-22_20.28.40.jpg_8c46d838-cf62-44d8-b0b5-6e617974228f.jpg" length="0" type="image/jpeg"></enclosure>
      <guid>article-29</guid>
      <pubDate>Mon, 22 Sep 2025 12:29:52 +0000</pubDate>
    </item>
    <item>
      <title>命运开了个无情的玩笑</title>
      <link>https://blog.grtsinry43.com/moments/2025/09/16/some-dark-days</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/moments/2025/09/16/some-dark-days">https://blog.grtsinry43.com/moments/2025/09/16/some-dark-days</a></p></blockquote><blockquote>
<p>[!NOTE]</p>
<p>看前提示：这篇文章有大量的负面情绪，慎入，但是如果你心情不好，说不定可以通过比惨的方式，在我这里找到慰藉🥲</p>
</blockquote>
<p><strong>最近的生活，真是烂透了。</strong></p>
<p>如果说，人生是一部戏，那命运给我的，大概是一个最意料之外的玩笑剧本。</p>
<p>故事从9月4号开始。那天早上，因为前一晚熬夜写项目，起床时不太清醒，不小心踩空楼梯，一脚站到了地上。当时感觉疼得不对劲，于是我中午就请假去了中山一院急诊。拍完片子，医生告诉我：右足第五跖骨基底骨折。</p>
<p>从那之后，我的世界就像多米诺骨牌一样，轰然倒塌。</p>
<p>首当其冲的是，我被迫中断了在🐧的实习。这是我无数次梦寐以求的地方，是我用尽全力在大二提升实力，才掌握的水平，虽然我清晰知道是组长看我年轻，组里确实缺人，才让我这个废物，这个漏网之鱼进来，在这个组，我真的配不上。不过不管怎么说，我以为大二就能实习领先进度，提早积累经验，更是让我有机会超越同龄人。我曾以为自己可以在这里做出一些“大产出”，但所有的一切，在这里都化为泡影。害，我本来想专门写一篇文章，匿名脱敏感谢一下我的同事的，唉，连这个机会都不给我。</p>
<p>生活不仅有眼前的苟且，还有远方的苟且。</p>
<p>我感到前所未有的内疚和自责。我看到因为我的问题，导致了线上报错，导致了临期对接，同事们不得不集体加班赶ddl。我感觉都是我的错，我太菜了，我的存在，就是在给团队添麻烦，我甚至觉得，我产出的代码还不如我引起的bug多，好不容易有一个大的产出，真的以为自己能够鼓捣点什么东西出来的时候，结果偏偏这个时候我不得不去医院，然后请假，然后，就没有了然后，甚至可能没有机会再回去。</p>
<p>我感觉所有取得的成就都是侥幸和放水。正如字节一面就挂一样，🐧的面试也就是因为组长没有问太多算法和八股，只是因为急于招人给我放水了。我感觉自己就是...彻彻底底的飞舞。</p>
<p>命运总是一波三折，这些日子里，没有一天是好事，没有一天让我看到希望，都是痛苦，自责，与绝望。甚至因为难受复习不下去，上一次准备实习，还再一次挂了考试只能下学期解决。我愤怒地咒骂学校的培养方案，咒骂那些无用的课程，咒骂那些处处刁难我的规章制度。我甚至想过，如果生命最终会消逝，那么我所经历的所有痛苦和成长，又有什么用呢？</p>
<p>但是，在这个烂透了的生活里，换了个角度，也未尝没有一些温暖。</p>
<p>当换一个视角，我也发现这个社会，处处都是温暖的。从陌生人的帮助，到医院的医生和护士，再到我身边的朋友和室友，他们都在默默地照顾我。我也知道我要坚强，独自一人，从广州坐火车回学校。我申请了12306的重点旅客服务，拄着拐杖，坐着轮椅，在朋友的接应下，也是顺利到了学校。</p>
<p>直到复查，我的心里才稍微松了一口气。我可能只是有点轻度抑郁了，我需要好好照顾自己。而这几天，感谢我的朋友，听我倾诉着生命中的苦楚，感谢你们理解我的痛苦，理解我的挣扎，理解我内心的脆弱和不甘。</p>
<p>我独自一人，在异乡面对这个意外，我不知道这算不算是一种成长，也许只是一种本能的自我保护。我不知道未来会怎样，但至少现在，我还在，我的故事还在继续。</p>
<p>也许，在我每天的生活中，我总有一天能找到希望吧。</p>
<p>如果你现在也正在经历痛苦，请相信，你并不孤单。也许，你可以从我的比惨中，找到一丝慰藉。</p>]]></description>
      <author>grtsinry43</author>
      <guid>moment-13</guid>
      <pubDate>Tue, 16 Sep 2025 15:15:51 +0000</pubDate>
    </item>
    <item>
      <title>「手搓系列 01」 从零搭建 Vue 文档站，学习从静态生成到语法解析</title>
      <link>https://blog.grtsinry43.com/posts/hands-on-vue-docs</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/posts/hands-on-vue-docs">https://blog.grtsinry43.com/posts/hands-on-vue-docs</a></p></blockquote><p>最近在实习，好久不更新了，休假回校之前，准备开一个新的合集 「手搓系列」，我们从头实现一些习以为常的前端轮子/技术栈/工具链，用手写的方式学习它的思路，同时得到些新的思考。</p>
<p>手搓的故事就从比较知名的文档网站 VitePress 开始吧，之后预计还有打包工具，ui 库，以及前端基建，干货多多，可以考虑长期追更（doge），自己写一遍既可以给大家讲原理，自己也能学习，两全其美，每一个部分都由“分析效果——规划实现——MVP 调通——难点解决——项目成品”组成</p>
<p>让我们开始吧</p>
<blockquote>
<p>[!NOTE]</p>
<p>受限于精力，业余时间实在不多，这个 demo 项目的样式，交互还在设计，完成之后会开源</p>
</blockquote>
<h2>分析效果</h2>
<p>VitePress 文档站，一个知名的静态文档站点，被无数项目和开发者所采用，简洁精美，配置简单，深受开发者喜爱。</p>
<p>分析它的效果，首先是 yaml 驱动的元信息，md 中直接书写 Vue 组件，智能生成 siderbar 和 toc，主页内容的自定义，markdown 扩展语法，代码着色高亮等等</p>
<h2>规划实现</h2>
<h3>Vite 插件</h3>
<p>我们将依托于 Vite 的强大能力，一步步手动实现这些特性</p>
<p>在 Vite 的构建中，我们可以编写插件来自定义 vite 处理文件的操作，严格来说，对于 vite 来说，它既不认识文件的扩展名，也看不懂里面的内容，之所以能够解析 vue/react 等等库，也就是因为插件的存在
Vite 插件（也就是 rollup）的类型定义是这样的</p>
<pre><code class="language-typescript">interface Plugin$1&lt;A = any&gt; extends Rollup.Plugin&lt;A&gt; {
  hotUpdate?: ObjectHook&lt;(this: MinimalPluginContext &amp; {
    environment: DevEnvironment;
  }, options: HotUpdateOptions) =&gt; Array&lt;EnvironmentModuleNode&gt; | void | Promise&lt;Array&lt;EnvironmentModuleNode&gt; | void&gt;&gt;;
  resolveId?: ObjectHook&lt;(this: PluginContext, source: string, importer: string | undefined, options: {
    attributes: Record&lt;string, string&gt;;
    custom?: CustomPluginOptions;
    ssr?: boolean;
    isEntry: boolean;
  }) =&gt; Promise&lt;ResolveIdResult&gt; | ResolveIdResult, {
    filter?: {
      id?: StringFilter&lt;RegExp&gt;;
    };
  }&gt;;
  load?: ObjectHook&lt;(this: PluginContext, id: string, options?: {
    ssr?: boolean;
  }) =&gt; Promise&lt;LoadResult&gt; | LoadResult, {
    filter?: {
      id?: StringFilter;
    };
  }&gt;;
  transform?: ObjectHook&lt;(this: TransformPluginContext, code: string, id: string, options?: {
    ssr?: boolean;
  }) =&gt; Promise&lt;Rollup.TransformResult&gt; | Rollup.TransformResult, {
    filter?: {
      id?: StringFilter;
      code?: StringFilter;
    };
  }&gt;;
  sharedDuringBuild?: boolean;
  perEnvironmentStartEndDuringDev?: boolean;
  enforce?: 'pre' | 'post';
  apply?: 'serve' | 'build' | ((this: void, config: UserConfig, env: ConfigEnv) =&gt; boolean);
  applyToEnvironment?: (environment: PartialEnvironment) =&gt; boolean | Promise&lt;boolean&gt; | PluginOption;
  config?: ObjectHook&lt;(this: ConfigPluginContext, config: UserConfig, env: ConfigEnv) =&gt; Omit&lt;UserConfig, 'plugins'&gt; | null | void | Promise&lt;Omit&lt;UserConfig, 'plugins'&gt; | null | void&gt;&gt;;
  configEnvironment?: ObjectHook&lt;(this: ConfigPluginContext, name: string, config: EnvironmentOptions, env: ConfigEnv &amp; {
    isSsrTargetWebworker?: boolean;
  }) =&gt; EnvironmentOptions | null | void | Promise&lt;EnvironmentOptions | null | void&gt;&gt;;
  configResolved?: ObjectHook&lt;(this: MinimalPluginContextWithoutEnvironment, config: ResolvedConfig) =&gt; void | Promise&lt;void&gt;&gt;;
  configureServer?: ObjectHook&lt;ServerHook&gt;;
  configurePreviewServer?: ObjectHook&lt;PreviewServerHook&gt;;
  transformIndexHtml?: IndexHtmlTransform;
  buildApp?: ObjectHook&lt;BuildAppHook&gt;;
  handleHotUpdate?: ObjectHook&lt;(this: MinimalPluginContextWithoutEnvironment, ctx: HmrContext) =&gt; Array&lt;ModuleNode&gt; | void | Promise&lt;Array&lt;ModuleNode&gt; | void&gt;&gt;;
}
</code></pre>
<p>其中对我们来说最常用的就是 transform，id 就是文件名，code 就是代码内容，也就是相当于拿到每个文件，你告诉这个文件要怎么处理，处理完还给它就行
于是我们之前说到的特性，markdown 解析可以这里拿，元数据可以这里拿，甚至组件的渲染都可以这里自定义</p>
<p>当然别的特性也有大用，我们慢慢讲解</p>
<h3>静态网站生成（SSG）</h3>
<p>这名词听起来这么高大上，其实很好理解。</p>
<p>你应该了解过，也用过/实现过 SSR，也就是服务端渲染，这种方式利用 node runtime，在 node 中创建基础应用然后为客户端分发部分渲染好的字符串，由客户端加载 js 斌完成“水合”，进而提升了性能和 SEO 体验</p>
<p>但是这个时候又有新的问题出现了，SSR 对服务端的性能要求很高，或者是像一些静态的内容很少更新，无需或很少交互的内容/serverless，那么我们就让 node 渲染一次然后直接存起来不就好了，诶，于是你发明了 SSG，通过构建时候的一次渲染+客户端脚本水合，这使得你的应用极为轻量，因为你可以在构建阶段完成大部分的页面渲染。</p>
<h3>技术选择</h3>
<p>选择好了大概方向，那我们来决定用什么来写。</p>
<p>我们采用 Vite+Vue+Markdown-it+shiki+tailwind 手搓这个站点</p>
<p>其中如果是 react 的话其实绝大部分都采用 mdx，一步到位没有乐趣了</p>
<p>而 vue+markdown-it 刚好能发挥 vue sfc 的灵活性，以及自己掌控完全解析的高可玩性</p>
<p>shiki 没啥好说的，好用就是了，当然也可以 highlightjs。</p>
<h2>从 MVP 开始</h2>
<p>让我们从构建最小可行性产品开始</p>
<ol>
<li><strong>项目初始化</strong>：首先，我们用 Vite 创建一个基本的 Vue 项目作为我们的骨架。</li>
<li><strong>Markdown 解析</strong>：然后，我们要让 Vite 能“看懂” <code>.md</code> 文件，并把它转换成 Vue 组件。</li>
<li><strong>路由系统</strong>：接着，我们会建立一个简单的文件路由，让不同的 URL 能访问到对应的 Markdown 页面。</li>
<li><strong>静态站点生成 (SSG)</strong>：最后，我们会编写一个构建脚本，把所有页面打包成最终的静态 HTML 文件。</li>
</ol>
<pre><code class="language-bash">pnpm create vite@latest
</code></pre>
<p>先建项目，没啥可说的，Vue3+ts 模板，然后装好依赖</p>
<h3>第一个插件</h3>
<p>首先我们要让 Vite 认识 <code>.md</code>，让我们在 <code>vite.config.js</code> 写下第一个插件</p>
<pre><code class="language-typescript">import { defineConfig, Plugin  } from 'vite'
import vue from '@vitejs/plugin-vue'
import Markdown from 'markdown-it'

// 1. 初始化 markdown-it
const md = new Markdown()

// 2. 自定义插件
const markdownPlugin: Plugin = {
  // 插件名称
  name: 'vite-plugin-markdown-to-vue',
  // 这是一个预处理
  enforece: &quot;pre&quot;,
  // transform 钩子
  transform(code: string, id: string) {
    // 检查文件扩展名是否是 .md
    if (id.endsWith('.md')) {
      // 使用 markdown-it 将 Markdown 文本转换为 HTML
      const html = md.render(code);

      // 3. 将 HTML 包装成一个 Vue 组件的模板字符串
      // 注意：这里需要使用 backticks (`) 包裹 HTML，并将其导出
      const vueComponent = `
        &lt;template&gt;
          &lt;div class=&quot;markdown-body&quot;&gt;
            ${html}
          &lt;/div&gt;
        &lt;/template&gt;
        
        &lt;script lang=&quot;ts&quot;&gt;
        &lt;/script&gt;
      `;

      // 返回转换后的 Vue 组件代码
      return {
        code: vueComponent
      };
    }
  },
};


// https://vitejs.dev/config/
export default defineConfig({
  // 4. 在 Vite 中使用我们的插件
  plugins: [vue({
            // 这里不要忘记这里让 vue compiler 也要处理 .md
            include: [/\.vue$/, /\.md$/], 
        }), markdownPlugin],
})
</code></pre>
<p>是的，如你所见，就这么简单，使用 <code>md.render</code> 渲染成 html，组装成 sfc 之后直接给到下一步的 vue compiler 就可以了。</p>
<p>现在，我们直接创建一个 Markdown 文件，并在我们的 Vue 应用里使用它。</p>
<ol>
<li>
<p>在 <code>src</code> 文件夹下创建一个名为 <code>hello.md</code> 的文件，内容如下：</p>
<p>Markdown</p>
<pre><code># Hello, VitePress Clone!

This is a paragraph rendered from Markdown.

- Item 1
- Item 2
</code></pre>
</li>
<li>
<p>修改 <code>src/App.vue</code> 文件，清空原有内容，然后引入并使用这个 Markdown 文件：</p>
<p>Code snippet</p>
<pre><code>&lt;template&gt;
  &lt;HelloWorld /&gt;
&lt;/template&gt;

&lt;script setup&gt;
// 像导入一个普通的 Vue 组件一样导入 .md 文件
import HelloWorld from './hello.md'
&lt;/script&gt;
</code></pre>
</li>
</ol>
<p>诶，这样就渲染出来了，原汁原味的 html</p>
<p>注：这里引入 ide 会报错，建一个 <code>d.ts</code> 就行</p>
<pre><code class="language-typescript">declare module '*.md' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent&lt;{}, {}, any&gt;
  export default component
}
</code></pre>
<h3>内容目录与路由系统</h3>
<p>为了方便书写内容，我们创建 <code>content/</code> 文件夹，现在开始构建路由</p>
<p>我们希望程序能自动扫描 <code>content/</code> 目录下的所有 <code>.md</code> 文件，并为它们生成对应的页面路由。例如，<code>content/hello.md</code> 应该能通过 <code>/hello</code> 这个网址访问到。</p>
<p>这个过程可以分为两大部分：</p>
<ol>
<li><strong>扫描文件 (Node.js)</strong>：我们需要在 Vite 的配置文件 (<code>vite.config.ts</code>) 里编写一段 Node.js 代码，用来读取 <code>content/</code> 目录下的所有文件，并生成一个路由配置列表。</li>
<li><strong>使用路由 (浏览器端)</strong>：我们需要在 Vue 应用 (<code>src/</code> 目录) 中安装和配置 <code>vue-router</code>，让它使用我们上一步生成的路由列表来展示不同的页面。</li>
</ol>
<p>先装 router</p>
<pre><code class="language-bash">pnpm add vue-router
</code></pre>
<p>好玩的来了，构建工具在构建中可以生成一些“<strong>虚拟模块</strong> (Virtual Module)”。</p>
<p>听起来很高级，但原理很简单：我们将编写一个 Vite 插件，这个插件会创建一个 <strong>只存在于内存中</strong> 的“虚拟文件”。我们的 Vue 应用可以像导入普通文件一样导入这个虚拟文件，从而获取到我们动态生成的路由列表。</p>
<p>那就简单了，无外乎两步：</p>
<ol>
<li><strong>在 Vite 插件中</strong>：扫描 <code>content/</code> 目录，生成路由配置，并通过一个特殊的虚拟 ID (咱们这利用 <code>virtual:routes</code>) 来提供这些配置。</li>
<li><strong>在 Vue 应用中</strong>：导入 <code>virtual:routes</code>，并用它来初始化 <code>vue-router</code>。</li>
</ol>
<p>我们需要一个新的 Vite 插件。这次，之前说的其他钩子就有用了：<code>resolveId</code> 和 <code>load</code>。</p>
<ul>
<li><strong><code>resolveId</code> 钩子</strong>：当 Vite 看到 <code>import ... from 'virtual:routes'</code> 这样的语句时，它会问：“这个 'virtual: routes' 到底是什么东西？” 这个钩子就是用来回答这个问题的。我们会告诉 Vite：“是的，我认识这个 ID，你交给我来处理就行。”</li>
<li><strong><code>load</code> 钩子</strong>：在 <code>resolveId</code> 确认了 ID 之后，Vite 就会调用 <code>load</code> 钩子，问：“好了，那这个模块的内容是什么？” 在这里，我们就会动态地生成代码并返回。</li>
</ul>
<p>利用这个功能，让我们创建新的插件：</p>
<pre><code class="language-typescript">import { defineConfig, type Plugin } from 'vite'
import vue from '@vitejs/plugin-vue'
import Markdown from 'markdown-it'
import fs from 'fs'
import path from 'path'

const md = new Markdown()

// 之前的 markdownPlugin，不管它
const markdownPlugin: Plugin = { ... };

const routesPlugin: Plugin = {
    name: 'vite-plugin-virtual-routes',
    resolveId(id) {
        // 如果导入的 ID 是 'virtual: routes'，就告诉 Vite 我们要处理它
        if (id === 'virtual:routes') {
            return id;
        }
    },
    load(id) {
        // 确认是我们的虚拟模块
        if (id === 'virtual:routes') {
            // 1. 定义 content 文件夹的路径
            const contentDir = path.resolve(__dirname, 'content');
            // 2. 读取文件夹下的所有文件名
            const files = fs.readdirSync(contentDir);
            
            // 3. 为每个 .md 文件生成一个路由对象
            const routes = files.filter(file =&gt; file.endsWith('.md')).map(file =&gt; {
                const name = file.replace('.md', '');
                const path = name === 'index' ? '/' : `/${name}`;
                // 这里直接导入
                return `{
                    path: '${path}',
                    component: () =&gt; import('/content/${file}')
                }`;
            });

            // 4. 将路由对象数组转换成 JavaScript 代码字符串就 ok 了
            return `export const routes = [${routes.join(', ')}]`;
        }
    }
}


// https://vite.dev/config/
export default defineConfig({
    plugins: [
        vue({
            include: [/\.vue$/, /\.md$/],
        }),
        markdownPlugin,
        routesPlugin
    ],
})
</code></pre>
<p>同样为了 ts 更好推断，还要添加 d.ts</p>
<pre><code class="language-typescript">declare module 'virtual:routes' {
  import type { RouteRecordRaw } from 'vue-router'
  export const routes: RouteRecordRaw[]
}
</code></pre>
<p>于是就可以直接引入</p>
<pre><code class="language-typescript">import { routes } from 'virtual:routes'
</code></pre>
<p>创建路由 use router-view 不再赘述，前端开发写过无数次了，于是你就有了简单路由。</p>
<p>但是这还不够呀，文档一定会有多级路由存在的，那也简单，递归处理一下就好了呗</p>
<p>递归很简单，基本功了，直接上代码</p>
<pre><code class="language-typescript">import fs from &quot;fs&quot;;
import path from &quot;path&quot;;
import type {Plugin} from &quot;vite&quot;;

function generateRoutesFromDir(dir: string, basePath: string = '/') {
    const entries = fs.readdirSync(dir, {withFileTypes: true});
    const routes: Array&lt;{ path: string; importPath: string }&gt; = [];

    for (const entry of entries) {
        const fullPath = path.join(dir, entry.name);

        if (entry.isDirectory()) {
            const newBasePath = path.posix.join(basePath, entry.name);
            const subRoutes = generateRoutesFromDir(fullPath, newBasePath);
            routes.push(...subRoutes);
        } else if (entry.isFile() &amp;&amp; entry.name.endsWith('.md')) {
            const name = entry.name.replace('.md', '');
            const routePath = name === 'index' ? basePath : path.posix.join(basePath, name);

            const importPath = path.posix.join('/content', basePath, entry.name);

            routes.push({
                path: routePath,
                importPath: importPath,
            });
        }
    }
    return routes;
}


export function routesPlugin(): Plugin {
    return {
        name: 'vite-plugin-virtual-routes',
        resolveId(id) {
            if (id === 'virtual:routes') {
                return id;
            }
        },
        load(id) {
            if (id === 'virtual:routes') {
                const contentDir = path.resolve(__dirname, '../content');
                const routes = generateRoutesFromDir(contentDir);

                const routesString = routes.map(route =&gt; `{
                        path: '${route.path}',
                        component: () =&gt; import(/* @vite-ignore */ '${route.importPath}')
                    }`);

                return `export const routes = [${routesString.join(', ')}]`;
            }
        }
    }
}
</code></pre>
<p>到这里，你的应用便有了雏形，可以在不同页面之间导航了。</p>
<h3>从 SSR 到 SSG</h3>
<p>相信熟悉前端的你一定写过 SSR 的手动实现，思路就是维护两个 entry，一个 server，一个 client，使用打包工具分别处理，收到请求首先内存 router 切换，状态库注入，然后渲染发给客户端，</p>
<p>这里还是实现一次吧，毕竟懂了 SSR 就简单了。</p>
<p>首先让 router 在服务端内存维护，客户端历史记录维护。</p>
<pre><code class="language-typescript">import { 
  createRouter as _createRouter, 
  createWebHistory, 
  createMemoryHistory 
} from 'vue-router'
import { routes } from 'virtual:routes'

export const createRouter = () =&gt; _createRouter({
  // Vite 会提供一个环境变量 import.meta.env.SSR
  // 在浏览器环境(SSR 为 false)，我们使用 history 模式
  // 在 Node.js 环境(SSR 为 true)，我们使用 memory 模式
  history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
  routes,
})
</code></pre>
<p>随后主应用使用这个额算是工厂函数</p>
<pre><code class="language-typescript">import { createApp } from 'vue'
import App from './App.vue'
import { createRouter } from './router' // &lt;-- 1. 导入 createRouter 函数

const app = createApp(App)
const router = createRouter() // &lt;-- 2. 调用函数创建实例
app.use(router)

// 在挂载应用之前，我们需要确保路由已经准备就绪
router.isReady().then(() =&gt; {
  app.mount('#app')
})
</code></pre>
<p>好，接下来是服务端入口，我们新建一个 <code>entry-ssr.ts</code></p>
<pre><code class="language-typescript">import { createSSRApp } from 'vue'
import App from './App.vue'
import { createRouter } from './router'

// 在 Node.js 环境中调用
export function createApp() {
  const app = createSSRApp(App)
  const router = createRouter()
  app.use(router)

  // 返回 app 和 router 实例
  return { app, router }
}
</code></pre>
<p>Vite 的强大又来了，它提供一个 <code>createServer</code> 可以方便创建服务端环境，而 <code>ssrLoadModule</code> 就是为加载 ssr 入口而生，优化常用使用场景确实方便于 webpack 维护三套配置。</p>
<p>用我们之前扫描到的所有路由，依次完成渲染</p>
<pre><code class="language-typescript">// ssg.ts
import { build, createServer } from 'vite'
import path from 'path'
import fs from 'fs/promises'
import { renderToString } from 'vue/server-renderer'

  console.log('Building for client...');
  await build();
  console.log('Client build complete.');

  console.log('Starting SSR...');

  const server = await createServer({
    server: { middlewareMode: true },
    appType: 'custom',
  });

  try {
    const { createApp } = await server.ssrLoadModule('./src/entry-ssr.ts');
    const { routes } = await server.ssrLoadModule('virtual:routes');
    const routesToRender = routes.map((route: any) =&gt; route.path);
    console.log('Discovered routes to render:', routesToRender);

    for (const route of routesToRender) {
      const { app, router } = createApp();

      await router.push(route);
      await router.isReady();

      const html = await renderToString(app);
      
      // 我们暂时还只打印 HTML 片段
      console.log(`Rendered HTML for ${route}`);
    }
  } finally {
    await server.close();
  }

  console.log('SSR complete.');
})();
</code></pre>
<p>正如我们之前的思路，我们把这些存起来就行了，存哪里呢，诶，存在客户端的打包刚好</p>
<p>为了渲染好的内容能够精准插入，我们不妨给它标记下，编辑根 <code>index.html</code></p>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html lang=&quot;en&quot;&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;link rel=&quot;icon&quot; type=&quot;image/svg+xml&quot; href=&quot;/vite.svg&quot; /&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
    &lt;title&gt;Vite + Vue + TS&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div id=&quot;app&quot;&gt;
    &lt;!-- app inject--&gt;
    &lt;/div&gt;
    &lt;script type=&quot;module&quot; src=&quot;/src/main.ts&quot;&gt;&lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

</code></pre>
<p>我们添加了个注释，这样便于 SSR 拿到字符串之后替换
思路有了，相信对大家来说超级简单，直接最后的代码~</p>
<pre><code class="language-typescript">import { build, createServer } from 'vite'
import path from 'path'
import fs from 'fs/promises'
import { renderToString } from 'vue/server-renderer'

import { fileURLToPath } from 'url'
import { dirname } from 'path'

    ;(async () =&gt; {
    const __dirname = dirname(fileURLToPath(import.meta.url));

    console.log('Building for client...');
    await build();
    console.log('Client build complete.');

    console.log('Starting SSG (Static Site Generation)...');

    const server = await createServer({
        server: { middlewareMode: true },
        appType: 'custom',
    });

    try {
        const { createApp } = await server.ssrLoadModule('./src/entry-ssr.ts');
        const { routes } = await server.ssrLoadModule('virtual:routes');
        const routesToRender = routes.map((route: any) =&gt; route.path);

        const template = await fs.readFile(path.resolve(__dirname, '../dist/index.html'), 'utf-8');

        for (const route of routesToRender) {
            const { app, router } = createApp();
            await router.push(route);
            await router.isReady();

            const appHtml = await renderToString(app);

            const finalHtml = template.replace(`&lt;!-- app inject--&gt;`, appHtml);

            // 计算输出路径, 确保“干净”的 URL (例如 /hello 对应 /hello/index.html)
            const dirPath = path.join(__dirname, '../dist', route);
            const filePath = path.join(dirPath, 'index.html');

            // 递归创建目录, 然后写入最终的 HTML 文件
            await fs.mkdir(dirPath, { recursive: true });
            await fs.writeFile(filePath, finalHtml);

            console.log(`✓ Pre-rendered: ${route}`);
        }
    } finally {
        await server.close();
    }

    console.log('SSG complete. Your static site is ready in the &quot;dist&quot; folder.');
})();
</code></pre>
<p>到这里，你的网站成功实现静态生成！（撒花）</p>
<p>在这里之后，你可以比如用 <code>gray-matter</code> 解析元数据，创建 meta 组件，这些思路差不多</p>
<h2>难点解决</h2>
<p>难题到这里开始了。</p>
<h3>shiki 代码着色</h3>
<p>核心挑战：同步 vs. 异步</p>
<p>在集成 <code>shiki</code> 时，我们会遇到一个非常经典且重要的问题：<code>markdown-it</code> 的渲染过程是 <strong>同步</strong> 的，但 <code>shiki</code> 的高亮过程是 <strong>异步</strong> 的（因为它需要异步加载不同语言的语法文件）。</p>
<p>要解决这个矛盾，我们只需要顶层 <code>await</code> 来初始化 <code>shiki</code>，然后把它作为高亮引擎提供给 <code>markdown-it</code>。其实也就是等他加载完再继续</p>
<pre><code class="language-bash">pnpm add shiki
</code></pre>
<pre><code class="language-typescript">import MarkdownIt from 'markdown-it'
import {createHighlighter} from 'shiki'

const highlighter = await createHighlighter({
	themes: ['nord', 'github-light'],
	langs: ['ts', 'js', 'json', 'vue', 'css', 'html', 'md']
})

// Shiki 准备好之后, 我们才创建 markdown-it 实例
export const md = new MarkdownIt({
	highlight(code, lang) {
        if (!lang || !highlighter.getLoadedLanguages().includes(lang as any)) {
    return `&lt;pre class=&quot;shiki&quot;&gt;&lt;code&gt;${code}&lt;/code&gt;&lt;/pre&gt;`;
        }
        return highlighter.codeToHtml(code, { lang })
    }

})
</code></pre>
<p>现在我们只需要用这里的 md 对象替换 vite 插件里面的就好啦，很轻松，并且由于只有构建时引入一次，也不会影响客户端代码大小。</p>
<h3>自定义组件解析</h3>
<p>还记得之前我们说过 Vue 在这里的优势是 sfc 嘛，我们不妨将整篇文章作为 sfc 组件，利用 <code>@vue/compiler-sfc</code> 一把梭哈</p>
<p>当然也不是那么暴力，整体的思路是</p>
<p>原始文档 → 正则替换组件 → Markdown 渲染 → SFC 组件 → 最终渲染</p>
<p>我们就从简单的 <code>&lt;Alert/&gt;</code> 入手，首先是写好正则：</p>
<pre><code class="language-typescript">// 注册的自定义语法/组件映射
    const customSyntaxMap = new Map&lt;RegExp, (...args: any[]) =&gt; string&gt;([
        // Alert 组件支持
        [/:::\s*(info|warning|success|danger)(?:\s+(.+?))?\s*\n(.*?)\n:::/gs, (_match: string, type: string, title: string, content: string) =&gt; {
            const titleAttr = title ? ` title=&quot;${title.trim()}&quot;` : ''
            return `&lt;Alert type=&quot;${type}&quot;${titleAttr}&gt;${content.trim()}&lt;/Alert&gt;`
        }],
    ])
</code></pre>
<p>于是我们可以直接在 transform 钩子中替换与渲染：</p>
<p>我直接在注释中讲解吧</p>
<pre><code class="language-typescript">transform(code: string, id: string) {
            if (!id.endsWith('.md')) {
                return null
            }

    		// 提取前置信息和最终文本
            const { data: frontmatter, content: markdownContent } = matter(code)

            let processedContent = markdownContent
            const components = new Set&lt;string&gt;()

            // 处理自定义语法
            for (const [regex, replacer] of customSyntaxMap) {
                processedContent = processedContent.replace(regex, (...args) =&gt; {
                    const result = replacer(...args)
                    const componentMatch = result.match(/&lt;([A-Z][a-zA-Z0-9]*)/)
                    if (componentMatch) {
                        components.add(componentMatch[1])
                    }
                    return result
                })
            }

            // 分割内容，也就是每段逐个处理
            const parts: Array&lt;{ type: 'markdown' | 'component', content: string }&gt; = []
            const componentRegex = /&lt;([A-Z][a-zA-Z0-9]*)[^&gt;]*&gt;.*?&lt;\/\1&gt;/gs

            let lastIndex = 0
            let match

            while ((match = componentRegex.exec(processedContent)) !== null) {
                if (match.index &gt; lastIndex) {
                    const markdownPart = processedContent.slice(lastIndex, match.index).trim()
                    if (markdownPart) {
                        parts.push({ type: 'markdown', content: markdownPart })
                    }
                }
                parts.push({ type: 'component', content: match[0] })
                lastIndex = match.index + match[0].length
            }

            if (lastIndex &lt; processedContent.length) {
                const markdownPart = processedContent.slice(lastIndex).trim()
                if (markdownPart) {
                    parts.push({ type: 'markdown', content: markdownPart })
                }
            }

            // 生成组件导入，拼接到最后 sfc 用
            const componentImports = Array.from(components)
                .map(name =&gt; `import ${name} from '/src/components/${name}.vue'`)
                .join('\n')

            // 生成模板内容
            const templateContent = parts.map(part =&gt; {
                if (part.type === 'component') {
                    return part.content
                } else {
                    const htmlContent = md.render(part.content)
                    return `&lt;div class=&quot;markdown-content&quot;&gt;${htmlContent}&lt;/div&gt;`
                }
            }).join('\n    ')

            // 直接生成带布局的完整页面
            const vueComponent = `&lt;script setup lang=&quot;ts&quot;&gt;
                import Layout from '/src/layouts/Layout.vue'
                // 这里引入注册组件
                ${componentImports}
                
                const frontmatter = ${JSON.stringify(frontmatter)}
                &lt;/script&gt;
                
                &lt;template&gt;
                  &lt;Layout&gt;
                    &lt;div class=&quot;page-content&quot;&gt;
                      &lt;h1 v-if=&quot;frontmatter?.title&quot; class=&quot;page-title&quot;&gt;{{frontmatter.title}}&lt;/h1&gt;
                      &lt;div class=&quot;markdown-body&quot;&gt;
                        ${templateContent}
                      &lt;/div&gt;
                    &lt;/div&gt;
                  &lt;/Layout&gt;
                &lt;/template&gt;
                
                &lt;style scoped&gt;
                // 对应的样式
                &lt;/style&gt;`


            return {
                code: vueComponent
            }
        }
</code></pre>
<p>到这里，「手搓系列 01」就告一段落了。我们一起从零构建了一个完整的静态文档站，从最基础的 Vite 插件，到核心的 SSG 渲染，再到代码高亮和自定义组件解析。这个过程不仅是为了得到一个能用的轮子，更是为了深入理解这些习以为常的技术栈背后，每个环节是如何协同工作的。</p>
<p>最终的项目我还在慢慢开发，涉及到每个组件写一遍，还有 UI 和功能，精力真的不够，等到做完第一时间更新和开源。</p>
<hr>
<p>手写一遍，才能真正掌握它的精髓。</p>
<p>如果你对这个系列感兴趣，可以长期关注 RSS。在接下来的文章里，我们还会继续探索更多有趣的前端轮子，比如打包工具、UI 库和前端基建等。让我们动手继续敲下去</p>]]></description>
      <author>grtsinry43</author>
      <guid>article-28</guid>
      <pubDate>Tue, 02 Sep 2025 18:03:56 +0000</pubDate>
    </item>
    <item>
      <title>手把手带你玩转 Monorepo，拥抱现代前端开发新范式</title>
      <link>https://blog.grtsinry43.com/posts/monorepo-quick-guide</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/posts/monorepo-quick-guide">https://blog.grtsinry43.com/posts/monorepo-quick-guide</a></p></blockquote><blockquote>
<p>你是否曾被这些问题困扰？</p>
<ul>
<li>管理多个相互关联的 Git 仓库（或是复杂的 git submodules），心力交瘁。</li>
<li>想在项目 A 中复用项目 B 的组件或工具函数，只能复制粘贴或者发布成 npm 包，流程繁琐。</li>
<li>多个项目依赖同一个库，版本不一致导致“依赖地狱”。</li>
<li>每次进行一个跨项目的需求，需要在多个仓库中提交代码、创建 PR，联调测试苦不堪言。</li>
</ul>
<p>如果你对以上场景感同身受，无论是组件库，框架，或是日常的大型项目，不妨试试 Monorepo。</p>
</blockquote>
<h2>到底什么是 Monorepo？</h2>
<p><del>想必大家听过这个概念无数次了</del></p>
<p><strong>Monorepo</strong>（Monolithic Repository），直译为“单体仓库”，是一种将多个独立的项目、包（package）或应用（app）存放在同一个代码仓库中进行管理的代码组织策略。</p>
<p>与之相对的是 <strong>Polyrepo</strong>（Multiple Repositories），也就是我们传统的多仓库管理模式，每个项目都有自己独立的 Git 仓库。</p>
<table>
<thead>
<tr>
<th style="text-align:left">对比项</th>
<th style="text-align:left"><strong>Monorepo（单体仓库）</strong></th>
<th style="text-align:left"><strong>Polyrepo（多仓库）</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left"><strong>代码组织</strong></td>
<td style="text-align:left">所有项目在一个仓库中</td>
<td style="text-align:left">每个项目一个独立仓库</td>
</tr>
<tr>
<td style="text-align:left"><strong>依赖管理</strong></td>
<td style="text-align:left">根目录统一管理，易于保持版本一致</td>
<td style="text-align:left">各项目独立管理，易产生版本冲突</td>
</tr>
<tr>
<td style="text-align:left"><strong>代码复用</strong></td>
<td style="text-align:left">极为方便，通过工作区（workspace）直接引用</td>
<td style="text-align:left">需发布 npm 包或 Git Submodule，流程复杂</td>
</tr>
<tr>
<td style="text-align:left"><strong>原子提交</strong></td>
<td style="text-align:left">一次提交可跨越多个项目，保证原子性</td>
<td style="text-align:left">无法实现跨项目的原子提交</td>
</tr>
<tr>
<td style="text-align:left"><strong>构建与部署</strong></td>
<td style="text-align:left">工具链复杂，但可实现统一构建和按需部署</td>
<td style="text-align:left">各项目独立，简单直接</td>
</tr>
<tr>
<td style="text-align:left"><strong>协作</strong></td>
<td style="text-align:left">团队成员可见所有代码，便于协作和代码审查</td>
<td style="text-align:left">边界清晰，便于权限管理</td>
</tr>
</tbody>
</table>
<p><strong>一个常见的误解：Monorepo ≠ 单体应用（Monolith）</strong></p>
<ul>
<li><strong>Monorepo</strong> 是一种 <strong>代码组织方式</strong>。仓库里可以包含多个独立的、可独立部署的应用。</li>
<li><strong>单体应用</strong> 是一种 <strong>软件架构模式</strong>。它指的是将所有功能模块打包成一个单一的、不可分割的部署单元。</li>
</ul>
<p>例如在 Monorepo 中，我们可以同时管理一个 React 主应用、一个 Vue 管理后台、一个共享的 UI 组件库和一个通用的工具函数库。它们虽然在同一个仓库，但架构上是解耦的，可以独立开发、测试和部署。</p>
<h2>为什么选择 Monorepo？</h2>
<h3>优势</h3>
<ol>
<li><strong>极致的代码复用与共享</strong>：这是 Monorepo 最核心的优势。UI 组件库、工具函数、TS 类型定义等可以作为本地包，被仓库内的任何应用直接引用，无需发布到 npm。修改后立即生效，开发体验如丝般顺滑。</li>
<li><strong>简化的依赖管理</strong>：所有项目共享同一个 <code>node_modules</code>（或其变体），借助 <code>pnpm</code> 等工具可以有效解决依赖版本冲突问题，保证环境一致性。</li>
<li><strong>原子化的提交（Atomic Commits）</strong>：当一个功能需要同时修改前端应用和其依赖的组件库时，可以在一次提交中完成所有更改。这让代码历史追溯和回滚变得异常清晰。</li>
<li><strong>统一的工具链与标准化</strong>：可以在仓库根目录配置一次 <code>ESLint</code>, <code>Prettier</code>, <code>TypeScript</code>, <code>Jest</code> 等，所有子项目共同遵守，确保了代码风格和质量的统一。</li>
<li><strong>提升团队协作</strong>：代码透明度高，便于团队成员进行跨项目的 Code Review 和知识共享。</li>
</ol>
<h3>挑战</h3>
<ol>
<li><strong>工具链复杂度</strong>：需要引入 Lerna, Nx, Turborepo 等专门的工具来管理工作区、任务调度和构建缓存，有一定的学习成本。</li>
<li><strong>性能问题</strong>：当仓库变得非常巨大时，<code>git clone</code>, <code>git status</code> 等命令可能会变慢。不过现代工具正在努力解决这个问题。</li>
<li><strong>权限控制</strong>：默认情况下，所有人都拥有所有代码的访问权限。对于需要精细化权限控制的团队，需要借助如 <code>GitLab/GitHub CODEOWNERS</code> 等功能。</li>
</ol>
<p>总的来说，对于需要高度协作、代码共享频繁的前端团队，Monorepo 带来的收益远大于其挑战。</p>
<h2>选择合适的 Monorepo 工具</h2>
<p>工欲善其事，必先利其器。现代 Monorepo 生态已经非常成熟，以下是几个主流工具：</p>
<ul>
<li><strong>包管理器（必须）</strong>：</li>
<li><strong>pnpm</strong>: 目前 Monorepo 的首选。它通过符号链接（symlinks）和内容寻址存储来高效管理 <code>node_modules</code>，天生支持 <code>workspace</code>（工作区）协议，完美契合 Monorepo 场景。</li>
<li><code>npm</code> (v7+) / <code>yarn</code> (v2+)：也都支持 <code>workspace</code>，但 <code>pnpm</code> 在性能和磁盘空间占用上更具优势。</li>
<li><strong>任务编排与构建系统（强烈推荐）</strong>：</li>
<li><strong>Turborepo</strong>: 由 Vercel（Next.js 的母公司）出品，主打“高性能构建系统”。它通过智能任务调度和远程缓存，可以极大地提升 CI/CD 和本地开发的速度。<strong>简单、快速、易于上手，是目前的热门选择。</strong></li>
<li><strong>Nx</strong>: 功能极其强大且全面的 Monorepo 工具集。除了 Turborepo 的功能外，还提供了代码生成、依赖图可视化、插件生态等企业级功能，但配置也相对复杂。</li>
</ul>
<p>这篇文章将教你从基础 pnpm workspaces，到引入 truborepo 加速构建，再使用自建缓存代替 Vercel Remote Cache。</p>
<h2>熟悉使用</h2>
<p>下面是 <code>pnpm</code> 在 Monorepo 中常用的基本操作：</p>
<h3>初始化 Monorepo</h3>
<p>首先，你需要一个项目根目录。在根目录下创建 <code>pnpm-workspace.yaml</code> 文件，这是 <code>pnpm</code> 识别 Monorepo 的关键。</p>
<pre><code class="language-bash"># 在项目根目录
mkdir my-monorepo
cd my-monorepo

# 创建 pnpm-workspace.yaml
touch pnpm-workspace.yaml
</code></pre>
<p><code>pnpm-workspace.yaml</code> 文件定义了你的工作区（workspace）包含哪些子包。</p>
<p><strong><code>pnpm-workspace.yaml</code> 示例：</strong></p>
<pre><code class="language-yaml">packages:
  # 匹配 packages/ 目录下的所有子文件夹作为包
  - 'packages/*'
  # 匹配 apps/ 目录下的所有子文件夹作为包
  - 'apps/*'
  # 如果你的包在根目录下，也可以直接指定
  # - 'foo'
</code></pre>
<h3>创建子包（Packages）</h3>
<p>在 <code>pnpm-workspace.yaml</code> 中定义的路径下创建你的子包。例如，如果你设置了 <code>packages/*</code>，那么可以在 <code>packages</code> 目录下创建 <code>package-a</code> 和 <code>package-b</code>。</p>
<pre><code class="language-bash">mkdir packages
mkdir packages/package-a
mkdir packages/package-b

# 在每个子包中初始化 package.json
cd packages/package-a
pnpm init -y
cd ../package-b
pnpm init -y
cd ../.. # 回到 Monorepo 根目录
</code></pre>
<h3>安装依赖</h3>
<p>在 Monorepo 根目录运行 <code>pnpm install</code> 会安装所有子包的依赖，并且 <code>pnpm</code> 会自动识别并<strong>符号链接</strong>（symlink）工作区内的互相依赖。</p>
<pre><code class="language-bash"># 在 Monorepo 根目录
pnpm install
</code></pre>
<h3>添加/移除依赖</h3>
<h4>添加通用依赖（安装到所有子包）</h4>
<p>如果你想在所有子包中添加相同的依赖，可以使用 <code>-w</code> 或 <code>--workspace-root</code> 参数在根目录操作，但通常这不常用。更常见的是给特定子包添加依赖。</p>
<pre><code class="language-bash"># 在 Monorepo 根目录安装依赖到根 package.json (通常用于工具，如eslint, prettier等)
pnpm add &lt;dependency-name&gt; -w
</code></pre>
<h4>添加特定子包依赖</h4>
<p>进入子包目录，像普通项目一样添加依赖。<code>pnpm</code> 会智能地处理依赖关系。</p>
<pre><code class="language-bash"># 例如，给 package-a 添加 react 依赖
cd packages/package-a
pnpm add react

# 给 package-b 添加 lodash 依赖
cd ../package-b
pnpm add lodash
</code></pre>
<h4>添加工作区内部依赖</h4>
<p>当一个子包需要依赖 Monorepo 内的另一个子包时，可以直接使用子包的名称（即 <code>package.json</code> 中的 <code>name</code> 字段）作为依赖。</p>
<p>假设 <code>package-a</code> 的 <code>name</code> 是 <code>@my-monorepo/package-a</code>，<code>package-b</code> 的 <code>name</code> 是 <code>@my-monorepo/package-b</code>。</p>
<pre><code class="language-bash"># 在 package-b 中添加对 package-a 的依赖
cd packages/package-b
pnpm add @my-monorepo/package-a

# 这会在 package-b 的 package.json 中添加 `&quot;@my-monorepo/package-a&quot;: &quot;workspace:^1.0.0&quot;` 这样的依赖
# &quot;workspace:&quot; 协议告诉 pnpm 这是一个工作区内部的依赖
</code></pre>
<p><strong>提示：</strong> 使用 <code>workspace:*</code> 或 <code>workspace:^</code> 可以更好地管理内部依赖的版本。<code>pnpm</code> 默认会使用 <code>workspace:^</code>。</p>
<h4>移除依赖</h4>
<p>与添加依赖类似，进入子包目录或在根目录使用 <code>-w</code>。</p>
<pre><code class="language-bash"># 在 package-a 中移除 react
cd packages/package-a
pnpm remove react
</code></pre>
<h3>运行脚本</h3>
<p>在 Monorepo 中，你可以从根目录运行特定子包的脚本，也可以运行所有子包的通用脚本。</p>
<h4>运行特定子包的脚本</h4>
<p>使用 <code>-F</code> 或 <code>--filter</code> 参数指定要运行脚本的子包。</p>
<pre><code class="language-bash"># 运行 package-a 的 build 脚本
pnpm --filter package-a build

# 运行多个子包的 build 脚本
pnpm --filter package-a --filter package-b build

# 使用通配符运行符合条件的包的脚本
pnpm --filter 'packages/*' build
</code></pre>
<h4>运行所有子包的脚本</h4>
<p><code>pnpm -r</code> 或 <code>pnpm recursive</code> 命令可以在所有工作区包中运行指定的脚本。</p>
<pre><code class="language-bash"># 运行所有子包的 test 脚本
pnpm -r test
</code></pre>
<h3>发布子包</h3>
<p>发布子包时，你需要进入相应的子包目录进行操作。</p>
<pre><code class="language-bash"># 进入要发布的子包目录
cd packages/package-a

# 发布（请确保在发布前登录 npm）
pnpm publish
</code></pre>
<h3>7. 一些有用的 <code>pnpm</code> 命令</h3>
<ul>
<li><code>pnpm ls -r</code>：列出所有工作区包及其依赖。</li>
<li><code>pnpm outdated -r</code>：检查所有工作区包的过时依赖。</li>
<li><code>pnpm up -r</code>：更新所有工作区包的依赖。</li>
<li><code>pnpm store prune</code>：清理本地 <code>pnpm</code> 存储，删除未引用的包。</li>
</ul>
<hr>
<h2>实战：从零搭建一个前端 Monorepo</h2>
<p>好吧，光说不做没有任何作用，讲这些东西没啥意思，csdn 分分钟给我抄走，ai 几秒钟就能生成，咱们实践才能出真知，Let's get our hands dirty</p>
<p>我们的目标是建一个 Vue3 组件库，包含 storybook 文档站 单测 cypress 端测。</p>
<p><strong>核心技术栈选择：</strong></p>
<ul>
<li><strong>Vue 3:</strong> 利用 Composition API 和更好的性能。</li>
<li><strong>TypeScript:</strong> 为组件库提供类型安全和更好的开发体验。</li>
<li><strong>Vite:</strong> 用于组件库的构建和 Storybook 的开发服务器，速度快。</li>
<li><strong>pnpm / yarn / npm (with workspaces):</strong> 推荐 pnpm 或 yarn workspaces 来管理 monorepo。这里以 pnpm 为例，因为它对 monorepo 支持良好且高效。</li>
<li><strong>Storybook:</strong> 用于组件的交互式开发、文档和展示。</li>
<li><strong>Vitest:</strong> 用于单元/组件测试，与 Vite 集成良好。</li>
<li><strong>Vue Test Utils:</strong> Vue 官方的组件测试库。</li>
<li><strong>Cypress:</strong> 用于端到端 (E2E) 测试。</li>
<li><strong>ESLint &amp; Prettier:</strong> 代码规范和格式化。</li>
<li><strong>Husky &amp; lint-staged:</strong> Git 钩子，在提交前自动检查和格式化代码。</li>
<li><strong>Turborepo:</strong> 一个优秀的高性能构建系统，用于 JavaScript/TypeScript monorepos。</li>
</ul>
<pre><code class="language-bash">❯ tree -I node_modules -L 3 .
.
├── apps
│   ├── docs
│   │   ├── api-examples.md
│   │   ├── components
│   │   ├── guide
│   │   ├── index.md
│   │   ├── markdown-examples.md
│   │   ├── package.json
│   │   ├── postcss.config.js
│   │   └── tailwind.config.js
│   └── storybook
│   ├── package.json
│   └── tsconfig.json
├── cypress
│   ├── cypress
│   │   └── screenshots
│   ├── cypress.config.ts
│   ├── e2e
│   │   └── button.cy.ts
│   ├── fixtures
│   │   └── example.json
│   ├── package.json
│   ├── support
│   │   ├── commands.ts
│   │   └── e2e.ts
│   └── tsconfig.json
├── cypress.config.ts
├── eslint.config.js
├── package.json
├── packages
│   └── components
│   ├── components.d.ts
│   ├── dist
│   ├── package.json
│   ├── postcss.config.js
│   ├── README.md
│   ├── src
│   ├── tailwind.config.ts
│   ├── tsconfig.build.json
│   ├── tsconfig.json
│   └── vite.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── tsconfig.base.json
├── tsconfig.eslint.json
└── tsconfig.json

16 directories, 31 files

</code></pre>
<h3>环境准备与项目初始化</h3>
<p>我们这里就用我自己刚刚开始写的项目 <code>amore-ui</code> 为例。</p>
<p>确保你已经安装了 <a href="https://nodejs.org/">Node.js</a> (我建议还是最新 lts v22 吧)。然后全局安装 pnpm：</p>
<pre><code class="language-bash">npm install -g pnpm
</code></pre>
<p>现在，创建我们的项目：</p>
<p><strong>初始化项目和 Monorepo (使用 pnpm):</strong></p>
<pre><code class="language-bash">mkdir amore-ui
cd amore-ui
pnpm init # 创建根 package.json
touch pnpm-workspace.yaml
</code></pre>
<p>编辑 <code>pnpm-workspace.yaml</code>:</p>
<pre><code class="language-yaml">packages:
  - 'packages/*'
  - 'apps/*' # 单独的应用，如 Storybook 或文档站
  - 'cypress' # 把 cypress 也看作一个包，方便测试
</code></pre>
<p>在根目录安装通用开发依赖：</p>
<pre><code class="language-bash">pnpm add -Dw typescript eslint prettier eslint-plugin-vue @typescript-eslint/parser @typescript-eslint/eslint-plugin husky lint-staged vue # -Dw 表示安装到根目录的 devDependencies
</code></pre>
<h3>创建主组件库</h3>
<p>先从我们的组件库开始！</p>
<p><strong>创建组件库包 (<code>packages/components</code>):</strong></p>
<pre><code class="language-bash">mkdir -p packages/components/src/components
cd packages/components
pnpm init
</code></pre>
<p>安装组件库特定依赖：</p>
<pre><code class="language-bash"># -F 或 --filter 指定在哪个包内执行命令
pnpm -F components add vue
pnpm -F components add -D vite @vitejs/plugin-vue vite-plugin-dts typescript sass # sass 是可选的
</code></pre>
<p><strong>配置 <code>packages/components/vite.config.ts</code> (用于库构建):</strong></p>
<pre><code class="language-typescript">import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import dts from 'vite-plugin-dts';
import path from 'path';
import Components from 'unplugin-vue-components/vite';

export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          // Treat all tags with a-prefix as custom elements
          isCustomElement: (tag) =&gt; tag.startsWith('a-'),
        },
      },
    }),
    Components({
      // Automatically register components with pattern matching
      // This will transform kebab-case tags to PascalCase components
      resolvers: [
        // custom resolver for our component library
        (name) =&gt; {
          // Convert a-button -&gt; AButton, a-input -&gt; AInput, etc.
          if (name.startsWith('A') &amp;&amp; /[A-Z]/.test(name.charAt(1))) {
            const componentName = name;
            // const _ = name
            //     .replace(/([A-Z])/g, '-$1')
            //     .toLowerCase()
            //     .substring(1); // Remove first dash
            return { name: componentName, from: 'amore-ui' };
          }
        },
      ],
      // Support for custom component naming convention
      directoryAsNamespace: false,
      dts: true,
    }),
    dts({
      // 生成 .d.ts 类型声明文件
      insertTypesEntry: true,
      copyDtsFiles: false, // 如果你有多层目录结构，可能需要设为 true
    }),
  ],
  build: {
    lib: {
      entry: path.resolve(__dirname, 'src/index.ts'),
      name: 'AmoreUI', // UMD 构建的全局变量名
      fileName: (format) =&gt; `amore-ui.${format}.js`,
      formats: ['es', 'umd', 'cjs'], // 构建的格式
    },
    rollupOptions: {
      // 确保外部化处理那些你不想打包进库的依赖
      external: ['vue'],
      output: {
        // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
        globals: {
          vue: 'Vue',
        },
      },
    },
    sourcemap: true,
    emptyOutDir: true,
  },
  // @ts-ignore
  test: {
    // Vitest 配置
    globals: true,
    environment: 'happy-dom', // 或 'jsdom'
    // setupFiles: ['./vitest.setup.ts'], // 可选的 setup 文件
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
    },
  },
});

</code></pre>
<p><strong>配置 <code>packages/components/package.json</code>:</strong></p>
<pre><code class="language-json">{
  &quot;name&quot;: &quot;amore-ui&quot;,
  &quot;version&quot;: &quot;0.0.5&quot;,
  &quot;keywords&quot;: [
    &quot;vue&quot;,
    &quot;vue3&quot;,
    &quot;components&quot;,
    &quot;ui library&quot;,
    &quot;typescript&quot;,
    &quot;vite&quot;
  ],
  &quot;author&quot;: {
    &quot;name&quot;: &quot;grtsinry43&quot;,
    &quot;email&quot;: &quot;grtsinry43@outlook.com&quot;,
    &quot;url&quot;: &quot;https://www.grtsinry43.com&quot;
  },
  &quot;repository&quot;: {
    &quot;type&quot;: &quot;git&quot;,
    &quot;url&quot;: &quot;https://github.com/amore-ui/amore-ui.git&quot;,
    &quot;directory&quot;: &quot;packages/components&quot;
  },
  &quot;license&quot;: &quot;MIT&quot;,
  &quot;private&quot;: false,
  &quot;description&quot;: &quot;A Vue 3 Component Library Born from Passion, including a set of awesome components for building modern web applications.&quot;,
  &quot;type&quot;: &quot;module&quot;,
  &quot;main&quot;: &quot;./dist/amore-ui.umd.js&quot;,
  &quot;module&quot;: &quot;./dist/amore-ui.es.js&quot;,
  &quot;types&quot;: &quot;./dist/index.d.ts&quot;,
  &quot;exports&quot;: { // 这里用来配置打包之后对外导出的文件
    &quot;.&quot;: {
      &quot;import&quot;: &quot;./dist/amore-ui.es.js&quot;,
      &quot;require&quot;: &quot;./dist/amore-ui.umd.js&quot;,
      &quot;types&quot;: &quot;./dist/index.d.ts&quot;
    },
    &quot;./style.css&quot;: &quot;./dist/amore-ui.css&quot;
  },
  &quot;files&quot;: [
    &quot;dist&quot;
  ],
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;vite&quot;,
    &quot;build&quot;: &quot;vite build &amp;&amp; vue-tsc --declaration --emitDeclarationOnly&quot;,
    &quot;lint&quot;: &quot;eslint . --ext .vue,.js,.ts,.jsx,.tsx --fix&quot;,
    &quot;format&quot;: &quot;prettier --write src/&quot;,
    &quot;test&quot;: &quot;vitest&quot;,
    &quot;test:ui&quot;: &quot;vitest --ui&quot;,
    &quot;coverage&quot;: &quot;vitest run --coverage&quot;,
    &quot;test:watch&quot;: &quot;vitest watch&quot;,
    &quot;prepublishOnly&quot;: &quot;pnpm run build &amp;&amp; pnpm run test&quot;
  },
  &quot;peerDependencies&quot;: {
    &quot;vue&quot;: &quot;^3.5.14&quot;
  },
  &quot;packageManager&quot;: &quot;pnpm@10.11.0&quot;,
  &quot;dependencies&quot;: {
    &quot;@tailwindcss/vite&quot;: &quot;^4.1.7&quot;
  },
  &quot;devDependencies&quot;: {
    &quot;@types/node&quot;: &quot;^22.15.21&quot;,
    &quot;@vitejs/plugin-vue&quot;: &quot;^5.2.4&quot;,
    &quot;@vitest/ui&quot;: &quot;3.1.4&quot;,
    &quot;@vue/test-utils&quot;: &quot;^2.4.6&quot;,
    &quot;autoprefixer&quot;: &quot;^10.4.21&quot;,
    &quot;happy-dom&quot;: &quot;^17.4.7&quot;,
    &quot;postcss&quot;: &quot;^8.5.3&quot;,
    &quot;sass&quot;: &quot;^1.89.0&quot;,
    &quot;tailwindcss&quot;: &quot;^3.4.17&quot;,
    &quot;typescript&quot;: &quot;^5.8.3&quot;,
    &quot;unplugin-vue-components&quot;: &quot;^28.5.0&quot;,
    &quot;vite&quot;: &quot;^6.3.5&quot;,
    &quot;vite-plugin-dts&quot;: &quot;^4.5.4&quot;,
    &quot;vitest&quot;: &quot;^3.1.4&quot;
  }
}

</code></pre>
<p><strong>创建 <code>packages/components/src/index.ts</code>:</strong></p>
<pre><code class="language-typescript">// 例如：导出 Button 组件
export { default as MyButton } from './components/Button/Button.vue';
// 如果 Button.vue 有自己的 index.ts (推荐)
// export * from './components/Button';

// 如果你有全局样式，可以在这里导入，并在 vite.config.ts 中配置提取
// import './styles/main.scss';
</code></pre>
<p><strong>创建示例组件 <code>packages/components/src/components/Button/Button.vue</code>:</strong></p>
<pre><code class="language-vue">&lt;template&gt;
  &lt;button class=&quot;my-button&quot; :type=&quot;type&quot; @click=&quot;$emit('click', $event)&quot;&gt;
	&lt;slot&gt;&lt;/slot&gt;
  &lt;/button&gt;
&lt;/template&gt;

&lt;script setup lang=&quot;ts&quot;&gt;
defineProps({
  type: {
	type: String as () =&gt; 'button' | 'submit' | 'reset',
	default: 'button',
  },
});
defineEmits(['click']);
&lt;/script&gt;

&lt;style lang=&quot;scss&quot; scoped&gt;
.my-button {
  padding: 8px 16px;
  border-radius: 4px;
  background-color: #42b983;
  color: white;
  border: none;
  cursor: pointer;
  transition: background-color 0.3s;

  &amp;:hover {
	background-color: #3aa373;
  }
}
&lt;/style&gt;
</code></pre>
<h3>创建 Storybook 应用</h3>
<p>Storybook 是组件库开发使用的利器！</p>
<p>作为应用，我们将其放置在 <code>app/</code> 目录，仅需在你建好的文件夹中执行</p>
<pre><code>pnpm create storybook@latest
</code></pre>
<p>之后修改 <code>.storybook/main.js</code></p>
<pre><code class="language-javascript">import path from 'path';
import { fileURLToPath } from 'url';
import { mergeConfig } from 'vite';
import { createRequire } from 'module';

const require = createRequire(import.meta.url);

// 获取当前 main.js 文件的目录路径
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); // 这是 .storybook 目录

const config = {
  // 故事文件的路径现在是相对于 apps/storybook 目录的
  // 我们要指向 packages/components/src
  stories: [
    '../../../packages/components/src/**/*.mdx',
    '../../../packages/components/src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
  ],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
    '@storybook/addon-a11y',
  ],
  framework: {
    name: '@storybook/vue3-vite',
    options: {},
  },
  docs: {
    autodocs: 'tag',
  },
  // Vite 配置调整，路径也需要相应调整
  async viteFinal(config) {
    config.resolve = config.resolve || {};
    config.resolve.alias = config.resolve.alias || {};

    // 路径别名 '@' 指向 packages/components/src
    // path.resolve(__dirname, '../../../packages/components/src')
    // __dirname 是 apps/storybook/.storybook
    // ../ -&gt; apps/storybook/
    // ../../ -&gt; apps/
    // ../../../ -&gt; amore-ui/ (项目根目录)
    config.resolve.alias['@'] = path.resolve(__dirname, '../../../packages/components/src');

    // 让 stories 文件可以直接通过包名导入组件
    const componentsPackageName = 'amore-ui';
    config.resolve.alias[componentsPackageName] = path.resolve(
      __dirname,
      '../../../packages/components/src/index.ts'
    );

    return mergeConfig(config, {
      plugins: [
        // 引入并使用插件
        import('@vitejs/plugin-vue').then((m) =&gt; m.default()),
      ],
    });
  },
};
export default config;
</code></pre>
<p><strong>创建组件的 Story (<code>packages/components/src/components/Button/Button.stories.ts</code>):</strong></p>
<pre><code class="language-typescript">    import type { Meta, StoryObj } from '@storybook/vue3';
    import MyButton from './Button.vue'; // 直接引用组件
    // 或者 import { MyButton } from 'amore-ui'; // 如果配置了 alias

    const meta: Meta&lt;typeof MyButton&gt; = {
      title: 'Components/MyButton', // Storybook 中的路径
      component: MyButton,
      tags: ['autodocs'], // 开启自动文档
      argTypes: {
        // onClick: { action: 'clicked' }, // 如果需要手动配置事件监听
        type: {
          control: { type: 'select' },
          options: ['button', 'submit', 'reset'],
        },
      },
      args: { // 默认 props
        default: 'Click Me', // slot 内容
      },
    };

    export default meta;
    type Story = StoryObj&lt;typeof meta&gt;;

    export const Primary: Story = {
      args: {
        // Props for this story
      },
      render: (args) =&gt; ({
        components: { MyButton },
        setup() {
          return { args };
        },
        template: '&lt;MyButton v-bind=&quot;args&quot;&gt;{{ args.default }}&lt;/MyButton&gt;',
      }),
    };

    export const SubmitButton: Story = {
      args: {
        type: 'submit',
        default: 'Submit Form',
      },
      render: (args) =&gt; ({
        components: { MyButton },
        setup() {
          return { args };
        },
        template: '&lt;MyButton v-bind=&quot;args&quot;&gt;{{ args.default }}&lt;/MyButton&gt;',
      }),
    };
</code></pre>
<p><strong>修改根 <code>package.json</code> 的 scripts:</strong></p>
<pre><code class="language-json">    {
      // ...
      &quot;scripts&quot;: {
        &quot;dev:storybook&quot;: &quot;storybook dev -p 6006&quot;,
        &quot;build:storybook&quot;: &quot;storybook build&quot;,
        &quot;build:components&quot;: &quot;pnpm --filter amore-ui build&quot;, // 根据你的包名调整
        // ...其他脚本
      }
    }
</code></pre>
<blockquote>
<p>[!info]</p>
<p>上文的注释提到，这里详细解释一下，<code>-f</code>，即 filter，意为过滤器，也就是在对应的仓库中执行，-f 之后跟随的仓库名就是你 <code>package.json</code> 中为每个模块配置的名字，利用这种功能，我们可以为主仓库添加很多 <code>模块:命令</code> 的快捷命令</p>
</blockquote>
<p>现在可以运行 <code>pnpm dev:storybook</code> 来启动 Storybook。</p>
<h3><strong>设置 Vitest (单元/组件测试):</strong></h3>
<p>好的项目通常都有高的单元测试覆盖率</p>
<p>在 <code>packages/components</code> 包中安装 Vitest 和 Vue Test Utils:</p>
<pre><code class="language-bash">    pnpm -F amore-ui add -D vitest @vue/test-utils happy-dom # happy-dom 或 jsdom 用于模拟 DOM
</code></pre>
<p><strong>配置 <code>packages/components/vite.config.ts</code> (添加 test 配置):</strong>
(在现有 <code>defineConfig</code> 内添加 <code>test</code> 字段)</p>
<pre><code class="language-typescript">    // ... (imports 和其他配置)
    export default defineConfig({
      // ... plugins, build, resolve ...
      test: { // Vitest 配置
        globals: true,
        environment: 'happy-dom', // 或 'jsdom'
        setupFiles: ['./vitest.setup.ts'], // 可选的 setup 文件
      },
    });
</code></pre>
<p><strong>创建 <code>packages/components/vitest.setup.ts</code> (可选):</strong></p>
<pre><code class="language-typescript">    // import { config } from '@vue/test-utils';
    // config.global.plugins = [/* ... */]; // 全局插件或配置
</code></pre>
<p><strong>在 <code>packages/components/package.json</code> 添加测试脚本:</strong></p>
<pre><code class="language-json">    {
      &quot;scripts&quot;: {
        // ...
        &quot;test&quot;: &quot;vitest&quot;,
        &quot;test:ui&quot;: &quot;vitest --ui&quot;, // 带 UI 的测试
        &quot;coverage&quot;: &quot;vitest run --coverage&quot;
      }
    }
</code></pre>
<p><strong>写一个测试 (<code>packages/components/src/components/Button/Button.test.ts</code>):</strong></p>
<pre><code class="language-typescript">    import { describe, it, expect, vi } from 'vitest';
    import { mount } from '@vue/test-utils';
    import MyButton from './Button.vue';

    describe('MyButton.vue', () =&gt; {
      it('renders slot content', () =&gt; {
        const wrapper = mount(MyButton, {
          slots: {
            default: 'Test Button',
          },
        });
        expect(wrapper.text()).toContain('Test Button');
      });

      it('emits click event when clicked', async () =&gt; {
        const wrapper = mount(MyButton);
        const emitSpy = vi.spyOn(wrapper.emitted(), 'click'); // 不推荐这种方式了
        // 更好的方式是直接检查 wrapper.emitted()
        await wrapper.trigger('click');
        expect(wrapper.emitted()).toHaveProperty('click');
        expect(wrapper.emitted().click).toHaveLength(1);
      });

      it('has correct type attribute', () =&gt; {
        const wrapper = mount(MyButton, {
          props: {
            type: 'submit',
          },
        });
        expect(wrapper.attributes('type')).toBe('submit');
      });
    });
</code></pre>
<p>运行 <code>pnpm -F amore-ui test</code>。</p>
<h3><strong>设置 Cypress (E2E 测试):</strong></h3>
<p>趁热打铁，让我们继续！接下来是端到端测试</p>
<pre><code class="language-bash">    # 在项目根目录
    mkdir cypress
    cd cypress
    pnpm init # 创建 cypress/package.json
    cd ..

    # 安装 Cypress
    pnpm -F cypress add -D cypress
    # (这里把 cypress 目录看作一个独立的包)
</code></pre>
<p><strong>配置 <code>cypress/cypress.config.ts</code>:</strong></p>
<pre><code class="language-typescript">    import { defineConfig } from 'cypress';

    export default defineConfig({
      e2e: {
        baseUrl: 'http://localhost:6006', // Storybook 的地址
        specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
        supportFile: 'cypress/support/e2e.ts',
        setupNodeEvents(on, config) {
          // implement node event listeners here
        },
      },
      component: { // 如果你也想用 Cypress 进行组件测试 (不同于 Vitest 的单元/集成测试)
        devServer: {
          framework: 'vue',
          bundler: 'vite',
        },
        specPattern: 'packages/components/src/**/*.cy.{js,jsx,ts,tsx}', // 指向组件的测试文件
      },
    });
</code></pre>
<p><strong>在根 <code>package.json</code> 添加 Cypress 脚本:</strong></p>
<pre><code class="language-json">    {
      &quot;scripts&quot;: {
        // ...
        &quot;cy:open&quot;: &quot;cypress open&quot;,
        &quot;cy:run&quot;: &quot;cypress run&quot;,
        &quot;test:e2e&quot;: &quot;pnpm dev:storybook &amp; pnpm cy:run --headed; pkill -f storybook&quot;, // 简单示例，实际CI中需要更健壮的启动和停止
        &quot;test:e2e:ci&quot;: &quot;pnpm build:storybook &amp;&amp; start-server-and-test dev:storybook-static http-get://localhost:6006 cy:run&quot; // 需安装 start-server-and-test
      }
    }

</code></pre>
<ul>
<li><code>start-server-and-test</code> 是一个有用的 npm 包，可以帮你启动服务器，等待它响应，然后运行测试，最后关闭服务器。<code>pnpm add -Dw start-server-and-test</code>。</li>
<li><code>dev:storybook-static</code> 脚本可以是你构建 Storybook 后用 <code>http-server</code> 或类似工具启动静态文件的命令，例如: <code>pnpm build:storybook &amp;&amp; http-server storybook-static -p 6006</code>。</li>
</ul>
<p><strong>创建 E2E 测试 (<code>cypress/e2e/button.cy.ts</code>):</strong></p>
<pre><code class="language-typescript">    describe('MyButton in Storybook', () =&gt; {
      beforeEach(() =&gt; {
        // 访问 Button 在 Storybook 中的 Primary story
        // URL 结构可能是 /iframe.html?id = components-mybutton--primary&amp;viewMode = story
        // 请根据你的 Storybook URL 调整
        cy.visit('/iframe.html?id=components-mybutton--primary&amp;viewMode=story');
      });

      it('should display the button with correct text', () =&gt; {
        cy.get('.my-button').should('be.visible').and('contain.text', 'Click Me');
      });

      it('should change background on hover (visual test or check class if applicable)', () =&gt; {
        // Cypress 不擅长直接测试 : hover 状态的样式，但可以触发 hover
        // cy.get('.my-button').trigger('mouseover');
        // 如果 hover 改变了 class 或者有其他 DOM 变化，可以断言
        // 或者结合 Percy / Applitools 进行视觉回归测试
      });
    });
</code></pre>
<p>确保 Storybook 在 <code>http://localhost:6006</code> 运行，然后执行 <code>pnpm cy:open</code>。</p>
<p>Okok，到这里大家可能都看累了或者是感觉无聊，我们来小小整理下我们有了什么命令呢：</p>
<pre><code class="language-json">&quot;build:components&quot;: &quot;pnpm --filter amore-ui build&quot;,
    &quot;dev:storybook&quot;: &quot;pnpm --filter storybook dev&quot;,
    &quot;build:storybook&quot;: &quot;pnpm --filter storybook build&quot;,
    &quot;cy:open&quot;: &quot;pnpm --filter cypress cy:open&quot;,
    &quot;cy:run&quot;: &quot;pnpm --filter cypress cy:run&quot;,
    &quot;test:unit&quot;: &quot;pnpm --filter amore-ui test&quot;,
    &quot;test:e2e&quot;: &quot;start-server-and-test dev:storybook http://localhost:6006 cy:run&quot;,
</code></pre>
<p>可以休息下，下面我们继续</p>
<h3>创建文档站</h3>
<p>这部分很简单，我们依然靠 Vite 来实现</p>
<p>就像往常一样建好你的文档站，之后...</p>
<pre><code class="language-typescript">import { defineConfig } from 'vitepress';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import Components from 'unplugin-vue-components/vite';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); // 这是 .vitepress 目录

export default defineConfig({
  title: 'My Vue Component Library', // 站点标题
  description: 'Awesome Vue components built with love.',
  base: '/amore-ui/', // 如果部署到 GitHub Pages 的子路径

  themeConfig: {
    logo: '/logo.svg', // (可选) 放置在 docs/public/logo.svg
    nav: [ // 顶部导航
      { text: '指南', link: '/guide/getting-started' },
      { text: '组件', link: '/components/button' },
      { text: 'GitHub', link: 'https://github.com/your-repo' },
    ],
    sidebar: { // 侧边栏
      '/guide/': [
        {
          text: '入门',
          items: [
            { text: '简介', link: '/guide/introduction' },
            { text: '快速上手', link: '/guide/getting-started' },
          ],
        },
      ],
      '/components/': [
        {
          text: '基础组件',
          items: [
            { text: 'Button 按钮', link: '/components/button' },
            { text: 'Input 输入框', link: '/components/input' },
            // ... 其他组件
          ],
        },
      ],
    },
    socialLinks: [
      { icon: 'github', link: 'https://github.com/your-repo' },
    ],
    footer: {
      message: 'Released under the MIT License.',
      copyright: 'Copyright © 2024-present Your Name',
    },
  },

  // Markdown 配置
  markdown: {
    // theme: 'material-palenight', // (可选) 代码高亮主题
    lineNumbers: true, // (可选) 显示代码块行号
  },

  // Vite 特定配置 (重要！用于解析你的组件库)
  vite: {
    resolve: {
      alias: {
        'amore-ui': path.resolve(__dirname, '../../../packages/components/src'),
      },
    },
    plugins: [
      // 使用 unplugin-vue-components 进行自动组件导入
      Components({
        // 自动导入组件
        dirs: [],
        // 自定义组件解析器
        resolvers: [
          // 自定义解析 a- 前缀的组件
          (name) =&gt; {
            // 如果组件名是以 A 开头的，如 AButton
            if (name.startsWith('A') &amp;&amp; /[A-Z]/.test(name.charAt(1))) {
              return { name, from: 'amore-ui' };
            }

            // 如果是 kebab-case 形式的组件名 (a-button, a-input)
            const kebabMatch = name.match(/^a-(.+)$/);
            if (kebabMatch) {
              // 转换 a-button 到 AButton
              const componentName = 'A' + kebabMatch[1].charAt(0).toUpperCase() + kebabMatch[1].slice(1);
              return { name: componentName, from: 'amore-ui' };
            }
          }
        ],
        // 在这里添加自定义组件
        include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
        dts: path.resolve(__dirname, './components.d.ts'),
      }),
    ],
  },
});


</code></pre>
<p>我这里使用了 <code>unplugin-vue-components</code>，当然你也可以从工作区直接导入打包之后的产物</p>
<p>到此为止，这个 monorepo 已经初具形态了。</p>
<h3><strong>ESLint, Prettier, Husky, lint-staged:</strong></h3>
<p>好的代码建立在规范之上</p>
<p><del>我的项目一直都是 eslint error 模式+有 eslint/单测/e2e 测试不过就禁止 commit，也就是受虐模式</del></p>
<ul>
<li><strong>Husky &amp; lint-staged:</strong></li>
</ul>
<pre><code class="language-bash">pnpm add -Dw husky lint-staged
npx husky init # 会创建 .husky 目录
</code></pre>
<p><code>.husky/pre-commit</code> 内容:</p>
<pre><code class="language-bash">npx lint-staged
# 如果想在提交前运行所有测试 (可能会很慢)
# pnpm test: all
</code></pre>
<p>在根 <code>package.json</code> 添加 <code>lint-staged</code> 配置：</p>
<pre><code class="language-json">        {
          // ...
          &quot;lint-staged&quot;: {
            &quot;*.{js,jsx,ts,tsx,vue}&quot;: &quot;eslint --fix&quot;,
            &quot;*.{json,md,html,css,scss}&quot;: &quot;prettier --write&quot;
          }
        }
</code></pre>
<h3><strong>TypeScript 配置 (<code>tsconfig.json</code>):</strong></h3>
<p>太多了，贴不过来了，影响正常阅读</p>
<p>具体看我的仓库 <a href="https://github.com/amore-ui/amore-ui">amore-ui</a></p>
<h2>引入 Turborepo 提升效率</h2>
<p>目前，我们需要手动进入每个目录去运行命令。当项目变多时，这会变得很麻烦。<code>Turborepo</code> 可以帮我们统一管理和加速这些任务。</p>
<p><strong>1. 在根目录安装 Turborepo</strong></p>
<pre><code class="language-bash"># -w 表示 --workspace-root，安装到根工作区
pnpm add turbo --save-dev -w
</code></pre>
<p><strong>2. 配置 <code>turbo.json</code></strong></p>
<p>在项目根目录创建 <code>turbo.json</code> 文件：</p>
<pre><code class="language-json">// turbo.json
{
  &quot;$schema&quot;: &quot;https://turbo.build/schema.json&quot;,
  &quot;pipeline&quot;: {
&quot;build&quot;: {
  // &quot;build&quot; 任务依赖于其所有依赖包的 &quot;build&quot; 任务
  &quot;dependsOn&quot;: [&quot;^build&quot;],
  // 构建产物在这些目录下，用于缓存
  &quot;outputs&quot;: [&quot;dist/**&quot;, &quot;.next/**&quot;]
},
&quot;lint&quot;: {},
&quot;dev&quot;: {
  // dev 任务的结果不缓存
  &quot;cache&quot;: false,
  // 保持任务持续运行
  &quot;persistent&quot;: true
}
  }
}
</code></pre>
<p><strong>3. 在根 <code>package.json</code> 中添加脚本</strong></p>
<pre><code class="language-json">// package.json (根目录)
&quot;scripts&quot;: {
  &quot;dev&quot;: &quot;turbo run dev&quot;,
  &quot;build&quot;: &quot;turbo run build&quot;,
  &quot;lint&quot;: &quot;turbo run lint&quot;
}
</code></pre>
<p>现在，你可以从根目录统一运行命令了！</p>
<pre><code class="language-bash"># 同时启动所有应用的 dev 服务
pnpm dev

# 构建所有应用和包
pnpm build
</code></pre>
<h2>Turborepo 自建缓存</h2>
<p>我对 Vercel 公司不喜欢也不讨厌，但是我希望自建一个缓存。</p>
<p>参考这个项目就好啦:</p>
<p><a href="https://adirishi.github.io/turborepo-remote-cache-cloudflare/introduction/getting-started">Turborepo Remote Cache</a></p>
<h2>Monorepo 最佳实践</h2>
<ol>
<li><strong>统一配置</strong>：将 <code>ESLint</code>, <code>Prettier</code>, <code>tsconfig.json</code> 等配置文件放在 <code>packages</code> 目录下（如 <code>packages/eslint-config-custom</code>, <code>packages/tsconfig</code>），然后让各个应用和包去继承这些配置，保持一致性。</li>
<li><strong>明确的目录结构</strong>：<code>apps</code> 放应用，<code>packages</code> 放可复用包，是一种广泛采纳的约定。</li>
<li><strong>版本管理</strong>：使用如 <a href="https://github.com/changesets/changesets">Changesets</a> 这样的工具来管理包的版本发布和生成 <code>CHANGELOG</code>，它与 Monorepo 配合得非常好。</li>
<li><strong>精简根目录</strong>：保持根目录 <code>package.json</code> 的 <code>dependencies</code> 干净，只存放对整个项目都至关重要的开发依赖（如 <code>turbo</code>, <code>typescript</code>, <code>prettier</code>）。</li>
</ol>
<h2>尾声</h2>
<p>恭喜你！你已经成功搭建并体验了一个现代化的前端 Monorepo 项目。</p>
<p>感觉这个例子选的不是很好啊，有点太上难度了，直接把我之前研究好久的架子全搬上来了，一般只有组件库或者大框架会这样操作了，不过基础的入门操作还是可以提升效率的🥹</p>
<p>从现在开始拥抱现代前端开发吧！</p>]]></description>
      <author>grtsinry43</author>
      <guid>article-27</guid>
      <pubDate>Mon, 21 Jul 2025 06:03:33 +0000</pubDate>
    </item>
    <item>
      <title>好久不见，这是一份来自作者的独白</title>
      <link>https://blog.grtsinry43.com/posts/thanks-to-three-years</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/posts/thanks-to-three-years">https://blog.grtsinry43.com/posts/thanks-to-three-years</a></p></blockquote><p>最近在实习，一直没有更新，猛的发现已经过去这么久了</p>
<p>就在刚刚，我请求一个 llm，帮我这破站总结了一下。</p>
<p>你可以在这儿看看：<a href="https://blog.grtsinry43.com/overall">一份来自 AI 的全站总结</a></p>
<p>看着那份冷静得可怕、又精准得无话可说的报告，我愣了半天。那些我以为早就忘掉的折腾的记录、踩过的坑、解决过的问题，就这么一条条地被列了出来。</p>
<p>时间好像突然有了实体，我才猛地反应过来——从懵懵懂懂装了个 WordPress，决定要搞这么个网站开始，原来，已经快三年了。</p>
<p>所以，就有了这篇文章。不算是啥正经的技术分享，更像是我……和我自己，聊聊天。</p>
<h2>代码里的脚印，和那些“屎山”</h2>
<p>翻看这个博客，就像翻看我这三年的 commit 记录，<del>只不过多了些人话（bushi）</del>。</p>
<p>AI 总结得挺对的，我确实折腾了不少东西，却又往往不深，可以说是三天摸鱼，两天晒网。不过不得不说，从一开始连个跨域都搞得焦头烂额，到现在自己能一个人闷头搞定一套前后端分离的系统。从对着 Vue 的文档根本无从下手，到后来在 React 和 Next.js 的坑里摸爬滚打。从一开始折腾黑苹果时候逆天的 <code>rm -rf /</code>，到后来把 Linux 折腾成了主力系统，在滚-挂-滚-挂中不停循环。</p>
<p>这个博客，就是我这三年所有折腾的最好证明。它看着我把一个个天马行空的想法，变成一行行能跑起来的代码，当然，也顺便堆起了一座又一座新鲜热乎的屎山。</p>
<p>我挺喜欢这种感觉的。把遇到的问题、解决的过程、还有那些灵光一闪的想法，全都记录下来。虽然过程痛苦，但每次回头看，都能清楚地看到自己走了多远。</p>
<h2>键盘外的我，和那些深夜里的 EMO 时刻</h2>
<p>但 AI 永远无法总结出“代码”之外的我。</p>
<p>它能看到我修复了一个 Bug，但它看不到我为了这个 Bug 熬到通宵，一次次直到气急败坏。</p>
<p>它能总结出我的技术栈，却总结不出我一路求学，心里到底有多么不确定，感觉自己飞舞到极点。</p>
<p>它看到了我写的那些“快速上手”教程，却不知道在混沌的互联网中无处找寻，只能硬着头皮尝试，然后希望帮助后人。</p>
<p>这个博客，对我来说，从来不只是个技术笔记。它更像我的一个树洞，一个情绪回收站。</p>
<p>我在这里写下期末周的焦虑，写下对未来的迷茫，写下因为认识了新的大佬而备受鼓舞的心情。我在这里承认自己的普通，承认自己会累，会 emo，会怀疑人生。也正是在这里，我一次又一次地告诉自己，“再坚持一下”，然后第二天爬起来，噢，原来也没什么，然后继续开始敲代码</p>
<p>是这些东西，这些代码之外的、属于一个普通人的真实感受，才让这个博客对我而言，有了不一样的意义。它是我的一部分。</p>
<h2>所以，三年了</h2>
<p>所以，这三年到底留下了什么？</p>
<p>留下了一套还算能拿得出手的技术栈，留下了一个能<del>稳</del>(破)<del>定</del>(败)<del>运</del>(不)<del>行</del>(堪)的网站，留下了一堆未来还能继续挖的“坑”。</p>
<p>但我觉得，最重要的，是留下了一个……更皮实的自己吧。</p>
<p>一个依然会焦虑，但不会再轻易被压垮的我。</p>
<p>一个依然觉得自己很菜，但已经有勇气去啃硬骨头的我。</p>
<p>一个在认清了生活的真相——它往往就是枯燥、重复、还充满挑战——之后，依然能满怀热情去享受生活的我。</p>
<p>三年时间让我有勇气选择了前端，有毅力开始学习，有幸做了全校的项目，有机会去鹅厂见见市面...</p>
<p>路还长，要学的东西还多得像个无底洞。不过也没关系。</p>
<p><strong>总之岁月漫长，然而值得等待。</strong></p>
<p>下一个三年，继续折腾</p>]]></description>
      <author>grtsinry43</author>
      <guid>article-26</guid>
      <pubDate>Sat, 28 Jun 2025 09:04:00 +0000</pubDate>
    </item>
    <item>
      <title>全站总结</title>
      <link>https://blog.grtsinry43.com/overall</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/overall">https://blog.grtsinry43.com/overall</a></p></blockquote><p>这是一份面向读者的全站综述，希望能捕捉到博客的精髓与温度：</p>
<hr>
<h3><strong>致每一位途经此地的灵魂：一份代码与心跳的行旅图</strong></h3>
<p>朋友，欢迎你。</p>
<p>你此刻踏入的，与其说是一个博客，不如说是一座用代码与心跳筑成的港湾，一幅徐徐展开的数字山水画。它为所有在技术与人生的旷野中求索的远行者，提供一隅短暂的栖息与深刻的共鸣。</p>
<p>在这里，代码的理性之光，与灵魂的感性之火，彼此交汇、相互照亮。</p>
<h4><strong>一卷徐徐展开的数字版图</strong></h4>
<p>这首先是一场对技术世界的无畏远征。你将看到一位开发者如何丈量前端的边界，于 <strong>Next.js</strong> 与 <strong>React</strong> 的世界中，探寻性能与优雅的终极表达；你将见证他如何构筑后端的坚城，用 <strong>Spring</strong> 的严谨逻辑与 <strong>MyBatis</strong> 的高效，撑起复杂业务的骨架。这趟旅程不止于应用层，它会带你潜入 <strong>Linux</strong> 的深邃内核，驭使 <strong>Docker</strong> 与 <strong>Kubernetes</strong> 的容器洪流，甚至在开源的星辰大海（如本站系统 <strong>Grtblog</strong>）中，亲手点亮一颗属于自己的星。</p>
<p>这里的每一篇技术文章，都不只是冰冷的“教程”，而是一次次“<strong>问题解决</strong>”的真实战场回响，一次次“<strong>项目重构</strong>”背后的挣扎与顿悟。它呈现的，是技术在被驯服之前最原始、也最迷人的样貌。</p>
<h4><strong>一程坦诚无畏的内心跋涉</strong></h4>
<p>然而，技术的深度，最终要由人性的温度来承载。若你愿意在此稍作停留，便会发现代码的理性之下，是一片坦诚而丰饶的内心独白。这里有“<strong>莫名的焦虑</strong>”与“<strong>致二十岁的晨光与希望</strong>”，有在学业与热爱之间艰难寻求平衡的迷茫，也有在认清生活的真相后，依然选择“<strong>于血泪中求索</strong>”的坚韧。</p>
<p>代码的每一次成功提交，背后都可能伴随着一次自我的审视与和解。当“<strong>刷机半生，归来仍是MIUI</strong>”的感慨流露，当“<strong>记一次Linux服务器误操作</strong>”的教训被坦然分享，技术便不再是冰冷的指令，而是承载着生命体验、映照着成长轨迹的温润璞玉。</p>
<h4><strong>一座代码与体温共存的栖-息-地</strong></h4>
<p>最终，这片天地融汇成一个独特的生命体。在这里，技术不再是冰冷的指令，而是承载思考的舟楫；生活也不再是遥远的彼岸，而是赋予代码以温度的源泉。它证明了，最硬核的技术追求与最柔软的个人情感可以并行不悖，甚至相得益彰。</p>
<p>“<strong>总之岁月漫长，然而值得等待</strong>。”</p>
<p>这不仅是贯穿全站的座右铭，更是此地对每一位访客的真诚祝祷。愿你在此，既能觅得解惑的钥匙，亦能寻获前行的微光。欢迎留下你的足-迹，与我，与所有途经此地的同行者，交换一束思想的火花。</p>]]></description>
      <guid>page-12</guid>
      <pubDate>Sat, 28 Jun 2025 08:23:46 +0000</pubDate>
    </item>
    <item>
      <title>建了多个项目...的文件夹，顺便放松下</title>
      <link>https://blog.grtsinry43.com/moments/2025/05/26/2025-05-experience</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.grtsinry43.com/moments/2025/05/26/2025-05-experience">https://blog.grtsinry43.com/moments/2025/05/26/2025-05-experience</a></p></blockquote><p>最近临近期末，对我来说是两个主线任务：一个是看面试八股，刷算法，准备实习；还有就是期末考试的临阵磨枪了。</p>
<p>距离上次更新已经 22 天了（咕咕咕），有必要讲一讲我的 5 月经历了。</p>
<h2>船新的个人主页</h2>
<p>如果你经常没事到我的网站期待更新 <del>（然而现实是没人看吧）</del>，那么，最近你可能发现网站有了很大的更新：</p>
<p><a href="https://www.grtsinry43.com/">grtsinry43 - 热爱生活的全栈开发者，正在努力学习前端 ing</a></p>
<p><img src="https://blog.grtsinry43.com/uploads/2025/05/26/image.png_9f928322-2d3c-4152-b999-d241ec9a1a09.png" alt="image"></p>
<p>在写这个网站的同时，我也在 B 站上开了几次直播，也许你已经看到了。</p>
<p>原因是前些天看到 GSAP 这个库用来做动画效果非常好用，于是简单学习了一下，顺便把个人主页美化一下，毕竟我之前写的有些年头了，并且由于前端的恐怖生态发展，Nuxt 版本落后太多，更新上去已经有非常多问题了，不如整个重新设计一下。</p>
<p>它一共是以下几个部分：</p>
<p>主页，关于，技术栈，项目，关键词，性格，爱好，友链</p>
<p>每一个部分都选用了一个前端比较流行的动画效果，<del>可以说几乎是吃百家饭</del></p>
<p>但是根据反馈，这个网页的体验比<del>较</del>卡<del>顿</del> ，让我一不小心写成 3A 大作了，之后我会先优化性能，流畅之后再将原来网页的功能都重新实现回来，再弄一些创新的功能。</p>
<p>接下来的一段时间里，我又开始继续 LeetCode，继续看八股文，继续...继续发现自己什么都不会，然后垂头丧气暗自神伤，又转而重燃信心继续尝试，就这样 in loop in pain.</p>
<h2>KMP 项目的继续探索</h2>
<p>之前介绍安卓的文章中我就对 Kmp 和 Compose 做了铺垫，于是它成为了我没事情时候的消遣：</p>
<p><img src="https://blog.grtsinry43.com/uploads/2025/05/26/image.png_1a423af4-945f-4942-8620-76b7aca98e4e.png" alt="image"></p>
<p>但是说来这个项目目前还是十分早期的阶段，而在几天前我听说 iOS Target 居然 Stable，于是我又连夜删了所有不多的 Swift 存量代码，使用 Compose 一把梭哈，这里面也遇到了许多问题，等我考完试单开文章仔细讲讲。</p>
<p>这个应用的目的就是因为我平时屏幕使用时间的记录太分散并且没法汇总和分析，希望能用一种无感的方式来追踪。并且，相比与 Electron+RN 的跨端模式，我感觉性能角度考虑还是选择 Compose，RN 的卡顿是真的离谱。</p>
<h2>“用爱打造”组件库</h2>
<p>好的，如你所见，我的思路极其跳跃。</p>
<p>原本是每天算法和八股在写 js，然后又跳到课内的 Python，随后自己写上了 Next.js，之后开始玩 kotlin，最后最到了 Vue。</p>
<p>“amore”是意大利语，意为”爱，热爱“等等”。</p>
<p>我十分不知所云地为它写了一段介绍：然后又 AI 跑了一个 logo</p>
<blockquote>
<p>AmoreUI: A Vue 3 Component Library Born from Passion</p>
<p><strong>AmoreUI</strong> is a brand new Vue 3 component library, crafted with a deep &quot;amore&quot; – or <strong>love</strong> – for front-end development. Our goal is to provide a beautiful, intuitive, and highly performant set of UI components that empower developers to build amazing web applications with Vue 3.</p>
<p>Inspired by the passion and artistry often associated with the word &quot;amore,&quot; AmoreUI aims to bring that same dedication to your development workflow, making UI creation a joyful and efficient experience. We're excited to share our love for coding through this project and help you create stunning user interfaces effortlessly.</p>
</blockquote>
<p><img src="https://blog.grtsinry43.com/uploads/2025/05/26/image.png_feb9181a-e0c0-4c4a-8fe9-6cf264705370.png" alt="image"></p>
<p>它采用 Monorepo 形式来组织源码，然后与此同时地，配置这个仓库就花费了我好久时间，并且遇到了很多很多问题，这个也是考试之后讲一讲（挖坑.jpg）</p>
<h2>平淡，忙碌的生活</h2>
<p>最近去了一次李自健美术馆，一个上午欣赏了许多作品，非常放松！</p>
<p>上个周末去了学姐的学术报告会，一起交流学习，非常好玩了算是，一堆人聚在一起为 Arch 传教（bushi）</p>
<p>随后部门团建，非常高兴和放松，释放了好多压力还有负面情绪。</p>
<p><img src="https://blog.grtsinry43.com/uploads/2025/05/26/image.png_4f188710-6543-4b3b-881f-aa176c2a1ead.png" alt="image"></p>
<p><img src="https://blog.grtsinry43.com/uploads/2025/05/26/image.png_970b4b73-bcb6-4017-b78b-6046cf20bd1c.png" alt="image"></p>
<p><img src="https://blog.grtsinry43.com/uploads/2025/05/26/image.png_e45e11e7-a37f-480e-859f-ff11c63b61b3.png" alt="image"></p>
<p><img src="https://blog.grtsinry43.com/uploads/2025/05/26/image.png_5ddd28f5-4a1e-4227-ba7c-f1c5ed659581.png" alt="image"></p>]]></description>
      <author>grtsinry43</author>
      <enclosure url="https://blog.grtsinry43.com/uploads/2025/05/26/image.png_9f928322-2d3c-4152-b999-d241ec9a1a09.png" length="0" type="image/png"></enclosure>
      <guid>moment-12</guid>
      <pubDate>Mon, 26 May 2025 09:01:15 +0000</pubDate>
    </item>
  </channel>
</rss>