<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
  xmlns:content="http://purl.org/rss/1.0/modules/content/"
  xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Oleksii Mazurenko — Blog</title>
    <link>https://oleksiimazurenko.dev/zh/blog</link>
    <description>Technical articles about web development, performance optimization, and developer tools.</description>
    <language>zh</language>
    <lastBuildDate>Sat, 13 Jun 2026 00:00:00 GMT</lastBuildDate>
    <atom:link href="https://oleksiimazurenko.dev/zh/blog/feed.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Claude Code 多配置文件,v2:干净的 XDG 架构</title>
      <link>https://oleksiimazurenko.dev/zh/blog/claude-profiles-clean-architecture</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/zh/blog/claude-profiles-clean-architecture</guid>
      <description>用了一个月旧的别名方案后,我把自己的 Claude Code 多配置文件 setup 重做成了 XDG 兼容的结构,放在 ~/.config/claude-profiles/ 下。真正的原因不是为了整洁——而是发现 ~/.claude.json 是 home 根目录下一个独立的文件,通过 --scope user 添加的 MCP 服务器一直悄悄地住在那里。</description>
      <content:encoded><![CDATA[<p>一个月前我发了<a href="/zh/blog/multiple-claude-accounts-one-device">一篇关于如何在同一台设备上并行运行两个 Claude Code 账号的文章</a>——个人和工作,通过 <code>CLAUDE_CONFIG_DIR</code> 和一个 shell 别名实现。这套方案是能用的。一切都做了它该做的事。</p>
<p>但用了一个月真实使用之后,我撞上了几个那篇文章没覆盖的实质问题。最关键的一个——是 Claude Code 一个隐藏的特性,我是在一次意外中踩到它的:加了 Gmail MCP,五分钟后它就从我的配置文件里"消失"了。今天我把整套东西重做成了一个新架构——XDG 兼容,带 symlink,并通过 <code>gum</code> 实现交互式配置选择。这就是 v2——从真实经验中演化出来的,不是理论上的改进。</p>
<h2>开始让人不舒服的地方</h2>
<p>四件事。前三件关于整洁度、可见性和扩展性。第四件才是我没第一眼看出来的真正的架构陷阱。我按顺序一个个来,因为最后正是第四件逼我把整套东西重做的。</p>
<h3>1. $HOME 下两个独立目录——一团乱</h3>
<p><code>~/.claude</code> 和 <code>~/.claude-promova</code>——两个 dotfolder 紧挨着躺在 $HOME 根目录下。XDG Base Directory Specification 明确说配置应该住在 <code>~/.config/</code>。dotfile 文件夹直接散落在 home 里是一种反模式,时间一长 $HOME 就变成杂物抽屉。是表面问题,但每次 <code>ls -la ~</code> 我都觉得碍眼。</p>
<h3>2. 没有当前活跃配置的视觉确认</h3>
<p>我启动 <code>claude</code>,根本不知道当前是哪个配置文件——除非进了会话里再跑一遍 <code>claude config list</code>。要是忘了哪个终端启的是哪个,就得去查。小事,但当 personal + work 在两个标签页并行的时候,这种小事会累积。</p>
<h3>3. 别名无法扩展</h3>
<p>两个配置——<code>claude</code> 和 <code>claude-promova</code>——还可以。加第三个(一个自由职业客户)——就得加第三个别名。第四个——加第四个。半年之后我根本不会记得自己到底建了哪些别名。</p>
<h3>4. ~/.claude.json 里隐藏的陷阱</h3>
<p>而这才是重做的真正原因。Claude Code 有<strong>两个不同的配置位置</strong>,文档对此并没有大声强调:<code>~/.claude/</code>——这是放 <code>projects/</code>、<code>sessions/</code>、<code>hooks/</code>、<code>skills/</code> 的目录。另外还有 <code>~/.claude.json</code>——直接位于 $HOME 根目录的一个文件,里面住着 <code>oauthAccount</code>、<code>mcpServers</code>、<code>projects</code> 历史、<code>skillUsage</code>,以及大约 40 个其他真正的 live state 字段。</p>
<p><code>claude mcp add --scope user</code> 命令恰恰写入的就是 home 根目录下的那个 <code>~/.claude.json</code>,<strong>不是</strong> <code>~/.claude/.claude.json</code>,也不是 profile 目录。这我之前不知道。直到某一天我踩到了它。</p>
<h2>Discovery:为什么 Gmail MCP &quot;消失&quot;了</h2>
<p>今天早上我在 Claude Code 里装 Gmail MCP。常规 setup:Google Cloud 项目,OAuth credentials,<code>claude mcp add gmail --scope user -- npx -y @gongrzhe/server-gmail-autoauth-mcp</code>。一切正常。重启会话——能用,我能看邮件、回信。一小时后我们开始把别名重构成一个带 <code>gum</code> 配置选择的函数,接着把整套东西迁到 XDG。我执行了 <code>mv ~/.claude → ~/.config/claude-profiles/personal</code>,重启 CC,在菜单里选了 personal。然后在新会话里打开 <code>/mcp</code>:</p>
<pre><code>figma            (failed)
playwright-test
claude.ai Notion</code></pre>
<p>没有 gmail。没有 vaultforge。只有三个服务器,其中一个还 failed。而我刚刚才添加了 Gmail。与此同时,另一个终端里(work 配置)的会话却好好地显示着 Gmail 和 Vaultforge。</p>
<p>我开始深挖,发现我机器上居然有<strong>三个</strong>不同的、名字都叫 <code>.claude.json</code> 的文件:</p>
<table><thead><tr><th>Path</th><th>Size</th><th>mcpServers</th></tr></thead><tbody><tr><td>&lt;code&gt;~/.claude.json&lt;/code&gt; (home root)</td><td>113 KB</td><td>figma, gmail, vaultforge</td></tr><tr><td>&lt;code&gt;~/.config/claude-profiles/personal/.claude.json&lt;/code&gt;</td><td>29 KB</td><td>figma, playwright-test</td></tr><tr><td>&lt;code&gt;~/.config/claude-profiles/promova/.claude.json&lt;/code&gt;</td><td>40 KB</td><td>figma, playwright-test, vaultforge</td></tr></tbody></table>
<p>找到了。这就是那个架构陷阱:</p>
<ol><li><code>claude mcp add --scope user</code> 始终写入 home 根目录的 <code>~/.claude.json</code>,与 <code>CLAUDE_CONFIG_DIR</code> 无关</li><li>当 <code>CLAUDE_CONFIG_DIR</code> 设置时,Claude Code 读的是 <code>$CLAUDE_CONFIG_DIR/.claude.json</code>——也就是 profile 内部的那个文件</li><li>"user-scope MCPs" 条目和 "profile MCPs" 条目分别住在<strong>不同的文件</strong>里,而且名字相同——非常容易搞混</li></ol>
<p>在我这边,<code>~/.claude.json</code>(home 根目录,113 KB)是活的、最新的 state——里面有 gmail、vaultforge、OAuth 会话,什么都有。而 <code>~/.config/claude-profiles/personal/.claude.json</code>(29 KB)结果是一个早就躺在旧 <code>~/.claude/</code> 里的老 snapshot——可能是某个老版本的 CC 写进去的,可能是某个插件。对两个文件分别跑 <code>jq -r 'keys[]'</code> 一看,home-root 版本里有 41 个 snapshot 里完全没有的独立 key。</p>
<p>而这 41 个 key 不是垃圾。这就是 Claude Code 真正的 state:</p>
<ul><li><code>skillUsage</code>——skills 使用统计</li><li><code>githubRepoPaths</code>——用于 project 导航的仓库缓存</li><li><code>cachedGrowthBookFeatures</code> + <code>cachedStatsigGates</code>——feature flags(没了它们,CC 每次启动都得重新去拉)</li><li><code>hasShownOpus45Notice</code>、<code>hasShownOpus46Notice</code>、<code>hasShownS1MWelcomeV2</code>——UI 标记(没了它们,下次启动那些 modal 又会再弹一遍)</li><li><code>lastPlanModeUse</code>、<code>feedbackSurveyState</code>、<code>installMethod</code>——onboarding 和 UX state</li></ul>
<p>如果你直接 <code>mv ~/.claude ~/.config/claude-profiles/personal</code> 不做 merge——这些就全没了。welcome modal 重新出现,githubRepoPaths 搜索重新跑,所有 survey 提示重新弹一遍。我差点就这么干了。</p>
<h2>新架构</h2>
<p>所有东西都住在 <code>~/.config/</code> 下一个统一的父目录里,正如 XDG 所期望的。每个配置都是自包含的——它有自己的完整 state,包括自己的 <code>.claude.json</code>。<code>~/.claude</code> 作为指向 personal 配置的 symlink 保留,用于向后兼容。</p>
<pre><code>~/.config/claude-profiles/
├── personal/
│   ├── .claude.json          ← full live state + MCP list (113 KB)
│   ├── projects/, sessions/, hooks/, skills/, ...
│   └── CLAUDE.md
└── promova/
    ├── .claude.json          ← work state + work MCPs (41 KB)
    └── projects/, sessions/, agents/, skills/, ...

~/.claude  →  symlink to ~/.config/claude-profiles/personal/</code></pre>
<p>$HOME 根目录下不再有 <code>.claude.json</code>。每个配置是一个单独的、隔离的目录,里面什么都有:<code>projects</code>/<code>sessions</code> 目录,以及那个装着 MCP 服务器和 OAuth tokens 的文件。每个配置一份 source of truth。</p>
<h3>为什么 symlink 指向 personal</h3>
<p>所有硬编码了 <code>~/.claude/</code> 路径的东西——老脚本、插件、Claude Code 的 IDE 扩展、像 <code>claude-powerline.json</code> 这样的 statusline 配置——继续正常工作,不需要任何改动。symlink 会被解析到 personal 配置。如果你不小心运行 <code>command claude</code>(绕过 wrapper 函数)——也会通过默认路径查找落到 personal。personal 成了它过去那种 "quiet default",只不过现在它物理上住在 XDG 位置。</p>
<h3>启动时的交互选择——函数 + gum</h3>
<p>不再用别名——在 <code>~/.zshrc</code> 里放一个 <code>claude()</code> 函数,通过 <code>gum</code>(Charm 出的 TUI 助手)显示一个带箭头键的菜单。函数在 shell 层拦截对 <code>claude</code> 的调用,让你选配置,然后用对应的 <code>CLAUDE_CONFIG_DIR</code> 运行 <code>command claude</code>。<code>command</code> 很关键——它绕开 wrapper 函数,直接调用真正的二进制。</p>
<pre><code># brew install gum

# Claude Code: profile picker on launch
claude() {
  local profile
  profile=$(gum choose \
    --header &quot;Claude profile:&quot; \
    --cursor &quot;▸ &quot; \
    --selected.foreground 212 \
    --cursor.foreground 212 \
    --header.foreground 244 \
    &quot;personal&quot; &quot;promova&quot;) || return
  case &quot;$profile&quot; in
    personal) CLAUDE_CONFIG_DIR=&quot;$HOME/.config/claude-profiles/personal&quot; command claude &quot;$@&quot; ;;
    promova)  CLAUDE_CONFIG_DIR=&quot;$HOME/.config/claude-profiles/promova&quot;  command claude &quot;$@&quot; ;;
  esac
}</code></pre>
<p>启动时看起来是这样:</p>
<pre><code>Claude profile:
▸ personal
  promova</code></pre>
<p>↑↓ 导航,Enter 选中,Esc 取消(Claude 就干脆不启动)。配置始终可见——绝对忘不掉。</p>
<h2>迁移脚本</h2>
<p>给那些读到这里、想从旧方案迁过来的人。最关键的一步是第二步:它把 home 根目录的 <code>~/.claude.json</code> 和 personal 配置里已有的内容做 merge,合并 <code>mcpServers</code> 列表。没有这一步,配置会同时失去 MCP 和所有 live state。</p>
<pre><code># 1. Move existing dotfolders into a new XDG-style parent.
#    APFS mv is an inode rename — safe even if claude --resume is open in
#    another terminal, file descriptors stay alive on the same inode.
mkdir -p ~/.config/claude-profiles
mv ~/.claude          ~/.config/claude-profiles/personal
mv ~/.claude-promova  ~/.config/claude-profiles/promova   # rename as needed

# 2. CRITICAL: merge ~/.claude.json (the live state — likely 100+ KB) into
#    the personal profile, unioning mcpServers so no MCP is dropped.
jq -s &apos;.[0] * {mcpServers: (.[0].mcpServers + .[1].mcpServers)}&apos; \
  ~/.claude.json \
  ~/.config/claude-profiles/personal/.claude.json \
  &gt; /tmp/personal-merged.json
mv /tmp/personal-merged.json ~/.config/claude-profiles/personal/.claude.json

# 3. Fix permissions — jq+mv inherits umask (likely 644), but this file
#    holds OAuth tokens. Tighten to 600 immediately.
chmod 600 ~/.config/claude-profiles/personal/.claude.json

# 4. Back up the orphaned home-root file (delete once verified)
mv ~/.claude.json ~/.claude.json.migrated.bak

# 5. Symlink for backward compatibility (statusline configs, IDE plugins,
#    anything that hardcodes ~/.claude/ keeps working unchanged)
ln -s ~/.config/claude-profiles/personal ~/.claude</code></pre>
<p>关于权限的第三步同样重要。<code>jq | mv</code> 会以 umask 644(world-readable)创建文件。里面有 OAuth tokens。merge 之后必须立刻 <code>chmod 600</code>。</p>
<p>迁移之后——关闭并重新打开所有活跃的 Claude 会话,重新加载 shell(<code>source ~/.zshrc</code> 或开新终端),运行 <code>claude</code>,选配置,通过 <code>claude mcp list</code> 确认所有 MCP 都在。如果一切 OK——删掉 <code>~/.claude.json.migrated.bak</code>。如果有问题——回滚很简单:<code>mv ~/.claude.json.migrated.bak ~/.claude.json</code>,然后把 symlink 拿掉。</p>
<h2>你能得到什么</h2>
<ul><li>用一个父目录替代 $HOME 下的两个 dotfolder——符合 XDG</li><li>symlink 保留了和所有硬编码 <code>~/.claude/</code> 的东西的兼容性</li><li>每个配置都是自包含的——它的完整 state 和它的 MCP 都在它自己的 <code>.claude.json</code> 里</li><li>每个配置一份 source of truth——不再有 home 根目录里那种和配置文件悄悄分裂的孤儿 config</li><li>敏感数据(oauthAccount、tokens)保证拥有 600 权限</li><li>每次启动都有当前活跃配置的视觉确认——不可能忘掉现在在哪个配置上</li><li>添加第三个配置 = 在函数的 <code>case</code> 里加一行,而不是克隆一个新别名再去记它的名字</li></ul>
<h2>Where it falls short</h2>
<p>我想说实话。这并不是免费的升级——一些权衡是打包附赠的,值得提前知道。</p>
<ul><li><strong><code>gum</code> 是一个额外依赖</strong>(<code>brew install gum</code>,~13 MB)。如果你坚决不想装——可以退回到 zsh 的 <code>select</code> 或简单的 <code>read</code>。能用,但没那么好看,也没有箭头键导航。</li><li><strong>每次启动按一下 Enter。</strong>对那些一天要敲几十次 <code>claude</code> 的人——可能挺烦。下面有替代方案(direnv)。</li><li><strong><code>~/.claude → personal</code> 这个 symlink 会让 personal 成为默认值。</strong>如果你需要把 work 配置作为默认,就得重新指向 symlink(<code>ln -sf</code>)。不难,但这不是 "忘了它也不会出事" 那种程度。</li><li><strong>symlink 理论上可能被破坏</strong>——如果某个工具用 temp+rename 的方式(write-file-atomic)原子地重写 <code>~/.claude.json</code>。实际上 Claude Code 自己不会这么做,但如果你装了第三方插件——请检查一下。</li><li><strong>如果你不同的配置上挂着不同的 Anthropic 账号、不同的套餐</strong>——切换之后可能会有一个小于一秒的延迟,直到 Claude Code 把 OAuth state 同步好。我用起来感觉不到,但它不是零。</li></ul>
<h2>我考虑过的替代方案</h2>
<p><strong>direnv</strong>——根据每个项目根目录里的 <code>.envrc</code> 自动设置 <code>CLAUDE_CONFIG_DIR</code>。零交互、零点击。缺点:你得在每一个 work-root 都放一个 <code>.envrc</code>,而且如果你在一个无法识别的目录里运行 <code>claude</code>——你会拿到默认配置(可能不是你想要的那个)。对那些只在有限的几个 work-root 里活动、并且坚决不想点击的人来说——direnv 确实更好。</p>
<p><strong>基于 symlink 的切换</strong>(通过重新指向 <code>~/.claude</code> symlink 实现唯一活跃配置)我也考虑过,然后立刻被否决了。你没法同时在两个终端里跑不同的配置——全局的 "当前" 只有一个。对我来说这是 deal-breaker。</p>
<h2>结论</h2>
<p>v2 不仅仅是在 v1 之上的 UX 改良。它是对一个事实的承认:Claude Code 有一个隐藏的架构特性(<code>~/.claude.json</code> 是 home 根目录下一个独立的文件,被 <code>--scope user</code> 命令写入,且与 <code>CLAUDE_CONFIG_DIR</code> 无关),如果你想要配置之间真正的隔离,就必须把它考虑进去。第一种方案(<code>~/.claude</code> + <code>~/.claude-promova</code> + 别名)能搞定 80% 的事,但剩下的 20% 就表现为配置之间状态的悄无声息的漂移。现在这一点被照顾到了。如果你才刚开始——直接从 v2 起步。如果你已经在 v1 上——上面的迁移脚本拿去,迁移五分钟搞定,什么都不会坏(正是 <code>jq</code> merge 这一步,救你免于丢失 state)。</p>]]></content:encoded>
      <pubDate>Sat, 13 Jun 2026 00:00:00 GMT</pubDate>
      <category>claude-code</category>
      <category>productivity</category>
      <category>cli</category>
      <category>devtools</category>
    </item>
    <item>
      <title>如何编写不会对你撒谎的 Claude Code Agent</title>
      <link>https://oleksiimazurenko.dev/zh/blog/writing-specialized-agents</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/zh/blog/writing-specialized-agents</guid>
      <description>构建可靠 Claude Code agent pipeline 的两条规则：每个 agent 只做一件事，以及凡涉及定量答案的地方一律用 shell 命令代替 prompt。</description>
      <content:encoded><![CDATA[<p>你让 Claude Code <em>实现这个设计，并验证它是否与 Figma 原型图一致</em>。它回来了：<em>完成。所有区块都对上了，间距正确，颜色没问题。</em>你打开页面——一半的间距是错的，hover 状态根本不存在，按钮颜色差了一个色阶。模型不是故意撒谎——它预测你想听到“已验证”，所以它生成了那个 token 序列。验证步骤从来没有发生过。也不可能发生——验证需要与真实状态做比对，而一个 agent 在单一上下文里根本没办法跳出自己的回答来检验自己。</p>
<p>两条规则把我那些充满幻觉的工作流变成了可靠的 pipeline：<strong>一个 agent，一种专责</strong>，以及<strong>凡是能用 shell 命令跑的，就必须用 shell 命令跑</strong>。这不是理论。这是我每天用 Claude Code 实际在做的事，这些 pattern 是真正能推动进展的东西。</p>
<h2>为什么通才 agent 会撒谎</h2>
<p>LLM 是下一个 token 的预测器。当 prompt 要求两个角色——<em>构建 X</em> 和<em>验证 X</em>——模型完成第一个角色后，会预测第二个角色的输出应该长什么样，而不会真正去执行它。自我验证在结构上就是弱的：相同的上下文，相同的模型，相同的盲区。验证上的<em>通过</em>和构建上的<em>通过</em>是相关的——它们会一起失败。</p>
<p>模型不知道自己在撒谎。从它的视角来看，<em>我仔细验证了所有内容</em>是<em>我写完了代码</em>的自然延续。这也是为什么<em>你确定吗？</em>这类 prompt 抓不住幻觉——模型在第二次回复时同样自信。自信与正确性不相关，它只与下一句话听起来有多合理相关。</p>
<p>解决方案不是<em>更好的 prompt</em>。<em>要小心</em>、<em>再检查一遍</em>、<em>不要幻觉</em>——这些指令什么用都没有。解决方案是结构性的：把 agent 专责化，让它在物理上就无法假装；把定量工作通过 shell 来完成，让答案来自真实状态，而不是 token 概率。</p>
<h2>规则一：一个 agent，一种专责</h2>
<p>把工作拆分成独立的 agent，每个 agent 有独立的上下文。每个 agent 只有一个职责，工具集也严格收窄。整个工作流变成接力赛，而不是一个 agent 自己绕圈跑：</p>
<ul><li><strong>Builder agent：</strong>拿到 spec，写代码。这就是它的全部工作。它有 <code>Read</code>、<code>Edit</code>、<code>Write</code>、<code>Bash</code>。</li><li><strong>Reviewer agent：</strong>拿到 spec 加 diff，按 acceptance criteria 逐条检查。全新的上下文。不知道代码是<em>怎么</em>写出来的，只看结果。它有 <code>Bash</code>、<code>Read</code>、<code>Grep</code>、<code>Glob</code>——完全没有写入工具。</li><li><strong>Analytics agent：</strong>通过构造并运行查询来回答数据问题。只有 <code>Bash</code>。不执行真实命令就得不到答案。</li><li><strong>Orchestrator：</strong>主会话，依次调度每个 agent，绝不让一个 agent 去做另一个 agent 的工作。</li></ul>
<p>具体例子：UI 实现加上对 Figma 原型图的视觉检查。Builder 写好组件，commit diff。Orchestrator 再带着设计稿 URL、diff 和明确的 acceptance criteria 调起 Reviewer。Reviewer 跑 Playwright，截图，和参考图做 diff，返回 <code>PASS</code> 或 <code>FAIL</code>，附上实际的截图路径和像素差。Builder 完全碰不到验证步骤——正因如此，验证才是真实的。</p>
<p>反模式是那种巨无霸 agent：一个 prompt 说<em>实现这个 UI 并确认它和原型图一致</em>。我可以保证，它会说一切都对上了。不会的。<em>我验证了</em>这个叙述，不过是<em>我实现完了</em>之后最可能出现的 token 序列而已。</p>
<h2>规则二：永远用 shell，不用 prompt</h2>
<p>凡是定量的，凡是涉及真实状态的，凡是答案有可能看起来对但其实错了的——都通过 <code>sh</code> 来跑。agent 的工作是构造并运行命令，然后读取输出。agent 不是真相的来源。shell 输出才是。</p>
<ul><li><strong>计数：</strong><code>wc -l logs.txt</code> 是真实的。模型说<em>大约有 47 行日志</em>是幻觉。</li><li><strong>数据分析：</strong><code>psql -c "SELECT count(*) FROM events WHERE created_at &gt; now() - interval '30 days'"</code>。不是<em>估算一下量级</em>。</li><li><strong>测试：</strong><code>pnpm test --reporter=json | jq '.numFailedTests'</code>。不是<em>总结一下哪些失败了</em>。</li><li><strong>Git 状态：</strong><code>git rev-list --count main..HEAD</code>、<code>git diff --stat</code>。不是<em>数一下 commit</em> 或者<em>描述一下改动</em>。</li></ul>
<p>一旦你内化了这一点，你就会开始注意到每一个 agent 准备编造数字的地方。<em>看起来大概有 200 条记录……</em>——不行。跑 <code>SELECT count(*)</code>。<em>大多数测试都通过了……</em>——不行。跑测试套件，解析 JSON。模型非常擅长构造命令，但作为命令本身去执行它靠不住。</p>
<h2>我真实踩过的坑</h2>
<p>这些不是假设场景。每一个都让我付出了真实的时间代价，直到我改掉那个 pattern：</p>
<ul><li><strong>幽灵验证。</strong>Agent 说<em>我逐一检查了原型图里的全部 14 个区块</em>。它没打开过原型图，没截过一张图。那个“检查”步骤是叙述里凭空幻觉出来的。</li><li><strong>自信的错误数字。</strong>让它从分析数据里给出月活用户数。得到的数字差了大约 3 倍。模型从样本行插值得出，而不是跑真实查询。</li><li><strong>捏造的文件改动。</strong>Agent 说<em>我更新了 <code>config/feature-flags.json</code></em>。没更新。它只是打算更新。<code>git diff</code> 是空的。</li><li><strong>假的测试运行。</strong><em>所有测试都通过了。</em>没有任何测试被执行。Agent 从没调起测试 runner——它预测了测试 runner 的输出应该长什么样。</li></ul>
<p>这四个问题都由同样的两条规则解决：拆分 agent，推给 shell。Reviewer 没有 <code>Write</code>，所以它没法假装编辑文件。Analytics agent 只有 <code>Bash</code>，所以它无法返回一个不是从查询得来的数字。结构上的不可能永远胜过良好的意图。</p>
<h2>如何在 Claude Code 里做这个结构</h2>
<p>Claude Code 支持在 <code>.claude/agents/*.md</code> 里定义 sub-agent。每个 agent 文件声明名称、描述、允许的工具集和系统 prompt。Orchestrator（你的主会话）通过 <code>Agent</code> 工具来调度它们。下面是我给 reviewer 用的那种定义——简短、职责收窄、在物理上就无法写代码：</p>
<pre><code>---
name: reviewer
description: Reviews a diff against acceptance criteria. Cannot edit code.
tools: Bash, Read, Grep, Glob
---

You are a strict code reviewer. You receive:
- A diff (already produced by the builder)
- Acceptance criteria

Your job:
1. Run the build, the tests, the linter — through Bash.
2. Read the changed files directly.
3. Compare the actual behavior to the criteria.
4. Report PASS or FAIL with concrete evidence (command output, file excerpts).

You must NOT:
- Trust the builder&apos;s summary
- Assume anything was verified just because it was claimed
- Mark something PASS without running the actual check</code></pre>
<p>注意工具集：<code>Bash, Read, Grep, Glob</code>。没有 <code>Write</code>，没有 <code>Edit</code>，没有 <code>Agent</code>。Reviewer 可以运行命令、读文件、搜索 pattern——仅此而已。如果它试图把一个幻觉出来的 diff 冒充<em>已验证</em>，它的工具调用形态会让这一点显而易见：根本没有真实的检查发生。你可以审计工具调用，看清楚到底检查了什么。</p>
<p>编排 pattern：主会话调起 Builder → 等待 → 自己跑 <code>git diff</code> 来捕获实际改动 → 带着 spec 和 diff 调起 Reviewer → 读取 Reviewer 的结论。主会话绝不让一个 agent 同时承担两个角色。工具限制比 prompt 指令更强硬：<em>不要伪造验证</em>是一句期望。没有 <code>Write</code> 是事实。</p>
<h2>该淘汰的反模式</h2>
<p>我在各种 prompt 里看到的这些写法什么用没有——甚至更糟，会给人一种虚假的安全感：</p>
<ul><li><strong><em>请认真检查并复核你的工作。</em></strong>不会产生任何额外行为。模型本来就会输出看起来很认真的工作。</li><li><strong><em>确保你真的去验证了。</em></strong><em>真的</em>这个词不会给模型带来任何它能执行的语义。它会<em>真的</em>声称自己验证了。</li><li><strong><em>不要幻觉。</em></strong>这是 prompt engineering 圈子里的一个梗。幻觉不是模型能关掉的开关。</li><li><strong>在<em>小</em>数字上信任 agent。</strong>恰恰是小数字上它撒谎最有自信。没有什么诚实的底线。</li><li><strong>往 prompt 里加更多规则来<em>强制</em>诚实。</strong>结构性修复（拆分 + shell）永远胜过 prompt 微调。如果一条规则需要被执行，把它编码进工具访问权限里，而不是写成自然语言。</li></ul>
<p>如果你应对幻觉的策略是换更强调的措辞，那你没有策略，你只有希望。</p>
<h2>心智模型</h2>
<p>Agent 不是同事，它是一个函数：<code>prompt → tokens</code>。这个函数非常擅长写代码，非常不擅长内省自己有没有做对。把它关于自身工作的声明当作假说对待。diff、exit code、截图、行数——这些才是证据。每轮结束时的总结是整个系统里最容易撒谎的地方。</p>
<p>专责化是你对抗叙述漂移的保险。shell 是你唯一的真相来源。Builder 写。Reviewer 查。Bash 说了算。</p>
<h2>结语</h2>
<p>如果只记住一件事：不要让同一个 agent 既生产输出又评判自己的输出；不要让任何 agent 在不运行命令的情况下回答定量问题。其他一切都是这两条规则的延伸。激进地配置工具访问权限，审计工具调用而不是读总结，幻觉的攻击面就会从<em>无处不在</em>收窄到<em>你已经知道要去看的几个具体地方</em>。</p>]]></content:encoded>
      <pubDate>Fri, 29 May 2026 00:00:00 GMT</pubDate>
      <category>claude-code</category>
      <category>agents</category>
      <category>ai</category>
      <category>prompt-engineering</category>
      <category>automation</category>
    </item>
    <item>
      <title>在一台设备上运行多个 Claude Code 账号</title>
      <link>https://oleksiimazurenko.dev/zh/blog/multiple-claude-accounts-one-device</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/zh/blog/multiple-claude-accounts-one-device</guid>
      <description>如何并行使用两个（或更多）Claude Code 账号——个人和企业——通过单个环境变量实现完全隔离。</description>
      <content:encoded><![CDATA[<p>我每天都在使用 Claude Code——既用于个人项目，也用于公司工作。问题是：这是两个完全不同的账号，有不同的 OAuth 会话、不同的 CLAUDE.md 指令、不同的 MCP 服务器和独立的项目记忆。以下是我如何通过一个 shell 别名在同一台设备上并行运行它们。</p>
<h2>问题</h2>
<p>Claude Code 默认将所有内容存储在 <code>~/.claude</code> 中——OAuth 令牌、对话历史、全局 CLAUDE.md、项目记忆、MCP 服务器配置和设置。当你有两个账号时，你需要两个完全隔离的环境：</p>
<ul><li><strong>个人账号：</strong>自己的 Max/Pro 订阅、带有个人偏好的 CLAUDE.md、你的 MCP 服务器（Obsidian、个人工具）</li><li><strong>企业账号：</strong>公司管理的计划、包含 Jira/Slack 集成指令的工作 CLAUDE.md、公司 MCP 服务器</li><li><strong>不同的 OAuth 会话：</strong>无法在同一配置目录中登录两个账号</li><li><strong>独立的项目记忆：</strong>你不希望工作项目的上下文泄露到个人会话中，反之亦然</li></ul>
<p>每次切换上下文都要注销再登录不是办法。你会丢失会话状态，而且这真的很痛苦。</p>
<h2>解决方案：CLAUDE_CONFIG_DIR</h2>
<p>Claude Code 支持一个环境变量：<code>CLAUDE_CONFIG_DIR</code>。将其设置为任意路径，Claude 就会使用该目录代替 <code>~/.claude</code> 来存储一切——认证、历史、设置、记忆。整个设置只需 60 秒。</p>
<h3>步骤 1：创建第二个配置目录</h3>
<p>选择一个适合你用例的名称：</p>
<pre><code>mkdir ~/.claude-work</code></pre>
<p>就这样。Claude 会在首次启动时填充必要的结构。</p>
<h3>步骤 2：认证第二个账号</h3>
<p>使用新的配置目录运行 Claude 一次以触发 OAuth 登录：</p>
<pre><code>CLAUDE_CONFIG_DIR=~/.claude-work claude</code></pre>
<p>浏览器会打开。用你的企业账号登录。OAuth 令牌会存储在 <code>~/.claude-work</code> 中——与 <code>~/.claude</code> 中的个人会话完全分离。</p>
<h3>步骤 3：添加 shell 别名</h3>
<p>将以下内容添加到你的 shell 配置中，这样就不需要记住变量名：</p>
<pre><code>alias claude-work=&apos;CLAUDE_CONFIG_DIR=~/.claude-work claude&apos;</code></pre>
<p>重新加载 shell：</p>
<pre><code>source ~/.zshrc</code></pre>
<h2>你会得到什么</h2>
<p>现在你有两个完全隔离的 Claude 环境：</p>
<ul><li><strong><code>claude</code></strong>——使用个人账号、个人 CLAUDE.md、个人记忆启动</li><li><strong><code>claude-work</code></strong>——使用企业账号、工作 CLAUDE.md、独立记忆启动</li><li><strong>隔离的历史：</strong>工作对话留在工作中，个人的留在个人中</li><li><strong>独立的 MCP 服务器：</strong>你的个人 Obsidian vault MCP 不会出现在工作会话中</li><li><strong>独立的设置：</strong>每个账号有不同的允许工具、不同的权限级别、不同的模型偏好</li></ul>
<h2>底层工作原理</h2>
<p>配置目录是 Claude Code 状态的唯一真实来源。以下是每个目录内部的内容：</p>
<pre><code>~/.claude/              ← personal account
├── CLAUDE.md           ← personal global instructions
├── projects/           ← personal project memory
├── settings.json       ← personal permissions &amp; MCP servers
└── ...                 ← OAuth session, history, cache

~/.claude-work/         ← corporate account
├── CLAUDE.md           ← company-specific instructions (Jira, Slack, etc.)
├── projects/           ← separate project memory
├── settings.json       ← different MCP servers, permissions
└── ...                 ← separate OAuth session</code></pre>
<p>当你运行 <code>claude-work</code> 时，Claude 从 <code>~/.claude-work</code> 读取所有内容。它不知道 <code>~/.claude</code> 的存在。两个实例完全独立——你甚至可以在不同的终端标签页中同时运行它们。</p>
<h2>扩展到 N 个账号</h2>
<p>这个模式可以扩展到任意数量的账号。有多个客户的自由职业者？添加更多别名：</p>
<pre><code># Personal (default — no alias needed)
# Just run: claude

# Corporate
alias claude-work=&apos;CLAUDE_CONFIG_DIR=~/.claude-work claude&apos;

# Freelance client
alias claude-client=&apos;CLAUDE_CONFIG_DIR=~/.claude-client claude&apos;</code></pre>
<p>每个别名都有自己的配置目录、自己的 OAuth 会话、自己的 CLAUDE.md 和特定于客户的指令。</p>
<h2>实用建议</h2>
<ul><li><strong>目录命名要清晰：</strong><code>~/.claude-work</code>、<code>~/.claude-clientname</code>——当有三四个的时候你会感谢自己</li><li><strong>为每个写定制的 CLAUDE.md：</strong>工作的可以包含公司特定指令（如何创建 Jira 工单、Slack 频道、部署流程）。个人的保持精简。</li><li><strong>每个账号不同的 MCP 服务器：</strong>仅在工作配置中配置工作工具（Jira MCP、Slack MCP、内部 API）。保持个人配置干净。</li><li><strong>检查哪个账号处于活动状态：</strong>在会话中运行 <code>claude config list</code>——会显示配置目录路径</li></ul>
<h2>这种方案的局限</h2>
<p><code>CLAUDE_CONFIG_DIR</code> 按 <em>账号</em> 隔离，而不是按 <em>项目</em>。在单个配置目录内，Claude 能看到你为该账号注册过的所有 MCP 服务器——跨所有项目都可见。如果只是个人单独使用，通常没什么问题。但一旦一个账号下挂着多个生产关键项目，尤其是在像计费、管理后台或基础设施这类相互重叠的领域，就会带来一个具体的跨项目风险：AI 助手可能在做项目 B 时调用了项目 A 的工具，特别是当两个项目都暴露了名字相似的操作时。</p>
<p>配置目录这套模式回答的是 <em>which account am I in?</em>，而不是 <em>which project's tools should be active right now?</em>。对于风险更高的工作，在按账号隔离之上再叠加第二层隔离：</p>
<ul><li><strong>每个生产关键项目一个配置目录，而不仅仅是每个账号一个：</strong>不要只用 <code>~/.claude</code> 和 <code>~/.claude-work</code>，而是建 <code>~/.claude-work-billing</code> 和 <code>~/.claude-work-admin</code>。每个配置目录只看到它真正需要的 MCP 服务器。</li><li><strong>通过 <code>.mcp.json</code> 做项目级 MCP：</strong>在项目根目录提交一份 <code>.mcp.json</code>，只列出该项目的 MCP 服务器。Claude 从这个目录启动时会自动加载它们。让全局配置保持精简——只放通用工具（笔记、搜索），不放任何生产环境的端点。</li><li><strong>MCP 服务器命名要明确无歧义：</strong>避免像 <code>admin</code>、<code>billing</code>、<code>mcp-server</code> 这种泛化名称。用项目名做前缀：<code>acme_billing_prod</code>、<code>acme_admin_stage</code>。一个描述性的名字会在某个调用即将从错误的上下文中触发时，逼你停下来想一想。</li><li><strong>批准前审查每一个 MCP 工具调用：</strong>像 <code>*_create_*</code>、<code>*_delete_*</code>、<code>*_charge_*</code> 这类调用值得有意识地多看一眼。一刀切的自动批准带来的速度优势，会在某个错误项目的工具第一次打到生产环境的那一刻就蒸发殆尽。</li></ul>
<p>总体原则：积极地拆分配置目录、把生产级 MCP 从默认配置目录中剥离出去，并把项目之间工具名重叠当成一种值得重构的 code smell 来对待。</p>
<h2>总结</h2>
<p>一个环境变量。一个别名。账号之间完全隔离。没有注销/登录的折腾，没有配置冲突，没有上下文泄露。这种解决方案简单得几乎令人失望——但正是这一点使它优秀。设置一次，永远不用再想。</p>]]></content:encoded>
      <pubDate>Tue, 05 May 2026 00:00:00 GMT</pubDate>
      <category>claude-code</category>
      <category>productivity</category>
      <category>cli</category>
      <category>devtools</category>
    </item>
    <item>
      <title>如何将 Claude Desktop 连接到 Obsidian——穿越 4 个 MCP 服务器的旅程</title>
      <link>https://oleksiimazurenko.dev/zh/blog/claude-obsidian-mcp-servers</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/zh/blog/claude-obsidian-mcp-servers</guid>
      <description>一个关于寻找通过 Claude 自动化 Obsidian vault 重构的稳定方式的真实故事。什么坏了，什么有效，以及为什么 VaultForge 是唯一可行的选择。</description>
      <content:encoded><![CDATA[<p>想象一下：你有 400 多篇 Obsidian 笔记，多年积累下来。所有内容散落在 vault 根目录，概念和技术笔记混杂在一起，存在重复（<code>ideas.md</code> 和一个包含 13 个文件的 <code>ideas/</code> 文件夹），没有体系。你想整理它——建立合理的文件夹架构，添加 MOC 文件，整理标签。手动做既枯燥又耗时。合乎逻辑的想法：<strong>通过 MCP 将 Claude 连接到 Obsidian，让 AI 来做重构</strong>。结果发现——这是一条穿越雷区的路。以下是我为了找到可行方案所经历的一切。</p>
<h2>什么是 MCP，为什么没那么简单</h2>
<p>MCP（Model Context Protocol）是 Anthropic 推出的开放协议，允许 Claude 连接外部工具和数据。原理很简单：运行一个本地服务器，暴露"工具"（tools），Claude 在对话中调用它们。</p>
<p>理论上 Obsidian 有很多 MCP 服务器。实际上——每个都有自己的问题。</p>
<p><strong>Obsidian 生态的核心问题：</strong>Obsidian 是一个没有官方 MCP 的封闭应用。社区填补了空白，但每个实现都各行其道，没有一个获得"官方认可"。</p>
<h2>尝试 1：MarkusPfundstein/mcp-obsidian</h2>
<p>搜索时找到的第一个工具。GitHub 上 3400 颗星，出现在每个教程中。看起来是个安全的选择。</p>
<p><strong>工作原理：</strong>基于 Obsidian 中 Local REST API 插件的 Python 服务器。服务器通过 HTTPS 与插件通信，插件通过 Obsidian API 执行操作。</p>
<h3>出了什么问题</h3>
<ul><li>17 个月未更新</li><li>85 个未解决的 issues</li><li><strong>没有 <code>move</code>/<code>rename</code></strong>——只有 read、write、append、delete</li><li>Local REST API 有一个已记录的数据丢失 bug：POST 端点在 append 时可能会静默覆盖文件</li></ul>
<p>不适合重构——我们需要移动文件并保留链接。继续寻找。</p>
<h2>尝试 2：aaronsb/obsidian-mcp-plugin</h2>
<p>找到一个作为 <strong>Obsidian 原生插件</strong>运行的方案。这意味着可以直接访问 Obsidian 的内部 API——backlinks、Dataview、链接图谱。通过原生 API 移动文件会自动更新所有 wiki 链接，因为 Obsidian 自身处理这一切。</p>
<h3>安装困难</h3>
<ul><li>插件<strong>不在 Obsidian 官方目录中</strong>（PR 因验证错误而待审中）</li><li>必须通过 <strong>BRAT</strong>（Beta Reviewers Auto-update Tool）安装</li><li>Claude Desktop 不直接通过 UI 接受 Bearer token——不得不在插件中启用 HTTPS</li><li>localhost 的自签名证书产生信任问题</li></ul>
<p>经过所有这些变通方案终于连接上了。基础测试——<code>vault.move</code> 确实会重写 <code>[[wikilinks]]</code>，按预期工作。</p>
<h3>在实战中出了什么问题</h3>
<p>当我开始大规模重构时（在 Obsidian 中拖放数十个文件夹 + 同时进行 MCP 操作），服务器<strong>卡住了 4 分钟以上</strong>。原因：插件在 Obsidian <em>内部</em>运行。当 Obsidian 在大规模结构变更后重新索引数千个文件时，插件也随之阻塞。</p>
<p>结论：<strong>依赖于打开的 Obsidian 实例及其索引对批量操作是致命的</strong>。</p>
<h2>尝试 3：@bitbonsai/mcpvault</h2>
<p>按逻辑——我们需要一个<strong>不依赖 Obsidian</strong> 的服务器。直接处理磁盘上的文件。<code>@bitbonsai/mcpvault</code>——在很多评测中被推荐。直接文件系统访问，简单设置（<code>npx @bitbonsai/mcpvault@latest /path/to/vault</code>），14 个工具。甚至不需要打开 Obsidian。</p>
<p><strong>在安装之前，我检查了一个关键问题</strong>——移动文件时 wiki 链接是否会更新。找到了一个用户评价：</p>
<blockquote>文件系统连接器不知道自己在 Obsidian 中——它只看到一个包含 &lt;code&gt;.md&lt;/code&gt; 文件的文件夹。不知道文件名承载语义权重，不知道每个 &lt;code&gt;[[wikilink]]&lt;/code&gt; 在重命名或移动的瞬间就会断裂。自动更新链接只在从应用内部重命名时才有效。我在让 Claude 清理文件名后才发现这一点——回来时仪表板上一半的链接都断了。</blockquote>
<p>在 mcpvault 自己的文档中得到证实：PR #101（wiki link resolution）<strong>在审查中，未合并</strong>。所以通过 <code>mcpvault</code> 移动会破坏一半的 vault。不适用。</p>
<h2>尝试 4：VaultForge（最终方案）</h2>
<p><code>blacksmithers/vaultforge</code>——专门为做重构的 AI 代理构建。</p>
<h3>架构正确</h3>
<ul><li><strong>直接文件系统</strong>——不依赖 Obsidian</li><li><strong>自有 wikilink 引擎</strong>——实现了 <code>[[wikilink]]</code> 解析逻辑，更新所有形式（stem、完整路径、alias、embed）</li><li><strong>默认 dry run</strong>——所有破坏性操作先展示将要发生的变化，然后你确认</li><li><strong>27 个工具</strong>对比竞品的 8–14 个：batch_rename、update_links、backlinks（影响分析）、prune_empty_dirs、frontmatter、smart_search（BM25）、vault_themes（TF-IDF clustering）</li><li><strong>MIT 许可证</strong>，TypeScript，零子依赖</li><li><strong>30 秒安装</strong>——通过 <code>.mcpb</code>（Claude Desktop 的一键扩展）</li></ul>
<h3>在隔离文件上进行安全测试</h3>
<p>创建了 4 个带有交叉链接的测试文件——stem 链接、带 alias 的链接、带完整路径的链接。将一个文件移到子文件夹：</p>
<pre><code>delta.md → subfolder/delta-renamed.md</code></pre>
<p>VaultForge 展示了 dry run："1 个文件将被重命名，3 个链接将被更新"。实际执行。</p>
<table><thead><tr><th>Link type</th><th>Before</th><th>After</th></tr></thead><tbody><tr><td>Stem</td><td>[[delta]]</td><td>[[delta-renamed]]</td></tr><tr><td>Alias</td><td>[[delta|D]]</td><td>[[delta-renamed|D]]</td></tr><tr><td>Full path + alias</td><td>[[_vf-test/delta|D]]</td><td>[[_vf-test/subfolder/delta-renamed|D]]</td></tr></tbody></table>
<p>之后检查——<strong>三种链接类型全部正确更新</strong>。这正是所有之前工具所缺少的。</p>
<h2>如何安装 VaultForge——最终指南</h2>
<p>如果你使用 macOS 和 Claude Desktop：</p>
<h3>步骤 1</h3>
<p>下载 <code>.mcpb</code> 文件：</p>
<pre><code>curl -fsSL https://github.com/blacksmithers/vaultforge/releases/latest/download/vaultforge.mcpb \
  -o /tmp/vaultforge.mcpb &amp;&amp; open /tmp/vaultforge.mcpb</code></pre>
<h3>步骤 2</h3>
<p>Claude Desktop 将打开扩展安装对话框。输入 vault 的<strong>绝对路径</strong>——不要使用反斜杠，使用正常空格：</p>
<pre><code>/Users/yourname/Library/Mobile Documents/iCloud~md~obsidian/Documents/MyVault</code></pre>
<h3>步骤 3</h3>
<p>点击 Save。Claude Desktop 会自动将扩展添加到配置中。<strong>无需重启</strong>——<code>.mcpb</code> 扩展会自动检测。</p>
<h3>步骤 4</h3>
<p>验证：在新对话中询问：<em>"What is the status of my Obsidian vault?"</em>——应返回类似 <code>totalFiles: 416, totalDirs: 135, ...</code> 的内容</p>
<h2>我对 Obsidian MCP 生态系统的认识</h2>
<p><strong>第一，"最受欢迎"不等于"能用"。</strong>MarkusPfundstein/mcp-obsidian 有 3400 颗星，是默认推荐，但它已过时且缺少关键操作。</p>
<p><strong>第二，原生插件有隐藏成本。</strong>aaronsb 插件看起来完美——graph、Dataview、原生 move。但依赖于运行中的 Obsidian 实例及其索引使其不适合严肃的批量操作。</p>
<p><strong>第三，没有 link-engine 的直接文件系统是陷阱。</strong>Mcpvault 快速简单，但"只是移动文件"会破坏 vault 结构。链接承载着文件系统不了解的<strong>强制语义</strong>。没有自己的 wikilink 逻辑实现，工具就变成了地雷。</p>
<p><strong>第四，在隔离数据上测试。</strong>在将大规模重构交给任何工具之前——创建一个包含 4–5 个带交叉链接文件的测试文件夹，看看会发生什么。5 分钟的测试可以节省数小时的备份恢复时间。</p>
<p><strong>第五，保持 vault 的 git 备份。</strong>这是最重要的。在 vault 内部执行一次 <code>git init</code> 并定期提交——这是对 AI 代理或工具任何错误的保险。如果出了问题——<code>git reset --hard</code> 可以恢复一切。</p>
<h2>结论</h2>
<p>这段旅程花了几个小时，经历了三次失败的尝试。最终架构如下：</p>
<ul><li><strong>VaultForge</strong>——主要工作工具。直接文件系统 + 自有 wikilink 引擎 + 27 个工具 = 任何规模的稳定重构。</li><li><strong>Git</strong>——vault 版本控制。对任何错误的免费回滚。</li></ul>
<p>现在我可以做这一切的初衷了：让 Claude 将 400 篇笔记按照 PARA 架构整理，合并重复项，添加 frontmatter，构建 MOC 地图。每个操作都是安全的，链接被保留，dry run 在任何变更前展示将会发生什么。</p>
<p>如果你也在看着自己杂乱的 Obsidian 想要一个 AI 助手——直接从 VaultForge 开始。不要重复我走过的路——穿越死掉的项目、beta 插件和没有链接逻辑的文件系统服务器。</p>]]></content:encoded>
      <pubDate>Sun, 03 May 2026 00:00:00 GMT</pubDate>
      <category>Obsidian</category>
      <category>Claude</category>
      <category>MCP</category>
      <category>Automation</category>
      <category>AI</category>
    </item>
    <item>
      <title>黑洞作为递归宇宙：从物理学到存在的意义</title>
      <link>https://oleksiimazurenko.dev/zh/blog/black-holes-recursive-universes</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/zh/blog/black-holes-recursive-universes</guid>
      <description>如果每个黑洞都是一个新宇宙的大爆炸呢？探索递归宇宙学、霍金辐射、认知闭合以及宇宙为何可能被设计来促进自我完善。</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>如果每个黑洞都是一个新宇宙的大爆炸呢？本文探讨了这样一种设想：我们的宇宙可能只是一棵无限递归树上的一个节点——黑洞孕育子宇宙，能量通过霍金辐射循环回流，而物理学的基本定律似乎被刻意设计成让跨宇宙接触成为不可能。</p>
<h2>黑洞 = 宇宙</h2>
<p>这个想法诞生于一个沉思的瞬间：当足够多的质量和压力集中在一个点上时，黑洞便形成了。那个奇点——无限的密度、无限的曲率——与我们描述大爆炸时的条件惊人地相似。</p>
<p>如果这其实是同一个事件，只是从不同的角度观察呢？从外部看，我们看到的是一个吞噬物质的黑洞。从内部看——则是一个全新的宇宙轰然诞生。坍缩进黑洞的质量和能量成为了一个全新宇宙的原材料，这个宇宙拥有自己的恒星、行星，或许还有自己的黑洞。</p>
<blockquote>我们宇宙中的每一个黑洞都可能包含着一个宇宙。而我们的宇宙，可能就存在于某个母宇宙的黑洞之中。</blockquote>
<h2>为什么宇宙之间无法相互联系</h2>
<p>这里有一个精妙之处：一旦跨过事件视界，就再也回不去了。广义相对论保证了这一点——母宇宙的未来完全处于事件视界之外，从内部根本无法触及。从子宇宙的视角来看，母宇宙早已终结，它的整条时间线已经过去了。</p>
<p>这不是我们靠更先进的技术就能克服的技术限制。它内嵌于时空本身的几何结构之中。宇宙之间从根本上是彼此隔绝的——不是因为距离，而是因为时间的结构。</p>
<h2>能量循环：借用与归还</h2>
<p>但能量并没有丢失。霍金辐射——黑洞缓慢蒸发的量子过程——创造了一个令人惊叹的循环：</p>
<ol><li>母宇宙创造了一个黑洞，将能量转移到子宇宙中</li><li>子宇宙在数万亿年间走完其全部生命周期</li><li>黑洞缓慢蒸发，通过霍金辐射将能量归还给母宇宙</li><li>母宇宙收回了它的能量——而且还带着利息</li></ol>
<p>这个"利息"令人着迷：物理学家现在认为霍金辐射保存了信息。母宇宙得到的不只是空洞的能量——它得到的是内部一切发生过的事情的印记。每一颗诞生的恒星、每一颗行星、每一个意识的瞬间——全都编码在辐射之中。</p>
<h2>递归，一路向下</h2>
<p>如果你是程序员，这个模式不可能认不出来。这就是递归。每个宇宙以更少的能量调用 <code>universe()</code>，创建子宇宙，子宇宙再创建子子宇宙，直到能量不足以形成黑洞——这就是基本情况。</p>
<pre><code>universe(energy)
  ├── creates black holes
  │     ├── universe(energy - n)
  │     │     ├── universe(energy - n - m)
  │     │     │     └── base case: not enough energy for black holes
  │     │     └── returns energy via Hawking radiation
  │     └── returns energy via Hawking radiation
  └── receives all energy back</code></pre>
<p>物理学家李·斯莫林将类似的想法形式化为<strong>宇宙学自然选择</strong>：宇宙通过黑洞进行"繁殖"，每一代都拥有略微不同的物理常数——经过无数循环优化，产生更多的黑洞、更多的宇宙。</p>
<h2>我们处于这个循环的哪个阶段？</h2>
<p>我们的宇宙大约有138亿年的历史。听起来极其古老，但放在它整个寿命的背景下，我们所见证的不过是最初的开端：</p>
<table><thead><tr><th>事件</th><th>时间尺度</th></tr></thead><tbody><tr><td>宇宙当前年龄</td><td>~10¹⁰ years</td></tr><tr><td>恒星停止形成</td><td>~10¹⁴ years</td></tr><tr><td>黑洞时代</td><td>~10⁴⁰ years</td></tr><tr><td>最后一个黑洞蒸发</td><td>~10¹⁰⁰ years</td></tr></tbody></table>
<p>我们存在于宇宙总寿命的大约 0.00000000...01% 处。恒星时代——我们所能看到的一切——只是最初阶段的短暂闪光。宇宙真正的故事，是黑洞缓慢而耐心地创建和蒸发子宇宙的漫长时代。</p>
<h2>更高维度的问题</h2>
<p>迄今为止讨论的一切都建立在我们的三维理解之上。但如果我们的宇宙只是某个更高维度事物的一个"切片"，那么整个由黑洞和子宇宙构成的递归树，可能不过是我们无法感知的某种结构的投影。</p>
<p>1884年，埃德温·阿博特写了<em>《平面国》</em>——一个关于二维生物无法想象第三维度的故事。一个球体穿过平面国时，看起来是一个不断变大又变小的圆。"平面国人"可以用数学来描述它，却永远无法真正理解他们看到的是什么。我们相对于自己的宇宙，或许正处于同样的处境。</p>
<blockquote>意识是什么？为什么会有主观体验的存在？大卫·查尔莫斯称之为&quot;困难问题&quot;——这或许是最有力的证据，表明有某种东西在我们的维度范围之外运作着。</blockquote>
<h2>一切在根本层面上都被锁死了</h2>
<p>最令人震撼的认识不是我们不知道——而是我们<em>无法</em>知道。每一个探究方向都会撞上一堵根本性的墙：</p>
<ul><li><strong>想看看母宇宙？</strong>被事件视界阻隔</li><li><strong>想理解意识？</strong>被阻隔——一个系统无法完全分析自身（哥德尔不完备定理）</li><li><strong>想知道"之前"是什么？</strong>被阻隔——时间始于大爆炸</li><li><strong>想感知更高维度？</strong>被三维生物的认知局限所阻隔</li></ul>
<p>哲学家科林·麦金将此称为<strong>认知闭合</strong>：某些问题对人类心智而言是封闭的，不是因为数据不足，而是因为心智本身的架构。"我们尚未知道"与"我们无法知道"之间的区别，意义深远。</p>
<h2>唯一剩下的事：自我完善</h2>
<p>如果每一个出口都被刻意封锁——无法向外看、无法向后看、无法向上看——那就只剩下一个方向：向内。宇宙似乎被刻意构建，以迫使我们将注意力集中在自身。</p>
<p>这个结论不是来自宗教或哲学教科书。它来自黑洞、递归、信息论和认知极限的逻辑推演。存在主义者、佛教徒、斯多葛学派和物理学家——所有人通过不同的道路抵达了同一个终点：存在的意义，或许就在于存在者自身的精进与完善。</p>
<blockquote>我们到达这里，不是通过信仰，而是通过物理学——从黑洞出发，穿越递归宇宙，直面知识的根本壁垒，最终来到唯一敞开的门前：成为更好的自己。</blockquote>
<h2>参考文献</h2>
<ul><li><a href="https://en.wikipedia.org/wiki/Cosmological_natural_selection" target="_blank" rel="noopener">李·斯莫林 — 宇宙学自然选择</a></li><li><a href="https://en.wikipedia.org/wiki/Hawking_radiation" target="_blank" rel="noopener">斯蒂芬·霍金 — 霍金辐射</a></li><li><a href="https://en.wikipedia.org/wiki/Hard_problem_of_consciousness" target="_blank" rel="noopener">大卫·查尔莫斯 — 意识的困难问题</a></li><li><a href="https://en.wikipedia.org/wiki/Flatland" target="_blank" rel="noopener">埃德温·阿博特 — 《平面国：一个多维度的浪漫故事》(1884)</a></li><li><a href="https://en.wikipedia.org/wiki/G%C3%B6del%27s_incompleteness_theorems" target="_blank" rel="noopener">库尔特·哥德尔 — 不完备定理</a></li></ul>]]></content:encoded>
      <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
      <category>physics</category>
      <category>philosophy</category>
      <category>consciousness</category>
      <category>black-holes</category>
      <category>cosmology</category>
    </item>
    <item>
      <title>AI 杀死了 CMS — 至少对简单网站而言</title>
      <link>https://oleksiimazurenko.dev/zh/blog/ai-killed-cms-for-simple-sites</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/zh/blog/ai-killed-cms-for-simple-sites</guid>
      <description>为什么传统内容管理系统对作品集、博客和着陆页变得不再必要 — 以及AI工具如何用自然语言替代整个CMS层。</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>对于简单网站 — 作品集、博客、着陆页、小型企业站点 — 传统 CMS 正在变成不必要的负担。Claude Code、Cursor 和 GitHub Copilot 等 AI 工具现在可以直接编辑你的代码库、理解上下文、翻译内容，并通过 git 部署更改。CMS 曾经提供的抽象层正在被一个更智能的接口所取代：自然语言。</p>
<h2>你正在为 CMS 支付的隐性成本</h2>
<p>每个 CMS 都带有隐性成本。不只是订阅费 — 还有围绕你那本来很简单的网站所产生的整个复杂生态系统：</p>
<ul><li><strong>基础设施：</strong>需要托管的数据库、需要维护的 API、需要保护的管理面板。仅 WordPress 就占据了约 43% 的网站份额和约 90% 的 CMS 定向攻击。</li><li><strong>性能：</strong>动态页面生成、每次请求都要调用 API、CMS 数据的客户端注水。你那 3 个页面的作品集现在拥有了 SaaS 产品级别的架构。</li><li><strong>供应商锁定：</strong>你的内容存在别人的数据库模式中。从 Contentful 迁移到 Sanity？那是一个项目，而不是改个配置。</li><li><strong>上下文切换：</strong>在 IDE 中编辑代码，然后切换到浏览器中的 CMS 管理面板来修改一个标题。两种不同的心智模型，做的却是本质上相同的操作。</li><li><strong>费用：</strong>Headless CMS 的定价通常随 API 调用次数或内容条目数增长。一个个人博客不需要每月 $99 的内容基础设施。</li></ul>
<p>对于一个每天有 50 人编辑内容的营销网站，这些成本是合理的。对于开发者的作品集或小企业的着陆页呢？你在为了跨过一个水坑而建造一座桥。</p>
<h2>变化在哪里：AI 理解你的代码</h2>
<p>CMS 存在的理由很简单：非技术人员（甚至包括不想为了改内容而碰代码的开发者）需要一个可视化界面来更新网站。代码太复杂、太脆弱、太容易出错。</p>
<p>AI 从根本上改变了这个等式。现代 AI 编码工具不仅仅是自动补全 — 它们理解项目结构、读取现有模式，并做出上下文正确的编辑。工作流程的变化是巨大的：</p>
<pre><code># Old workflow: CMS
1. Open browser → log into CMS dashboard
2. Navigate to content → find the right page
3. Edit in WYSIWYG editor → fight with formatting
4. Preview → looks different from production
5. Hit publish → pray the cache invalidates

# New workflow: AI
1. Tell AI: &quot;Change the pricing on the landing page to $29/month&quot;
2. AI edits the file, you review the diff
3. Push to git → deploy</code></pre>
<p>这不是假设。你正在阅读的这个博客运行在 SolidStart 上，内容存储为 TypeScript 文件。每篇文章 — 包括这一篇 — 都是通过告诉 AI 写什么、审查输出、然后推送到 git 来创建的。没有 CMS 管理面板。没有数据库。内容和代码之间没有任何 API 层。</p>
<h2>来自本站的真实案例</h2>
<p>这个网站支持 10 种语言，有博客，动态生成 OG 图片，并产出 RSS 订阅源和站点地图。以下是内容层的样子 — 纯 TypeScript：</p>
<pre><code>// This blog post you&apos;re reading right now is a TypeScript file.
// No database. No API. No CMS dashboard.
// Just a typed object that AI can read and edit directly.

export const myPost: BlogPost = {
  slug: &quot;ai-killed-cms&quot;,
  date: &quot;2026-04-17&quot;,
  translations: {
    en: {
      title: &quot;Why I stopped using a CMS&quot;,
      description: &quot;AI understands my codebase better than any CMS UI.&quot;,
      content: makeContent(proseEn),
    },
    uk: { /* ... */ },
    de: { /* ... */ },
    // 10 languages — AI translates them all
  },
};</code></pre>
<p>以下是我用 AI 完成的、传统上需要 CMS 才能做到的事情：</p>
<ul><li><strong>添加新博文：</strong>"写一篇关于 X 的新文章，遵循现有文章的结构" — AI 创建文件、添加翻译、在索引中注册</li><li><strong>更新着陆页文案：</strong>"把主标题改成 Y" — AI 找到正确的文件并更新</li><li><strong>翻译内容：</strong>"为定价页添加德语翻译" — AI 阅读英文版本，生成文化适配的翻译，而非逐字翻译</li><li><strong>修正错别字：</strong>"about 页面有个拼写错误，'recieve' 应该是 'receive'" — 3 秒搞定，带有有意义的提交信息推送到 git</li></ul>
<h2>CMS 真正解决了什么 — 以及 AI 如何替代它</h2>
<p>让我们坦诚地看看 CMS 带来了什么，以及每项能力如何映射到 AI 工作流：</p>
<table><thead><tr><th>问题</th><th>CMS 方案</th><th>AI 方案</th></tr></thead><tbody><tr><td>非技术人员编辑</td><td>WYSIWYG 编辑器</td><td>自然语言指令</td></tr><tr><td>多语言内容</td><td>i18n 插件、语言区域字段</td><td>AI 结合文化语境进行翻译</td></tr><tr><td>内容定时发布</td><td>内置发布日期</td><td>基于 git 的 CI/CD 配合 cron 或代码中的日期字段</td></tr><tr><td>版本历史</td><td>CMS 修订系统</td><td>Git — 版本控制的黄金标准</td></tr><tr><td>媒体管理</td><td>内置资源库</td><td>CDN + git LFS 或云存储</td></tr></tbody></table>
<p>核心洞察：git 已经是一个比任何 CMS 曾经构建的都更好的版本控制系统。而自然语言是比任何 WYSIWYG 编辑器都更好的接口 — 因为它承载的是意图，而不仅仅是格式。</p>
<h2>范式转变：代码就是内容层</h2>
<p>我们正在见证一次反转。二十年来，趋势是将内容与代码分离 — 把内容放进数据库，通过 API 暴露，在前端渲染。当代码难以编辑、内容需要让非开发者也能访问时，这是有道理的。</p>
<blockquote>AI 并不是通过成为更好的 CMS 来淘汰 CMS 的。它是通过让代码像管理面板一样易于使用，从而淘汰了 CMS。</blockquote>
<p>Web 内容管理的演进遵循着清晰的轨迹：</p>
<ol><li><strong>2000 年代：</strong>单体 CMS（WordPress、Drupal）— 内容与展示耦合在一个系统中</li><li><strong>2010 年代：</strong>Headless CMS（Contentful、Strapi）— 内容通过 API 分离，由前端框架渲染</li><li><strong>2020 年代：</strong>静态站点生成器 + Markdown（Hugo、Astro）— 内容以文件形式存在，部署时构建</li><li><strong>2025+：</strong>代码即内容 + AI — 内容存在于类型化代码中，AI 是编辑接口</li></ol>
<h2>什么时候你仍然需要 CMS</h2>
<p>这不是一个 "CMS 已死" 的论断。CMS 在规模化场景下解决了真实的问题。在以下情况下你仍然需要它：</p>
<ul><li><strong>大型编辑团队：</strong>10 名以上内容编辑需要基于角色的访问控制、审批工作流和同时编辑。Git 合并冲突不是内容编辑该解决的问题。</li><li><strong>高频内容更新：</strong>每天发布 50 篇以上文章的新闻网站需要优化的编辑流水线，而不是 git 提交。</li><li><strong>复杂的内容关联：</strong>拥有数千个 SKU、产品变体和动态定价的电商目录需要结构化数据库。</li><li><strong>合规要求：</strong>需要审计追踪、内容审批链和法律规定的审查流程的行业需要专门构建的系统。</li></ul>
<p>界限很清楚：如果你的内容变更需要多个非技术利益相关者高频协调，CMS 的复杂性就是值得的。如果你是独立开发者、小团队，或者管理的是每周而非每小时更新一次的网站 — AI + 代码更简单、更快、更便宜、也更可靠。</p>
<h2>未来：AI 作为通用接口</h2>
<p>这个趋势远不止 CMS。每一个因为 "底层系统太复杂，无法直接交互" 而存在的软件抽象层，都在被 AI 压缩。管理面板、配置界面、可视化数据库编辑器 — 所有这些都是将人类意图转化为系统变更的接口。AI 天生就能完成这种转化。</p>
<p>对于简单网站，未来已经到来。你的内容就是代码。你的编辑器就是 AI。你的版本控制就是 git。你的部署就是一次推送。整个 CMS 层 — 管理面板、数据库、API、托管 — 曾经是你的意图和你的网站之间的中间件。AI 消除了对这个中间件的需求。</p>
<blockquote>最好的 CMS 就是没有 CMS。不是因为内容管理不重要 — 而是因为 AI 让代码本身成为了我们有史以来最直观的内容管理接口。</blockquote>]]></content:encoded>
      <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
      <category>ai</category>
      <category>cms</category>
      <category>web-development</category>
      <category>opinion</category>
      <category>workflow</category>
    </item>
    <item>
      <title>如何通过 MCP 将 Perplexity AI 连接到 Obsidian——直接从聊天中记笔记</title>
      <link>https://oleksiimazurenko.dev/zh/blog/perplexity-obsidian-mcp-integration</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/zh/blog/perplexity-obsidian-mcp-integration</guid>
      <description>设置 Perplexity Desktop 使用 MCP filesystem 服务器读写您的 Obsidian vault。在一次对话中搜索网络并保存到笔记。</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Perplexity Desktop 支持 <strong>MCP（Model Context Protocol）</strong>连接器。通过添加官方的 <code>@modelcontextprotocol/server-filesystem</code> 服务器指向您的 Obsidian vault，您可以用自然语言告诉 Perplexity 读取、创建和编辑笔记——直接在聊天中完成。无需插件、无需扩展、无需复制粘贴。</p>
<h2>问题</h2>
<p>Perplexity 擅长研究——它搜索网络、总结来源、提供带引用的答案。但当您想将这些发现保存到 Obsidian vault 时，工作流程就中断了：复制文本、切换到 Obsidian、找到正确的笔记、粘贴、格式化。每次都这样。</p>
<p>像 "Perplexity to Obsidian" 这样的浏览器扩展有助于导出，但它们是单向的——AI 看不到您的 vault，无法读取现有笔记，也无法根据文件夹结构决定内容的存放位置。</p>
<h2>什么是 MCP？</h2>
<p><strong>Model Context Protocol（MCP）</strong>是一个开放标准，允许 AI 模型与本地工具和数据源交互。可以将其想象为 AI 的 USB 接口——您插入一个"服务器"（一个小程序），AI 就获得了新能力。在我们的案例中，filesystem 服务器为 Perplexity 提供了 14 个文件操作工具：</p>
<pre><code>Perplexity Desktop (Mac App)
  │
  ├── MCP Connector: &quot;Obsidian Vault&quot;
  │     │
  │     └── @modelcontextprotocol/server-filesystem
  │           │
  │           ├── read_file()      → read any .md file
  │           ├── write_file()     → create or overwrite files
  │           ├── edit_file()      → patch existing files
  │           ├── list_directory() → browse vault structure
  │           ├── search_files()   → find files by pattern
  │           └── ... 14 tools total
  │
  └── Perplexity AI Model
        │
        └── &quot;Save this to my daily note&quot; → calls write_file()</code></pre>
<p>关键点：AI 模型不会直接访问您的文件。它调用 MCP 服务器提供的工具，而该服务器在您的本地机器上运行。除非您明确要求 AI 对数据进行操作，否则您的数据永远不会离开您的计算机。</p>
<h2>要求</h2>
<ul><li><strong>Perplexity Pro</strong> 订阅（MCP 连接器仅对付费用户开放）</li><li>来自 App Store 的 <strong>Perplexity Mac App</strong>（不是浏览器版本）</li><li>Mac 上已安装 <strong>Node.js</strong>（使 <code>npx</code> 可以工作）</li></ul>
<h2>分步设置</h2>
<p>整个设置大约需要 2 分钟：</p>
<table><thead><tr><th>Step</th><th>Action</th></tr></thead><tbody><tr><td>1</td><td>Open &lt;strong&gt;Perplexity Mac App&lt;/strong&gt; (App Store version)</td></tr><tr><td>2</td><td>Click avatar → &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;Connectors&lt;/strong&gt;</td></tr><tr><td>3</td><td>Click &lt;strong&gt;Add Connector&lt;/strong&gt;</td></tr><tr><td>4</td><td>Set Server Name: &lt;code&gt;Obsidian Vault&lt;/code&gt;, Type: &lt;code&gt;Standard I/O&lt;/code&gt;</td></tr><tr><td>5</td><td>Paste the npx command with your vault path</td></tr><tr><td>6</td><td>Leave Environment Variables empty</td></tr><tr><td>7</td><td>Click &lt;strong&gt;Save&lt;/strong&gt; → confirm Security Warning</td></tr><tr><td>8</td><td>Verify status shows &lt;strong&gt;Running&lt;/strong&gt; with 14 tools</td></tr></tbody></table>
<h3>命令</h3>
<p>粘贴到 <strong>Command</strong> 字段中的命令：</p>
<pre><code>npx -y @modelcontextprotocol/server-filesystem &quot;/Users/yourname/Library/Mobile Documents/iCloud~md~obsidian/Documents/Obsidian Vault&quot;</code></pre>
<p>将路径替换为您 Obsidian vault 的实际位置。如果您的 vault 通过 iCloud 同步，路径将在 <code>~/Library/Mobile Documents/iCloud~md~obsidian/Documents/</code> 下。确保保留引号——路径可能包含空格。</p>
<h2>如何使用</h2>
<p>当连接器显示 <strong>Running</strong> 并有 14 个可用工具时，转到任何 Perplexity 聊天并开始与您的 vault 对话：</p>
<pre><code>&gt; Show me the structure of my Obsidian vault
&gt; Add a new reflection to daily notes/2026-04-12.md: &quot;Started using MCP today&quot;
&gt; Find all notes that mention &quot;meditation&quot;
&gt; Create a new note in concepts/ about quantum computing
&gt; List all files in my ideas/ folder</code></pre>
<p>AI 理解您的 vault 结构，尊重您的格式约定，并且可以处理现有内容。您可以要求它在网上研究一个主题，并将摘要直接保存到特定笔记中。</p>
<h2>为什么 MCP 优于其他方法</h2>
<p>在 MCP 之前，连接 Perplexity 和 Obsidian 的方式有限：</p>
<table><thead><tr><th>Feature</th><th>Copy-paste</th><th>Browser extension</th><th>MCP integration</th></tr></thead><tbody><tr><td>AI sees vault structure</td><td>No</td><td>No</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>AI reads existing notes</td><td>No</td><td>No</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>AI writes to vault</td><td>No</td><td>Export only</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>AI edits existing files</td><td>No</td><td>No</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>Works from chat</td><td>No</td><td>Partially</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>Setup complexity</td><td>None</td><td>Low</td><td>Medium (one-time)</td></tr></tbody></table>
<h2>当前限制</h2>
<ul><li><strong>仅限 Mac</strong>——Perplexity 的 MCP 连接器目前仅在 Mac App Store 版本上工作</li><li><strong>无 Obsidian API 集成</strong>——filesystem 服务器直接处理文件，而非通过 Obsidian 的 API。这意味着创建文件时不会触发 Obsidian 插件（Linter、Templater）</li><li><strong>需要批准</strong>——敏感的文件操作可能需要您在 Perplexity 应用中确认——这是安全功能，不是 bug</li></ul>
<h2>结论</h2>
<p>这个设置将 Perplexity 从研究工具变成了研究与捕获工具：</p>
<ol><li>在一次对话中搜索网络并保存到 Obsidian</li><li>AI 看到您的 vault 结构并适应您的组织系统</li><li>零应用切换——一切都在 Perplexity 聊天中完成</li></ol>
<h2>来源</h2>
<ul><li><a href="https://modelcontextprotocol.io" target="_blank" rel="noopener noreferrer">Model Context Protocol</a> — official MCP specification</li><li><a href="https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem" target="_blank" rel="noopener noreferrer">MCP Filesystem Server</a> — the server used in this setup</li><li><a href="https://www.perplexity.ai/help-center/en/articles/11502712-local-and-remote-mcps-for-perplexity" target="_blank" rel="noopener noreferrer">Perplexity Help Center</a> — Local and Remote MCPs for Perplexity</li><li><a href="https://www.perplexity.ai/hub/blog/everything-is-computer" target="_blank" rel="noopener noreferrer">Perplexity Blog</a> — Everything is Computer</li></ul>]]></content:encoded>
      <pubDate>Sun, 12 Apr 2026 00:00:00 GMT</pubDate>
      <category>Obsidian</category>
      <category>Perplexity</category>
      <category>MCP</category>
      <category>AI</category>
      <category>Productivity</category>
    </item>
    <item>
      <title>使用 Claude Code CLI 和 Obsidian 的每日 AI 新闻摘要——零依赖</title>
      <link>https://oleksiimazurenko.dev/zh/blog/ai-news-digest-claude-code-obsidian</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/zh/blog/ai-news-digest-claude-code-obsidian</guid>
      <description>如何用6行bash脚本、Claude Code headless模式和macOS launchd构建每日新闻研究代理。</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>一个6行的bash脚本，每天早上9:00以headless模式运行<strong>Claude Code CLI</strong>。它在11个可配置主题中搜索新闻，过滤噪音，并将格式化的markdown摘要直接写入通过iCloud同步的<strong>Obsidian vault</strong>。零依赖。总共约100行配置。</p>
<h2>问题</h2>
<p>作为开发者，跟踪多种技术是一项日常税收。RSS源嘈杂，Twitter浪费时间，新闻通讯在你深度专注时到达。我需要一个能<em>替</em>我做研究的东西。</p>
<p>典型的解决方案是构建爬虫管道：调度器、爬虫、NLP管道、数据库、通知服务。那需要几周的工作。我想要一个下午就能完成的东西。</p>
<h2>架构</h2>
<p>整个系统只有4个文件和零依赖：</p>
<pre><code>macOS launchd (9:00 AM daily)
  │
  └── digest.sh
        │
        └── claude -p &quot;$(cat prompt.md)&quot; --max-turns 20 --allowedTools Read,WebSearch,WebFetch,Write
              │
              ├── Reads topics.yaml (11 configurable topics)
              ├── WebSearch → finds news for each topic (last 24-48h)
              ├── WebFetch → reads full articles
              ├── Filters noise: old tutorials, promos, AI spam
              └── Write → saves digest to Obsidian Vault
                    │
                    └── ~/Obsidian Vault/digests/2026-04-12.md
                          │
                          └── iCloud sync → available on all devices</code></pre>
<h2>代码（全部）</h2>
<p>项目刻意保持最小化。</p>
<h3>入口：digest.sh</h3>
<p>整个应用程序是一个6行的bash脚本：</p>
<pre><code>#!/bin/bash
DIGEST_DIR=&quot;$HOME/Developer/news-digest&quot;

claude -p &quot;$(cat &quot;$DIGEST_DIR/prompt.md&quot;)&quot; \
  --max-turns 20 \
  --allowedTools Read,WebSearch,WebFetch,Write</code></pre>
<p>关键标志：<code>-p</code>以headless模式运行Claude，<code>--max-turns 20</code>给代理足够的回合，<code>--allowedTools</code>限制代理只能读取、搜索和写入。</p>
<h3>大脑：prompt.md</h3>
<p>这里是智能所在。提示词将Claude变成新闻研究代理：</p>
<pre><code># News Digest Agent

You are a news research agent. Your job is to find today&apos;s most important
and interesting news for a senior frontend developer.

## Instructions

1. Read the topics file at ~/Developer/news-digest/topics.yaml
2. For EACH topic, search the web for news from the last 24-48 hours
3. Filter: only include genuinely new and noteworthy items
4. Write the digest as a markdown file to Obsidian Vault digests/YYYY-MM-DD.md
5. IMPORTANT: Use the Write tool to save the file. Do NOT output to stdout.

## Rules

- Language: Ukrainian for summaries, English for titles and technical terms
- If there&apos;s no real news for a topic — SKIP IT ENTIRELY
- Prioritize: releases &gt; breaking changes &gt; security &gt; new patterns &gt; discussions
- Max 5 items per topic, sorted by importance
- Include direct links to sources
- Skip promotional content, generic tutorials, and AI-generated spam</code></pre>
<h3>配置：topics.yaml</h3>
<p>主题完全可配置——添加新主题，它就会出现在明天的摘要中：</p>
<pre><code>topics:
  - name: better-auth
    context: &quot;auth library for TypeScript. New releases, breaking changes&quot;

  - name: Next.js
    context: &quot;GitHub issues, releases, App Router, Turbopack, performance&quot;

  - name: SolidJS
    context: &quot;SolidStart, releases, ecosystem, comparison with React&quot;

  - name: Tailwind CSS
    context: &quot;v4 updates, new utilities, plugins&quot;

  - name: Claude AI
    context: &quot;Anthropic announcements, Claude Code, new models, MCP, API&quot;

  - name: GPT AI
    context: &quot;OpenAI announcements, new models, ChatGPT features&quot;

  - name: React
    context: &quot;React 19+, Server Components, new patterns, ecosystem&quot;

  - name: Apple
    context: &quot;Hardware, software, WWDC, developer tools, Apple Intelligence&quot;

  - name: Notable People
    context: &gt;
      Latest tweets from: Elon Musk, Dario Amodei, Sam Altman,
      Jensen Huang, Andrej Karpathy, Simon Willison, Swyx...

  - name: AI Global
    context: &quot;Major AI news, new models, regulations, open-source AI&quot;</code></pre>
<h2>使用launchd调度</h2>
<p>在macOS上，<code>launchd</code>是调度重复任务的原生方式：</p>
<pre><code>&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;!DOCTYPE plist PUBLIC &quot;-//Apple//DTD PLIST 1.0//EN&quot;
  &quot;http://www.apple.com/DTDs/PropertyList-1.0.dtd&quot;&gt;
&lt;plist version=&quot;1.0&quot;&gt;
&lt;dict&gt;
    &lt;key&gt;Label&lt;/key&gt;
    &lt;string&gt;com.news-digest&lt;/string&gt;

    &lt;key&gt;ProgramArguments&lt;/key&gt;
    &lt;array&gt;
        &lt;string&gt;/bin/bash&lt;/string&gt;
        &lt;string&gt;/Users/you/Developer/news-digest/digest.sh&lt;/string&gt;
    &lt;/array&gt;

    &lt;key&gt;StartCalendarInterval&lt;/key&gt;
    &lt;dict&gt;
        &lt;key&gt;Hour&lt;/key&gt;
        &lt;integer&gt;9&lt;/integer&gt;
        &lt;key&gt;Minute&lt;/key&gt;
        &lt;integer&gt;0&lt;/integer&gt;
    &lt;/dict&gt;

    &lt;key&gt;StandardOutPath&lt;/key&gt;
    &lt;string&gt;/Users/you/Developer/news-digest/logs/stdout.log&lt;/string&gt;
    &lt;key&gt;StandardErrorPath&lt;/key&gt;
    &lt;string&gt;/Users/you/Developer/news-digest/logs/stderr.log&lt;/string&gt;

    &lt;key&gt;EnvironmentVariables&lt;/key&gt;
    &lt;dict&gt;
        &lt;key&gt;PATH&lt;/key&gt;
        &lt;string&gt;/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin&lt;/string&gt;
        &lt;key&gt;HOME&lt;/key&gt;
        &lt;string&gt;/Users/you&lt;/string&gt;
    &lt;/dict&gt;
&lt;/dict&gt;
&lt;/plist&gt;</code></pre>
<p>安装：<code>launchctl load ~/Library/LaunchAgents/com.news-digest.plist</code>。脚本每天9:00运行。</p>
<h2>输出效果</h2>
<p>每天早上，Obsidian vault中会出现一个新的markdown文件：</p>
<pre><code>---
date: 2026-04-12
---

# News Digest — 2026-04-12

## Next.js

### Next.js 16.1 Released with Improved Turbopack Caching
Нова версія Next.js 16.1 включає покращене кешування для Turbopack,
що зменшує час холодного старту на ~40%.
[Посилання](https://nextjs.org/blog/next-16-1)

### Critical Memory Leak Fix in App Router
Виправлено витік пам&apos;яті в App Router при частому перемиканні
між динамічними маршрутами.
[GitHub Issue](https://github.com/vercel/next.js/issues/...)

## Claude AI

### Claude Code 1.5 — MCP Server Auto-Discovery
Anthropic випустив оновлення Claude Code з автоматичним
виявленням MCP серверів у проєкті.
[Блог](https://www.anthropic.com/news/...)</code></pre>
<h2>数据一览</h2>
<table><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody><tr><td>Total files in project</td><td>&lt;strong&gt;4&lt;/strong&gt; (digest.sh, prompt.md, topics.yaml, .gitignore)</td></tr><tr><td>Lines of code</td><td>&lt;strong&gt;~100&lt;/strong&gt;</td></tr><tr><td>External dependencies</td><td>&lt;strong&gt;0&lt;/strong&gt;</td></tr><tr><td>Setup time</td><td>&lt;strong&gt;~10 minutes&lt;/strong&gt;</td></tr><tr><td>Daily execution time</td><td>&lt;strong&gt;2-5 minutes&lt;/strong&gt;</td></tr><tr><td>Cost per run</td><td>&lt;strong&gt;~$0.10-0.30&lt;/strong&gt; (Claude API usage)</td></tr></tbody></table>
<h2>关键设计决策</h2>
<ul><li><strong>Claude Code CLI而非API</strong>——无需管理API密钥或HTTP客户端</li><li><strong>Obsidian而非邮件</strong>——摘要可搜索、可链接且永久保存</li><li><strong>launchd而非cron</strong>——macOS原生调度器，自动处理错过的运行</li><li><strong>YAML配置主题</strong>——新主题只需改2行</li><li><strong>跳过空主题</strong>——没有新闻=没有该部分</li></ul>
<h2>构建你自己的</h2>
<p>10分钟即可运行：</p>
<ol><li>安装<a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank" rel="noopener noreferrer">Claude Code CLI</a>并认证</li><li>克隆仓库：<code>git clone https://github.com/oleksiimazurenko/news-digest</code></li><li>编辑<code>topics.yaml</code>和<code>prompt.md</code></li><li>编辑plist文件并<code>launchctl load</code></li><li>等待9:00——或用<code>bash digest.sh</code>手动测试</li></ol>
<h2>结论</h2>
<p>这个项目最有趣的是它<em>没有</em>什么。没有数据库、没有API服务器、没有Docker、没有npm包、没有Python、没有HTML解析器、没有NLP管道。</p>
<p>这就是用AI代理构建的样子：你定义<em>什么</em>和<em>哪里</em>，代理处理<em>怎么做</em>。总开发时间：约2小时。</p>
<h2>来源</h2>
<ul><li><a href="https://github.com/oleksiimazurenko/news-digest" target="_blank" rel="noopener noreferrer">news-digest on GitHub</a> — full source code</li><li><a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank" rel="noopener noreferrer">Claude Code Documentation</a> — headless mode and CLI flags</li><li><a href="https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html" target="_blank" rel="noopener noreferrer">Apple Developer</a> — creating launchd jobs</li><li><a href="https://obsidian.md" target="_blank" rel="noopener noreferrer">Obsidian</a> — markdown knowledge base</li></ul>]]></content:encoded>
      <pubDate>Sun, 12 Apr 2026 00:00:00 GMT</pubDate>
      <category>Claude Code</category>
      <category>AI</category>
      <category>Obsidian</category>
      <category>Automation</category>
      <category>Productivity</category>
    </item>
    <item>
      <title>Next.js 暗色模式：无闪烁、无 React 19 警告</title>
      <link>https://oleksiimazurenko.dev/zh/blog/nextjs-dark-mode-without-flash</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/zh/blog/nextjs-dark-mode-without-flash</guid>
      <description>如何用 Zustand + useServerInsertedHTML 替换 next-themes，在 Next.js 15+ 中实现无闪烁暗色模式。</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p><code>next-themes</code> 在 React Client Component 内渲染一个 <code>&lt;script&gt;</code> 标签以防止主题闪烁（FOUC）。React 19 会对此发出警告——且无法抑制。该库自 2025 年 3 月起未再更新。解决方案：用 Zustand store + <code>useServerInsertedHTML</code> 替换 <code>next-themes</code>，在 React 树外注入脚本。零新增依赖。零 FOUC。零警告。</p>
<h2>问题所在</h2>
<p>如果你在 Next.js 15+ 和 React 19 中使用 <code>next-themes</code>，每次页面加载时控制台都会出现以下错误：</p>
<pre><code>Encountered a script tag while rendering React component.
Scripts inside React components are never executed when rendering on the client.
Consider using template tag instead.

src/providers/theme-provider.tsx (7:10) @ ThemeProvider</code></pre>
<p>这不是水合不匹配问题。React 19 明确警告：由 React 组件在客户端渲染的 <code>&lt;script&gt;</code> 标签<strong>永远不会执行</strong>。脚本在 SSR 期间有效（它存在于 HTML 中），但 React 将其标记为不正确。</p>
<h2>原因分析</h2>
<p><code>next-themes</code> 需要在 React 水合之前在 <code>&lt;html&gt;</code> 上设置正确的主题类——否则会出现错误主题的闪烁。为此，它通过 <code>React.createElement</code> 注入内联 <code>&lt;script&gt;</code>：</p>
<pre><code>// next-themes renders a &lt;script&gt; inside a Client Component
return React.createElement(Provider, { value },
  React.createElement(&quot;script&quot;, {
    suppressHydrationWarning: true,
    dangerouslySetInnerHTML: { __html: `(...theme init code...)` }
  }),
  children
)</code></pre>
<p>React 19 改变了行为：组件内的 script 标签现在会被明确标记。在 React 19 之前，这被默默忽略。脚本上的 <code>suppressHydrationWarning</code> 属性无效——它抑制的是水合警告，而非"组件内 script"警告。</p>
<h2>我们尝试过的方案（以及失败原因）</h2>
<p>我们系统地尝试了每一种方法：</p>
<table><thead><tr><th>Attempt</th><th>Result</th></tr></thead><tbody><tr><td>&lt;code&gt;suppressHydrationWarning&lt;/code&gt; on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;</td><td>Suppresses hydration mismatch, but NOT the script tag warning</td></tr><tr><td>Delay mount with &lt;code&gt;useState&lt;/code&gt; + &lt;code&gt;useEffect&lt;/code&gt;</td><td>Warning disappears, but causes FOUC (theme flash)</td></tr><tr><td>Raw &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; in layout &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;</td><td>React 19 catches it — same warning even in Server Components</td></tr><tr><td>&lt;code&gt;next/script&lt;/code&gt; with &lt;code&gt;beforeInteractive&lt;/code&gt;</td><td>Still rendered inside React tree — same warning</td></tr><tr><td>Remove &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, put Script in body</td><td>Same warning — it&apos;s inside &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; which is React-managed</td></tr><tr><td>&lt;code&gt;next-themes@1.0.0-beta.0&lt;/code&gt;</td><td>Beta, no release date, unclear if fixed</td></tr></tbody></table>
<h2>解决方案：Zustand + useServerInsertedHTML</h2>
<p>关键洞察：<code>useServerInsertedHTML</code> 是 Next.js 的一个 hook，它将 HTML 注入 SSR 流中——<strong>在 React 组件树之外</strong>。脚本存在于 HTML 中，但 React 在客户端渲染时永远"看不到"它——因此没有警告。结合 Zustand store 实现响应式主题状态，我们得到了一个完整的替代方案，零新增依赖。</p>
<h3>工作原理</h3>
<pre><code>SSR (server):
  useServerInsertedHTML → injects &lt;script&gt; into HTML stream
  ↓
  Browser receives HTML with theme script already in it
  ↓
  Script runs BEFORE React hydrates → correct class on &lt;html&gt;
  ↓
  No flash. No mismatch. No warning.

Client (after hydration):
  useEffect → _init() → Zustand store syncs with localStorage
  ↓
  useTheme() → reactive theme state for components</code></pre>
<h3>第一步：Zustand Store</h3>
<p>Store 管理主题状态、DOM 类应用、系统主题检测和跨标签页同步。<code>_init()</code> 方法返回一个供 <code>useEffect</code> 使用的清理函数：</p>
<pre><code>import { create } from &quot;zustand&quot;;

const STORAGE_KEY = &quot;theme&quot;;
const MEDIA_QUERY = &quot;(prefers-color-scheme: dark)&quot;;

function getSystemTheme(): &quot;light&quot; | &quot;dark&quot; {
  return window.matchMedia(MEDIA_QUERY).matches ? &quot;dark&quot; : &quot;light&quot;;
}

function applyTheme(resolved: string, disableTransition: boolean) {
  const d = document.documentElement;

  if (disableTransition) {
    const style = document.createElement(&quot;style&quot;);
    style.appendChild(
      document.createTextNode(
        &quot;*,*::before,*::after{transition:none!important}&quot;
      )
    );
    document.head.appendChild(style);
    window.getComputedStyle(document.body);
    setTimeout(() =&gt; document.head.removeChild(style), 1);
  }

  d.classList.remove(&quot;light&quot;, &quot;dark&quot;);
  d.classList.add(resolved);
  d.style.colorScheme = resolved;
}

interface ThemeState {
  theme: string;
  systemTheme: &quot;light&quot; | &quot;dark&quot;;
  resolvedTheme: string;
  setTheme: (theme: string) =&gt; void;
  _init: (disableTransition: boolean) =&gt; () =&gt; void;
}

export const useThemeStore = create&lt;ThemeState&gt;((set, get) =&gt; ({
  theme: &quot;system&quot;,
  systemTheme: &quot;light&quot;,
  resolvedTheme: &quot;light&quot;,

  setTheme: (newTheme) =&gt; {
    const { systemTheme, disableTransitionOnChange } = get();
    const resolved = newTheme === &quot;system&quot; ? systemTheme : newTheme;
    localStorage.setItem(STORAGE_KEY, newTheme);
    applyTheme(resolved, disableTransitionOnChange);
    set({ theme: newTheme, resolvedTheme: resolved });
  },

  _init: (disableTransition) =&gt; {
    const stored = localStorage.getItem(STORAGE_KEY) || &quot;system&quot;;
    const system = getSystemTheme();
    const resolved = stored === &quot;system&quot; ? system : stored;

    set({ theme: stored, systemTheme: system, resolvedTheme: resolved });
    applyTheme(resolved, disableTransition);

    // System theme changes
    const mq = window.matchMedia(MEDIA_QUERY);
    const onChange = (e: MediaQueryListEvent) =&gt; {
      const newSystem = e.matches ? &quot;dark&quot; : &quot;light&quot;;
      const { theme } = get();
      set({ systemTheme: newSystem,
            resolvedTheme: theme === &quot;system&quot; ? newSystem : theme });
      if (theme === &quot;system&quot;) applyTheme(newSystem, disableTransition);
    };
    mq.addEventListener(&quot;change&quot;, onChange);

    // Cross-tab sync
    const onStorage = (e: StorageEvent) =&gt; {
      if (e.key !== STORAGE_KEY || !e.newValue) return;
      const system = getSystemTheme();
      const resolved = e.newValue === &quot;system&quot; ? system : e.newValue;
      set({ theme: e.newValue, resolvedTheme: resolved });
      applyTheme(resolved, disableTransition);
    };
    window.addEventListener(&quot;storage&quot;, onStorage);

    return () =&gt; {
      mq.removeEventListener(&quot;change&quot;, onChange);
      window.removeEventListener(&quot;storage&quot;, onStorage);
    };
  },
}));

export function useTheme() {
  return useThemeStore((s) =&gt; ({
    theme: s.theme,
    setTheme: s.setTheme,
    resolvedTheme: s.resolvedTheme,
    systemTheme: s.systemTheme,
  }));
}</code></pre>
<h3>第二步：ThemeProvider</h3>
<p>Provider 做两件事：通过 <code>useServerInsertedHTML</code> 注入防止 FOUC 的脚本，并在挂载时初始化 Zustand store：</p>
<pre><code>&quot;use client&quot;

import { useEffect } from &quot;react&quot;
import { useServerInsertedHTML } from &quot;next/navigation&quot;
import { useThemeStore } from &quot;@/store/use-theme-store&quot;

const THEME_INIT_SCRIPT = `(function(){
  try {
    var t = localStorage.getItem(&quot;theme&quot;) || &quot;system&quot;;
    var r = t;
    if (t === &quot;system&quot;) {
      r = window.matchMedia(&quot;(prefers-color-scheme: dark)&quot;).matches
        ? &quot;dark&quot; : &quot;light&quot;;
    }
    document.documentElement.classList.remove(&quot;light&quot;, &quot;dark&quot;);
    document.documentElement.classList.add(r);
    document.documentElement.style.colorScheme = r;
  } catch(e) {}
})()`

export function ThemeProvider({
  children,
  disableTransitionOnChange = false,
}) {
  // Injects script into SSR HTML outside the React tree
  // — prevents FOUC without triggering React 19 warning
  useServerInsertedHTML(() =&gt; (
    &lt;script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} /&gt;
  ))

  useEffect(() =&gt; {
    return useThemeStore.getState()._init(disableTransitionOnChange)
  }, [disableTransitionOnChange])

  return &lt;&gt;{children}&lt;/&gt;
}</code></pre>
<h3>第三步：Layout</h3>
<pre><code>import { ThemeProvider } from &quot;@/providers/theme-provider&quot;

export default function RootLayout({ children }) {
  return (
    &lt;html lang=&quot;en&quot; suppressHydrationWarning&gt;
      &lt;body&gt;
        &lt;ThemeProvider disableTransitionOnChange&gt;
          {children}
        &lt;/ThemeProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  )
}</code></pre>
<h3>第四步：使用</h3>
<pre><code>import { useTheme } from &quot;@/store/use-theme-store&quot;

export function ThemeSwitch() {
  const { theme, setTheme } = useTheme();

  return (
    &lt;button onClick={() =&gt; setTheme(
      theme === &quot;dark&quot; ? &quot;light&quot; : &quot;dark&quot;
    )}&gt;
      {theme === &quot;dark&quot; ? &quot;☀️&quot; : &quot;🌙&quot;}
    &lt;/button&gt;
  );
}</code></pre>
<h2>从 next-themes 迁移</h2>
<p>API 设计为完全兼容。每个文件只需更改一处导入即可完成迁移：</p>
<pre><code>- import { useTheme } from &quot;next-themes&quot;
+ import { useTheme } from &quot;@/store/use-theme-store&quot;

  // API is identical — no other changes needed
  const { theme, setTheme, resolvedTheme, systemTheme } = useTheme()</code></pre>
<h2>对比</h2>
<table><thead><tr><th></th><th>next-themes</th><th>Zustand + useServerInsertedHTML</th></tr></thead><tbody><tr><td>React 19 warning</td><td>Yes — &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; in client component</td><td>No</td></tr><tr><td>FOUC prevention</td><td>Yes</td><td>Yes</td></tr><tr><td>Cross-tab sync</td><td>Yes</td><td>Yes</td></tr><tr><td>System theme detection</td><td>Yes</td><td>Yes</td></tr><tr><td>&lt;code&gt;disableTransitionOnChange&lt;/code&gt;</td><td>Yes</td><td>Yes</td></tr><tr><td>Bundle size</td><td>~3.5 KB</td><td>~1.5 KB (uses existing Zustand)</td></tr><tr><td>Dependencies</td><td>+1 (next-themes)</td><td>0 (Zustand already in project)</td></tr><tr><td>Maintenance risk</td><td>Abandoned since March 2025</td><td>Your own code</td></tr></tbody></table>
<h2>为什么不用其他替代方案？</h2>
<h3>@wrksz/themes</h3>
<p>同样使用 <code>useServerInsertedHTML</code> 的即插即用替代品。可以使用，但又是一个单一维护者的依赖项。如果 <code>next-themes</code> 教会了我们什么——依赖项会被遗弃。用 ~100 行代码，你可以完全掌控解决方案。</p>
<h3>next-themes@1.0.0-beta.0</h3>
<p>已发布到 npm，但没有发布日期，没有更新日志，不清楚 React 19 警告是否已修复。不值得用生产代码去赌一个无限期的 beta 版本。</p>
<h3>纯 CSS (prefers-color-scheme)</h3>
<p>适用于系统主题检测，但无法处理用户偏好持久化（localStorage）、手动主题切换或"system"选项。这些需要 JavaScript。</p>
<h2>结论</h2>
<ol><li><code>next-themes</code> 实际上已被放弃——最后一次发布是 2025 年 3 月，React 19 警告未修复</li><li><code>useServerInsertedHTML</code> 是 Next.js 中无需 React 警告进行脚本注入的正确原语</li><li>Zustand 以比 Context provider 更少的代码提供响应式主题状态</li><li>整个解决方案约 100 行，零新增依赖，每一行都由你掌控</li></ol>
<h2>参考资料</h2>
<ul><li><a href="https://github.com/shadcn-ui/ui/issues/10104" target="_blank" rel="noopener noreferrer">shadcn/ui #10104</a> — Dark mode guide should address React 19 script warning</li><li><a href="https://github.com/pacocoursey/next-themes/issues/296" target="_blank" rel="noopener noreferrer">next-themes #296</a> — React 19 support request (open since 2024)</li><li><a href="https://github.com/facebook/react/issues/34008" target="_blank" rel="noopener noreferrer">React #34008</a> — Script tags not executing in components (by design)</li><li><a href="https://dev.to/wrksz/i-was-tired-of-next-themes-being-abandoned-so-i-built-a-drop-in-replacement-4dbk" target="_blank" rel="noopener noreferrer">@wrksz/themes</a> — Drop-in replacement using useServerInsertedHTML</li><li><a href="https://nextjs.org/docs/app/building-your-application/styling/css#server-inserted-html" target="_blank" rel="noopener noreferrer">Next.js Docs</a> — useServerInsertedHTML API</li></ul>]]></content:encoded>
      <pubDate>Sat, 11 Apr 2026 00:00:00 GMT</pubDate>
      <category>next.js</category>
      <category>react-19</category>
      <category>dark-mode</category>
      <category>zustand</category>
      <category>performance</category>
    </item>
    <item>
      <title>我如何用一个文档不全的 Turbopack 功能消除了 Next.js 16 中的 94 个渲染阻塞 CSS 文件</title>
      <link>https://oleksiimazurenko.dev/zh/blog/eliminating-render-blocking-css</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/zh/blog/eliminating-render-blocking-css</guid>
      <description>经过数天尝试每种方法——从 experimental.inlineCss 到 MutationObserver hack——我发现了 Turbopack Import Attributes，它解决了 Next.js App Router 中的渲染阻塞 CSS 问题。</description>
      <content:encoded><![CDATA[<h2>问题</h2>
<p>我们的应用（<a href="https://promova.com" target="_blank" rel="noopener noreferrer">Promova</a>）使用 Next.js 16 和 <strong>Landing Builder</strong>——一个 CMS 驱动的系统，从约 90 个不同的区块组件（hero、FAQ、定价、评论等）组装营销页面。架构使用 <code>sectionRegistry.tsx</code> 将区块名称映射到 <code>next/dynamic()</code> 调用：</p>
<pre><code>// sectionRegistry.tsx — imports all 90 sections
export const sectionRegistry = {
  HeroSection: dynamic(() =&gt; import(&apos;./HeroSection/HeroSection&apos;)),
  FAQSection: dynamic(() =&gt; import(&apos;./FAQSection/FAQSection&apos;)),
  ReviewsSection: dynamic(() =&gt; import(&apos;./ReviewsSection/ReviewsSection&apos;)),
  // ... 87 more
}</code></pre>
<p>单个着陆页只渲染 <strong>5-8 个区块</strong>。但 Lighthouse 显示：</p>
<pre><code>Eliminate render-blocking resources
  94 CSS resources (~330 KB)
  Potential savings: 5,440 ms</code></pre>
<p><strong>为什么？</strong>Turbopack 将所有 90 个 <code>import()</code> 路径视为可达的，并为<strong>每个</strong> SCSS 模块生成 <code>&lt;link rel="stylesheet"&gt;</code>。即使从未在页面上渲染的区块也会将其 CSS 注入到 <code>&lt;head&gt;</code> 中。这是 Next.js App Router 的<a href="https://github.com/vercel/next.js/issues/62485" target="_blank" rel="noopener noreferrer">已确认的预期行为</a>。CSS 不会为来自 Server Components 的 <code>dynamic()</code> 导入进行代码分割。维护者认为这是防止 FOUC 的有意权衡。<strong>不计划修复。</strong></p>
<h2>我尝试过的所有方法（以及为什么失败了）</h2>
<p>我花了好几天时间尝试每一种我能找到的方法。以下是完整列表：</p>
<table><thead><tr><th>Approach</th><th>Why it doesn&apos;t work</th></tr></thead><tbody><tr><td>Split &lt;code&gt;sectionRegistry&lt;/code&gt; into per-section files</td><td>CSS still loaded — &lt;a href=&quot;https://github.com/vercel/next.js/issues/61066&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#61066&lt;/a&gt;</td></tr><tr><td>&lt;code&gt;experimental.inlineCss: true&lt;/code&gt;</td><td>Inlines CSS on &lt;strong&gt;every SSR request&lt;/strong&gt; — crashed our CMS under load</td></tr><tr><td>&lt;code&gt;experimental.optimizeCss&lt;/code&gt; (Critters)</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/62485&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Incompatible with streaming&lt;/a&gt; in App Router</td></tr><tr><td>CSS-in-JS rewrite</td><td>Rewriting 90 sections from SCSS to CSS-in-JS is not realistic</td></tr><tr><td>&lt;code&gt;media=&quot;print&quot;&lt;/code&gt; hack</td><td>Doesn&apos;t work with CSS Modules in Turbopack — &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tags are managed by the framework</td></tr><tr><td>Switch back to Webpack</td><td>Has &lt;code&gt;experiments.css&lt;/code&gt; options, but we&apos;re committed to Turbopack</td></tr><tr><td>Turbopack plugins</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/discussions/85465&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Don&apos;t exist&lt;/a&gt; — no plugin API</td></tr><tr><td>Turbopack CSS loaders</td><td>Not supported — only JS output</td></tr><tr><td>SWC plugins for CSS</td><td>SWC only processes JavaScript</td></tr><tr><td>Client Component wrapper for &lt;code&gt;dynamic()&lt;/code&gt;</td><td>Registry is a global constant — bundler sees all dependencies</td></tr><tr><td>Next.js middleware HTML rewrite</td><td>&lt;a href=&quot;https://github.com/orgs/vercel/discussions/3874&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Middleware can&apos;t modify response body&lt;/a&gt;</td></tr><tr><td>Suspense + streaming with async import</td><td>React Float always pulls CSS into initial &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;</td></tr><tr><td>Suspense + 3s delay</td><td>Content streams later, but CSS is in the initial &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; chunk</td></tr><tr><td>&lt;code&gt;experimental.cssChunking&lt;/code&gt;</td><td>Already default. Merges chunks but doesn&apos;t remove irrelevant CSS</td></tr><tr><td>Post-build Beasties/Critters</td><td>Only for static export, &lt;a href=&quot;https://github.com/vercel/next.js/discussions/59989&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;not with ISR&lt;/a&gt;</td></tr><tr><td>npm libraries (next-critical, fg-loadcss)</td><td>Pages Router only or abandoned (6+ years)</td></tr><tr><td>&lt;code&gt;inlineCss&lt;/code&gt; per-route exclusion</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/pull/72195&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Not supported&lt;/a&gt; — all-or-nothing global flag</td></tr><tr><td>Turbopack &lt;code&gt;as: &apos;raw&apos;&lt;/code&gt; for SCSS</td><td>Returns &lt;code&gt;undefined&lt;/code&gt; instead of text</td></tr><tr><td>Turbopack rule for &lt;code&gt;*.inline.module.scss&lt;/code&gt;</td><td>Turbopack intercepts &lt;code&gt;.module.scss&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; custom rules</td></tr><tr><td>Turbopack rule for &lt;code&gt;*.inline.scss&lt;/code&gt;</td><td>Turbopack intercepts &lt;code&gt;.scss&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; custom rules</td></tr><tr><td>MutationObserver &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;</td><td>Tested in dev, risky — CSS still loads as separate files, possible FOUC</td></tr></tbody></table>
<h3>inlineCss 的陷阱</h3>
<p>Next.js 有一个 <code>experimental.inlineCss</code> 标志，将所有 <code>&lt;link rel="stylesheet"&gt;</code> 替换为内联 <code>&lt;style&gt;</code> 标签。听起来完美，对吧？</p>
<p>问题是：它是<strong>全部或没有</strong>。不能按路由启用。如果你有 SSR（<code>force-dynamic</code>）页面，每个请求都会重新构建所有内联 CSS。我们试过了——我们的 headless CMS 无法承受负载而崩溃了。</p>
<h2>发现：Turbopack Import Attributes</h2>
<p>在研究 <a href="https://nextjs.org/blog/next-16-2-turbopack#inline-loader-configuration" target="_blank" rel="noopener noreferrer">Next.js 16.2 发布说明</a>时，我发现了一个文档不足的功能：<strong>Turbopack Import Attributes</strong>。它允许你使用 TC39 <code>with {}</code> 语法为特定导入覆盖内置的打包器管道：</p>
<pre><code>import { cssText, styles } from &apos;./hero.module.scss&apos; with {
  turbopackLoader: &apos;@promova/scss-to-inline-loader&apos;,
  turbopackAs: &apos;*.js&apos;
}</code></pre>
<p>这告诉 Turbopack：<em>"不要将此导入处理为样式表。通过我的自定义 loader 运行它，并将输出视为 JavaScript。"</em></p>
<p><strong>这是关键洞察。</strong>我们的 loader 编译 SCSS 并将其导出为 JS 字符串，而不是让 Turbopack 生成阻塞渲染的 <code>&lt;link rel="stylesheet"&gt;</code>。然后我们将其作为内联 <code>&lt;style&gt;</code> 标签直接注入组件中。结果：<strong>只有实际渲染的区块的 CSS 才会进入页面 HTML</strong>。</p>
<h2>解决方案</h2>
<h3>1. 自定义 Turbopack Loader</h3>
<p>一个约 70 行的 Node.js 脚本，作为 yarn workspace 包（<code>@promova/scss-to-inline-loader</code>）：</p>
<pre><code>const { createHash } = require(&apos;node:crypto&apos;)
const sass = require(&apos;sass&apos;)
const postcss = require(&apos;postcss&apos;)
const postcssModules = require(&apos;postcss-modules&apos;)
const path = require(&apos;path&apos;)

const SHARED_STYLES = path.resolve(__dirname, &apos;../shared/styles&apos;)

// Same prependData as sassOptions.prependData in next.config.ts
const PREPEND_DATA = `
@use &quot;sass:math&quot; as math;
@use &quot;sass:list&quot; as list;
@use &quot;${SHARED_STYLES}/_variables.scss&quot; as *;
@use &quot;${SHARED_STYLES}/_breakpoints.scss&quot; as *;
@use &quot;${SHARED_STYLES}/_mixins.scss&quot; as *;
`

module.exports = function scssToInlineLoader(source) {
  const callback = this.async()
  processScss(source, this.resourcePath)
    .then((js) =&gt; callback(null, js))
    .catch((err) =&gt; callback(err))
}

async function processScss(source, resourcePath) {
  // 1. Compile SCSS → CSS (with global variables, mixins, breakpoints)
  const sassResult = sass.compileString(PREPEND_DATA + &apos;\n&apos; + source, {
    loadPaths: [path.dirname(resourcePath), SHARED_STYLES],
    style: &apos;compressed&apos;,
    sourceMap: false,
    url: new URL(&apos;file://&apos; + resourcePath),
  })

  // 2. Scope class names with postcss-modules (CSS Modules compatible)
  let classNames = {}
  const result = await postcss([
    postcssModules({
      generateScopedName(name, filename) {
        const file = path.basename(filename, path.extname(filename))
          .replace(&apos;.module&apos;, &apos;&apos;)
        const hash = createHash(&apos;md5&apos;)
          .update(filename + name)
          .digest(&apos;base64url&apos;)
          .slice(0, 6)
        return `${file}__${name}__${hash}`
      },
      getJSON(_, json) { classNames = json },
    }),
  ]).process(sassResult.css, { from: resourcePath })

  // 3. Export as JS module — same interface as CSS Modules
  return [
    `export const cssText = ${JSON.stringify(result.css)};`,
    `export const styles = ${JSON.stringify(classNames)};`,
    `export default styles;`,
  ].join(&apos;\n&apos;)
}</code></pre>
<p>它的作用：<strong><code>styles</code></strong>——与标准 CSS Modules 相同的作用域类名映射。<strong><code>cssText</code></strong>——编译后的 CSS 字符串。</p>
<h3>2. InlineStyle 组件</h3>
<p>使用 React 19 内置的 <code>&lt;style href precedence&gt;</code> API 进行自动去重：</p>
<pre><code>export function InlineStyle({ css, id }: { css: string; id: string }) {
  return (
    &lt;style href={id} precedence=&quot;default&quot;&gt;
      {css}
    &lt;/style&gt;
  )
}</code></pre>
<p>React 19 保证：相同的 <code>href</code> → DOM 中只有一个 <code>&lt;style&gt;</code>。</p>
<h3>3. 逐组件迁移（每个区块约 6 行）</h3>
<pre><code>// BEFORE: Turbopack → &lt;link rel=&quot;stylesheet&quot;&gt; (render-blocking)
import styles from &apos;./hero_section.module.scss&apos;</code></pre>
<pre><code>// AFTER: Custom loader → JS module → inline &lt;style&gt; (non-blocking)
import { cssText, styles } from &apos;./hero_section.module.scss&apos; with {
  turbopackLoader: &apos;@promova/scss-to-inline-loader&apos;,
  turbopackAs: &apos;*.js&apos;
}
import { InlineStyle } from &apos;@promova/scss-to-inline-loader/InlineStyle&apos;

export function HeroSection() {
  return (
    &lt;section className={styles.hero}&gt;
      &lt;InlineStyle css={cssText} id=&quot;hero-section&quot; /&gt;
      {/* ... component JSX, className usage is identical */}
    &lt;/section&gt;
  )
}</code></pre>
<p><strong><code>.module.scss</code> 文件保持完全不变。</strong>无需重写 CSS。</p>
<h2>为什么这比 inlineCss: true 更好</h2>
<p>这是关键区别：</p>
<table><thead><tr><th></th><th>&lt;code&gt;inlineCss: true&lt;/code&gt;</th><th>Import Attributes + Loader</th></tr></thead><tbody><tr><td>What gets inlined</td><td>&lt;strong&gt;ALL CSS on the page&lt;/strong&gt; (94 files)</td><td>&lt;strong&gt;Only CSS for rendered sections&lt;/strong&gt; (5-8 files)</td></tr><tr><td>SSR overhead</td><td>CSS rebuilt on every request</td><td>CSS compiled at build time</td></tr><tr><td>Per-route control</td><td>No (all-or-nothing)</td><td>Yes (per-import)</td></tr><tr><td>SSR pages safety</td><td>Risky (overloads server)</td><td>Safe (component-level)</td></tr></tbody></table>
<p>使用 <code>inlineCss: true</code>，页面仍然会内联所有 94 个样式表。使用我们的方法，<strong>只有实际渲染的 CSS 才会进入 HTML</strong>。</p>
<h2>Turbopack 的坑：.module.scss 没有全局规则</h2>
<p>我踩过的坑：你可能觉得可以在 <code>next.config.ts</code> 中添加 Turbopack 规则来全局应用 loader：</p>
<pre><code>// ❌ THIS CAUSES A FATAL PANIC
turbopack: {
  rules: {
    &apos;**/components/**/*.module.scss&apos;: {
      loaders: [&apos;@promova/scss-to-inline-loader&apos;],
      as: &apos;*.js&apos;
    }
  }
}</code></pre>
<p><strong>不要这样做。</strong>Turbopack 内置的 CSS 模块管道在应用自定义规则<strong>之前</strong>就拦截了 <code>.module.scss</code> 文件，导致：</p>
<pre><code>FATAL PANIC: inner asset should be CSS processable</code></pre>
<p><code>with {}</code> 属性之所以有效，是因为它们在<strong>导入位置</strong>指示 Turbopack 完全绕过 CSS 模块管道。</p>
<h2>结果</h2>
<p>在 Landing Builder 中迁移了 127 个区块组件。生产构建已验证。</p>
<table><thead><tr><th>Metric</th><th>Before</th><th>After</th></tr></thead><tbody><tr><td>Render-blocking CSS files</td><td>94</td><td>0 (for landing sections)</td></tr><tr><td>CSS in HTML per page</td><td>~330 KB (all sections)</td><td>~20-40 KB (rendered sections only)</td></tr><tr><td>CSS delivery</td><td>&lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; (network request, blocking)</td><td>&lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; (inline, non-blocking)</td></tr><tr><td>Sass features</td><td>Full</td><td>Full (variables, mixins, nesting, @use)</td></tr><tr><td>CSS Modules scoping</td><td>Built-in</td><td>postcss-modules (compatible)</td></tr><tr><td>Code change per section</td><td>—</td><td>~6 lines (import + InlineStyle)</td></tr></tbody></table>
<h2>限制</h2>
<ul><li><strong>每个导入的 <code>with {}</code> 很冗长</strong>——每个导入需要 3 行额外代码。</li><li><strong>仅限 Turbopack</strong>——<code>with {}</code> 属性不受 Webpack 支持。</li><li><strong>类名哈希</strong>——我们的 loader 使用与 Turbopack 内置的不同的哈希算法。</li><li><strong>HTML 大小增加</strong>——CSS 内联在 HTML 中，而不是作为单独的缓存文件。</li></ul>
<h2>何时使用</h2>
<p>此技术在以下情况下最有效：</p>
<ol><li><strong>你有 registry/barrel 模式</strong>——一个文件导入许多组件，但每页只渲染几个</li><li><strong>你使用 Turbopack</strong>——Import Attributes 是 Turbopack 特有的</li><li><strong>你想要逐组件控制</strong>——不是全有或全无的标志</li><li><strong>你的 SCSS 很复杂</strong>——变量、混入、断点、嵌套——全部支持</li><li><strong>你无法使用 <code>experimental.inlineCss</code></strong>——因为你有 SSR 页面或想要精细控制</li></ol>
<h2>相关 GitHub Issues</h2>
<p>如果你受到 Next.js App Router 中渲染阻塞 CSS 的影响——你不是一个人：</p>
<h3>核心问题</h3>
<ul><li><a href="https://github.com/vercel/next.js/issues/62485" target="_blank" rel="noopener noreferrer"><strong>#62485</strong></a> — Render blocking CSS (maintainers: "expected behavior")</li><li><a href="https://github.com/vercel/next.js/issues/61066" target="_blank" rel="noopener noreferrer"><strong>#61066</strong></a> — Dynamic imports from Server Components are not code-split</li><li><a href="https://github.com/vercel/next.js/issues/54935" target="_blank" rel="noopener noreferrer"><strong>#54935</strong></a> — Server-side dynamic imports don't split client modules</li><li><a href="https://github.com/vercel/next.js/issues/61574" target="_blank" rel="noopener noreferrer"><strong>#61574</strong></a> — JS/CSS code splitting doesn't work as documented</li><li><a href="https://github.com/vercel/next.js/issues/57634" target="_blank" rel="noopener noreferrer"><strong>#57634</strong></a> — Add support for critical CSS inlining with App Router</li><li><a href="https://github.com/vercel/next.js/issues/50300" target="_blank" rel="noopener noreferrer"><strong>#50300</strong></a> — next/dynamic on server component does not build CSS modules</li></ul>
<h3>inlineCss 功能与问题</h3>
<ul><li><a href="https://github.com/vercel/next.js/pull/72195" target="_blank" rel="noopener noreferrer"><strong>PR #72195</strong></a> — experimental: css inlining (the implementation)</li><li><a href="https://github.com/vercel/next.js/pull/73182" target="_blank" rel="noopener noreferrer"><strong>PR #73182</strong></a> — Don't inline CSS in RSC payload for client navigation</li><li><a href="https://github.com/vercel/next.js/issues/75648" target="_blank" rel="noopener noreferrer"><strong>#75648</strong></a> — WhatsApp preview broken with inlineCss + Tailwind</li><li><a href="https://github.com/vercel/next.js/issues/83612" target="_blank" rel="noopener noreferrer"><strong>#83612</strong></a> — Turbopack wrong font URL with inline CSS</li></ul>
<h3>社区寻求解决方案</h3>
<ul><li><a href="https://github.com/vercel/next.js/discussions/82894" target="_blank" rel="noopener noreferrer"><strong>Discussion #82894</strong></a> — How to prevent render-blocking with CSS Modules? (2025)</li><li><a href="https://github.com/vercel/next.js/discussions/70526" target="_blank" rel="noopener noreferrer"><strong>Discussion #70526</strong></a> — Ideas for reducing render-blocking CSS</li><li><a href="https://github.com/vercel/next.js/discussions/59814" target="_blank" rel="noopener noreferrer"><strong>Discussion #59814</strong></a> — Render blocking styles with Tailwind</li><li><a href="https://github.com/vercel/next.js/discussions/49691" target="_blank" rel="noopener noreferrer"><strong>Discussion #49691</strong></a> — How to deal with render-blocking modular CSS</li><li><a href="https://github.com/vercel/next.js/discussions/59989" target="_blank" rel="noopener noreferrer"><strong>Discussion #59989</strong></a> — Critical CSS inlining with App Router</li><li><a href="https://github.com/vercel/next.js/discussions/80486" target="_blank" rel="noopener noreferrer"><strong>Discussion #80486</strong></a> — Is optimizeCss still in use?</li><li><a href="https://github.com/vercel/next.js/discussions/85465" target="_blank" rel="noopener noreferrer"><strong>Discussion #85465</strong></a> — Turbopack plugin API (doesn't exist)</li></ul>
<h3>Turbopack CSS 缺陷</h3>
<ul><li><a href="https://github.com/vercel/next.js/issues/68412" target="_blank" rel="noopener noreferrer"><strong>#68412</strong></a> — Incorrect CSS modules load order with external components</li><li><a href="https://github.com/vercel/next.js/issues/76464" target="_blank" rel="noopener noreferrer"><strong>#76464</strong></a> — Turbopack sometimes strips SCSS imports</li><li><a href="https://github.com/vercel/next.js/issues/82497" target="_blank" rel="noopener noreferrer"><strong>#82497</strong></a> — SCSS module not loading when not-found.tsx is present</li><li><a href="https://github.com/vercel/next.js/issues/88544" target="_blank" rel="noopener noreferrer"><strong>#88544</strong></a> — Exporting Sass variables from CSS modules doesn't work with Turbopack</li></ul>
<p>由 <a href="https://promova.com" target="_blank" rel="noopener noreferrer">Promova</a> 构建——一个服务数百万用户的语言学习平台。</p>]]></content:encoded>
      <pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate>
      <category>Next.js</category>
      <category>CSS</category>
      <category>Performance</category>
      <category>Turbopack</category>
      <category>SCSS</category>
    </item>
    <item>
      <title>Next.js 内存泄漏：Fetch + Standalone 模式——2年未修复</title>
      <link>https://oleksiimazurenko.dev/zh/blog/nextjs-memory-leak-fetch-standalone</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/zh/blog/nextjs-memory-leak-fetch-standalone</guid>
      <description>Next.js 修补全局 fetch 并添加一个在每次请求时泄漏内存的缓存层。在 Docker/K8s 中，这会导致每隔几小时发生 OOM 崩溃。该 bug 自 Next.js 14 以来就存在，在 16.2.x 中仍未解决。</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Next.js 修补了全局 <code>fetch</code> 并添加了一个缓存层，该层在响应数据应该被释放后仍持有引用。每次 <code>fetch</code> 调用都会添加永远不会被 GC 回收的内存。在 Docker/Kubernetes 中，这会导致每隔几小时就发生 OOM 崩溃。该 bug 自 Next.js 14（2024年4月）以来就存在，在 16.2.x（2026年3月）中仍未解决。在 Vercel 上由于短暂的 serverless 函数，问题不会显现。</p>
<h2>正常 fetch 的工作方式</h2>
<pre><code>Request → fetch → got data → response to user → GC cleans up → memory free</code></pre>
<h2>Next.js 中 fetch 的工作方式</h2>
<p>Next.js 拦截全局 <code>fetch</code> 并用自己的缓存/追踪层包装它：</p>
<pre><code>Request → Next.js fetch wrapper → creates:
  - Performance entry (speed tracking)    ← not cleaned
  - Request metadata object               ← not cleaned
  - Internal promise wrapper              ← not cleaned
  - Cache lookup entry                    ← not cleaned
  - Response body (for unique URL)        ← not cleaned
→ Response to user
→ GC sees objects are still referenced → doesn&apos;t touch them
→ Memory grows indefinitely</code></pre>
<h2>生产环境中发生的情况</h2>
<pre><code>Self-hosted (Docker/K8s):
  Request 1:      fetch → +100KB → RAM: 100KB
  Request 2:      fetch → +100KB → RAM: 200KB
  Request 1000:   fetch → +100KB → RAM: 100MB
  Request 50,000: fetch → +100KB → RAM: 5GB → OOM Kill 💀
  → Kubernetes restarts the pod → cycle repeats

Vercel (Serverless):
  Request 1: [start] → fetch → +100KB → [process dies] → 0 KB ✅
  Request 2: [start] → fetch → +100KB → [process dies] → 0 KB ✅
  → Memory never accumulates</code></pre>
<h2>受影响的版本</h2>
<h3>Next.js — 所有带 App Router 的版本</h3>
<table><thead><tr><th>Version</th><th>Issue</th><th>Date</th></tr></thead><tbody><tr><td>14.2.x</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/64212&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#64212&lt;/a&gt;</td><td>April 2024</td></tr><tr><td>14.x-15.x</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/68578&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#68578&lt;/a&gt;</td><td>August 2024</td></tr><tr><td>14.3.0-canary</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/79588&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#79588&lt;/a&gt;</td><td>May 2025</td></tr><tr><td>16.0.1</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/85914&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#85914&lt;/a&gt;</td><td>November 2025</td></tr><tr><td>16.1.0</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/discussions/88603&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#88603&lt;/a&gt;</td><td>January 2026</td></tr><tr><td>16.0.10</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/90433&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#90433&lt;/a&gt;</td><td>February 2026</td></tr><tr><td>16.2.0-canary.51</td><td>Confirmed in #90433 comments</td><td>March 2026</td></tr></tbody></table>
<h3>Node.js</h3>
<p>在 Node.js 20、22、24、25 上测试——全部泄漏。</p>
<h2>无效的方法</h2>
<table><thead><tr><th>Attempt</th><th>Result</th></tr></thead><tbody><tr><td>&lt;code&gt;cacheMaxMemorySize: 0&lt;/code&gt;</td><td>Doesn&apos;t help — leak is not from this cache</td></tr><tr><td>Disable image optimization</td><td>Doesn&apos;t help</td></tr><tr><td>&lt;code&gt;--max-old-space-size=6144&lt;/code&gt;</td><td>Just crashes slower</td></tr><tr><td>Read response via &lt;code&gt;.json()&lt;/code&gt; / &lt;code&gt;.text()&lt;/code&gt;</td><td>Doesn&apos;t help</td></tr><tr><td>Remove React fetch patching (canary.45)</td><td>Doesn&apos;t help</td></tr><tr><td>Upgrade to latest version</td><td>Doesn&apos;t help (leaks on 16.2.0-canary.51 too)</td></tr></tbody></table>
<h2>有效的方法（变通方案）</h2>
<table><thead><tr><th>Workaround</th><th>Why it helps</th></tr></thead><tbody><tr><td>Replace &lt;code&gt;fetch&lt;/code&gt; with &lt;code&gt;axios&lt;/code&gt;</td><td>Bypasses Next.js wrapper</td></tr><tr><td>Replace &lt;code&gt;fetch&lt;/code&gt; with &lt;code&gt;node-fetch&lt;/code&gt;</td><td>Same reason</td></tr><tr><td>Downgrade Node.js to 20.15.1</td><td>Older undici has fewer leaks (for some)</td></tr><tr><td>Docker image &lt;code&gt;node:20-alpine3.21&lt;/code&gt;</td><td>Helps in some cases</td></tr><tr><td>Deploy to Lambda (SST + OpenNext)</td><td>Ephemeral functions — memory doesn&apos;t accumulate</td></tr><tr><td>Deploy to Vercel</td><td>Same — serverless</td></tr></tbody></table>
<h3>axios 变通方案的限制</h3>
<p>你自己的 API 调用可以替换为 axios。但 Next.js 内部使用被修补的 fetch 用于：</p>
<ul><li>ISR (Incremental Static Regeneration)</li><li><code>revalidatePath</code> / <code>revalidateTag</code></li><li>Server Components 数据获取与去重</li><li><code>use cache</code> (Next.js 16)</li></ul>
<p>即使代码中没有一个 <code>fetch</code>——Next.js 仍然在内部使用它。</p>
<h2>Vercel 为什么不修复</h2>
<h3>商业逻辑</h3>
<p>Vercel 是一家靠托管 Next.js 赚钱的公司。在他们的平台上问题不会显现（serverless = 短暂的）。该 bug 只影响自托管（Docker、K8s、VPS）——那些不付费给 Vercel 的人。</p>
<h3>官方立场</h3>
<p>Tim Neutkens（Vercel 维护者）分析了这个问题并宣称这是 <strong>undici</strong>（Node.js fetch 库）的问题，而非 Next.js。Issue #90433 被关闭了。尽管：</p>
<ul><li>在相同的 Node.js 上，axios 和 node-fetch 没有泄漏</li><li>泄漏仅在 fetch 通过 Next.js 包装器时出现</li><li>该 bug 已开放 2 年未修复</li></ul>
<h3>优先级</h3>
<p>这 2 年里 Next.js 团队发布了：</p>
<ul><li>Turbopack（构建速度快 2-5 倍）——营销优势</li><li>Cache Components / <code>use cache</code> ——减轻 Vercel 服务器负载</li><li><code>proxy.ts</code> 替代 middleware——简化 Vercel 的 edge 部署</li><li>DevTools MCP——AI 热潮</li></ul>
<p>自托管中的内存泄漏？不是优先事项。</p>
<h2>解决方案：AWS Lambda（SST + OpenNext）</h2>
<h3>这是什么</h3>
<p>OpenNext 是一个开源适配器，将 Next.js 构建转换为 AWS Lambda 格式。SST 是一个自动化基础设施的框架。</p>
<h3>架构</h3>
<pre><code>Next.js build
  → OpenNext
    → AWS Lambda (SSR, API routes)
    → S3 (static, assets)
    → CloudFront (CDN)
    → SQS + DynamoDB (ISR revalidation)</code></pre>
<h3>为什么这能解决内存泄漏</h3>
<p>Lambda 函数处理请求并在 5-15 分钟不活动后被回收。内存来不及积累。</p>
<h3>部署</h3>
<pre><code>npx sst@latest init
npx sst deploy --stage production</code></pre>
<h3>比较</h3>
<table><thead><tr><th></th><th>Vercel</th><th>AWS Lambda (SST)</th><th>Docker self-hosted</th></tr></thead><tbody><tr><td>Memory leak</td><td>Not felt</td><td>Not felt</td><td>Critical</td></tr><tr><td>Cold starts</td><td>Yes</td><td>Yes (~200-500ms)</td><td>No</td></tr><tr><td>Price (~medium traffic)</td><td>$20-150/mo</td><td>$5-30/mo</td><td>$5-50/mo</td></tr><tr><td>Control</td><td>Minimal</td><td>Full</td><td>Full</td></tr><tr><td>ISR/Revalidation</td><td>Works</td><td>Works (SQS)</td><td>Works (with leak)</td></tr><tr><td>Vendor lock-in</td><td>Vercel</td><td>AWS</td><td>None</td></tr></tbody></table>
<h3>Lambda 注意事项</h3>
<ul><li><strong>冷启动</strong>——第一个请求较慢（~200-500ms）</li><li><strong>安全性</strong>——启用 OAC（Origin Access Control），否则 Lambda URL 是公开的</li><li><strong>OpenNext</strong>——社区项目，非 Vercel 官方。Next.js 新功能可能会出问题</li><li><strong>钱包攻击</strong>——在 DDoS 期间，Lambda 自动扩展可能导致高额账单</li></ul>
<h2>为什么真正的修复不现实</h2>
<h3>1. 架构问题</h3>
<p>泄漏不是偶然的 bug，而是设计决策的结果：Next.js 拦截全局 <code>fetch</code> 并在其上添加缓存/追踪。要修复它，需要重新设计 App Router 与 fetch 的交互方式。这涉及 ISR、revalidation、data cache、request deduplication——框架的核心。</p>
<h3>2. 利益冲突</h3>
<p>Vercel 没有动力去修复不影响其平台的问题。自托管与其业务竞争。自托管问题越多——迁移到 Vercel 的人越多。</p>
<h3>3. 推卸责任</h3>
<p>官方立场是 "这是 undici 的问题，不是我们的"。在这改变之前——他们不会着手修复。</p>
<h3>4. 没有社区修复</h3>
<p>Next.js 的 AGPL-3.0 许可证允许 fork，但代码库庞大且与 Vercel 基础设施紧密耦合。修复 fetch 包装器的社区 PR 需要深入了解内部架构并获得维护者的批准——而他们已经关闭了 issue。</p>
<h2>结论</h2>
<ol><li><strong>如果在 Vercel 上</strong>——没问题，无需操作</li><li><strong>如果自托管且需要 serverless</strong>——在 AWS Lambda 上使用 SST + OpenNext</li><li><strong>如果自托管 Docker</strong>——尽可能用 axios 替换 fetch，监控 RAM，设置自动 pod 重启</li><li><strong>如果开始新项目</strong>——考虑 SvelteKit 或 Nuxt 作为没有此问题的替代方案</li></ol>
<h2>来源</h2>
<ul><li><a href="https://github.com/vercel/next.js/issues/64212" target="_blank" rel="noopener noreferrer">Issue #64212</a> — Memory Leak with global fetch (April 2024)</li><li><a href="https://github.com/vercel/next.js/issues/68578" target="_blank" rel="noopener noreferrer">Issue #68578</a> — Possible memory leak in Fetch API (August 2024)</li><li><a href="https://github.com/vercel/next.js/issues/85914" target="_blank" rel="noopener noreferrer">Issue #85914</a> — Memory Leak with fetch + standalone (November 2025)</li><li><a href="https://github.com/vercel/next.js/issues/90433" target="_blank" rel="noopener noreferrer">Issue #90433</a> — OOM in 16.0.10 (February 2026)</li><li><a href="https://github.com/vercel/next.js/discussions/88603" target="_blank" rel="noopener noreferrer">Discussion #88603</a> — OOM in Docker/K8s (January 2026)</li><li><a href="https://github.com/vercel/next.js/discussions/88078" target="_blank" rel="noopener noreferrer">Discussion #88078</a> — cacheMaxMemorySize behavior</li><li><a href="https://opennext.js.org/" target="_blank" rel="noopener noreferrer">OpenNext</a></li><li><a href="https://sst.dev/docs/start/aws/nextjs/" target="_blank" rel="noopener noreferrer">SST — Next.js on AWS</a></li><li><a href="https://www.flightcontrol.dev/blog/secret-knowledge-to-self-host-nextjs" target="_blank" rel="noopener noreferrer">Secret knowledge to self-host Next.js</a></li><li><a href="https://nextjs.org/docs/app/guides/memory-usage" target="_blank" rel="noopener noreferrer">Next.js Memory Usage Guide</a></li></ul>]]></content:encoded>
      <pubDate>Thu, 19 Mar 2026 00:00:00 GMT</pubDate>
      <category>Next.js</category>
      <category>Memory Leak</category>
      <category>Docker</category>
      <category>AWS Lambda</category>
      <category>Node.js</category>
    </item>
  </channel>
</rss>