<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" 
  xmlns:content="http://purl.org/rss/1.0/modules/content/" 
  xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>MoonGate</title>
    <link>https://moongate.top</link>
    <description>Where Moon Meets Code</description>
    <language>zh-CN</language>
    <lastBuildDate>Sat, 15 Aug 2026 12:57:06 GMT</lastBuildDate>

    <item>
      <title><![CDATA[Flutter 桌面端：输入框设计的细节与边界]]></title>
      <link>https://moongate.top/docs/flutter-desktop-input-design</link>
      <guid isPermaLink="true">66503c7c-d972-44cc-89d1-a8df5262c224</guid>
      <pubDate>Thu, 13 Aug 2026 23:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>从「按回车没反应」到「方向键区回车又能换行」，这些桌面端输入框的坑，背后的根源其实是一个焦点模型的问题。</p>
</blockquote>

<h2 id="引子-一个用户报来的-bug">引子：一个用户报来的 bug</h2>

<p>&ldquo;在输入框里打字后，第一次回车会换行，第二次才正常提交。&rdquo;</p>

<p>这是 Flutter 桌面端开发中极具代表性的问题：<strong>移动端的输入逻辑无法直接平移到桌面端</strong>。移动端软键盘上的「发送」按钮天然触发 <code>onSubmitted</code>；而桌面端有实体键盘，回车、Shift、方向键都是独立可见的物理事件，它们的语义需要开发者自己定义。</p>

<p>（背景说明：这个输入框来自一个 AI 驱动的交互式叙事应用，用户以「命运」身份输入指令，AI 据此展开故事。输入框和流式回复是这个应用最核心的两个交互入口，所以它的细节值得认真打磨。）</p>

<p>我最初的做法很「直觉」：用一个外层 <code>Focus</code> 包裹 TextField，在里面拦截 Enter 键。结果就有了文章开头那个 bug——第一次回车成了换行。</p>

<h2 id="一-键盘事件真正的传播路径">一、键盘事件真正的传播路径</h2>

<h3 id="直觉为什么是错的">直觉为什么是错的</h3>

<p>大多数人（包括我）会这样写：</p>

<pre><code class="language-dart">Expanded(
  child: Focus(
    onKeyEvent: _handleKeyEvent, // 外层 Focus 拦截
    child: TextField(
      focusNode: _focusNode,
      maxLines: null, // 桌面端多行
      textInputAction: TextInputAction.newline,
    ),
  ),
)
</code></pre>

<p>看起来 <code>onKeyEvent</code> 应该能收到所有按键。但实际上，<strong>键盘事件先到达真正获得焦点的节点</strong>——也就是 TextField 内部的 <code>EditableText</code>——而不是你外面套的那层 <code>Focus</code>。</p>

<p>在 <code>maxLines: null</code> + <code>textInputAction: newline</code> 的组合下，<code>EditableText</code> 收到 Enter 后会：</p>

<ol>
<li>在内部插入一个换行符</li>
<li>返回 <code>KeyEventResult.handled</code>（标记事件已消费）</li>
</ol>

<p>事件一旦被 <code>handled</code>，就<strong>不会继续冒泡</strong>到外层 <code>Focus</code>。你的 <code>_handleKeyEvent</code> 根本收不到这个事件，自然无法拦截。第一次回车变成了换行，第二次才「碰巧」正常提交。</p>

<p>这里「冒泡」这个词，前端读者自然会联想到 <strong>JS 的 DOM 事件冒泡</strong>。两者确实有一个共同点：事件从某一点开始，沿一条链向上传播，中途可以被消费从而停止。但「传播路径」和「中途停止」的细节<strong>完全不同</strong>：</p>

<table>
<thead>
<tr>
<th></th>
<th>JS DOM 事件</th>
<th>Flutter 键盘事件</th>
</tr>
</thead>

<tbody>
<tr>
<td>传播路径由谁决定</td>
<td><strong>DOM 树</strong></td>
<td><strong>焦点链（Focus Chain）</strong></td>
</tr>

<tr>
<td>视觉包裹 = 传播路径？</td>
<td>是</td>
<td>否（焦点关系 ≠ 包裹关系）</td>
</tr>

<tr>
<td>传播方向</td>
<td>capture 下行 → target → 冒泡上行</td>
<td>焦点节点 → 沿焦点链逐级向上</td>
</tr>

<tr>
<td>中途阻止</td>
<td><code>stopPropagation()</code></td>
<td>返回 <code>KeyEventResult.handled</code></td>
</tr>

<tr>
<td>关键区别</td>
<td>任意 DOM 祖先都能收到事件</td>
<td>内层节点可<strong>提前消费</strong>，事件在冒泡到达祖先前就被截断</td>
</tr>
</tbody>
</table>
<p>在 JS 里，外层 <code>div</code> 包裹内层 <code>input</code>，事件<strong>一定</strong>会经过外层 <code>div</code>——视觉包裹关系就是传播路径，你在外层拦截理所当然。但在 Flutter 里，<strong>事件走的是焦点链，而不是 widget 树的包裹关系</strong>：TextField 内部的 <code>EditableText</code> 是当前焦点节点，事件从它开始沿焦点链向上传播。外层 <code>Focus</code> 作为 <code>EditableText</code> 的祖先，<strong>节点确实在焦点链上</strong>——但问题是 <code>EditableText</code> 处理 Enter 时返回了 <code>KeyEventResult.handled</code>，<strong>事件冒泡在到达外层 <code>Focus</code> 之前就被截断了</strong>，根本轮不到外层处理。这才是「外层 Focus 包裹 TextField 却拦截不到回车」的真正原因：不是节点不在链上，而是事件在到达它之前就已被消费。</p>

<h3 id="正确的挂载点">正确的挂载点</h3>

<p>把键盘事件处理<strong>直接绑定在 TextField 自己的 <code>FocusNode</code> 上</strong>：</p>

<pre><code class="language-dart">late FocusNode _focusNode;

@override
void initState() {
  super.initState();
  _focusNode = FocusNode(onKeyEvent: _handleKeyEvent);
}

// build 中不再需要外层 Focus 包裹
Expanded(
  child: TextField(
    focusNode: _focusNode,
    // ...
  ),
)
</code></pre>

<p>这样 <code>_handleKeyEvent</code> 会在 <code>EditableText</code> 处理之前先运行。Enter（无 Shift）返回 <code>handled</code> 阻止换行并发送；Shift+Enter 返回 <code>ignored</code> 放行给 TextField 插入换行。</p>

<p><strong>教训</strong>：在 Flutter 里，「包裹 widget」不等于「能拦截深层的键盘事件」。要拦截什么，就得把监听器挂在事件真正经过的那个节点上。</p>

<h2 id="二-同一个-回车-两个键码">二、同一个「回车」，两个键码</h2>

<p>修复了第一次回车换行后，用户又报来一个新问题：「<strong>方向键区域的回车键还是会导致换行</strong>」。</p>

<p>同一个回车键，为什么字母区正常、方向键区不正常？</p>

<p>因为在 Flutter 里，这两个「回车」是<strong>不同的键码</strong>：</p>

<table>
<thead>
<tr>
<th>键</th>
<th><code>LogicalKeyboardKey</code></th>
</tr>
</thead>

<tbody>
<tr>
<td>主键盘区 Enter</td>
<td><code>enter</code></td>
</tr>

<tr>
<td>方向键区上方 / 数字小键盘 Enter</td>
<td><code>numpadEnter</code></td>
</tr>
</tbody>
</table>
<p>而我的判断写的是：</p>

<pre><code class="language-dart">if (event.logicalKey == LogicalKeyboardKey.enter) {
</code></pre>

<p><code>numpadEnter</code> 不匹配，<code>_handleKeyEvent</code> 对它返回 <code>ignored</code>，事件放行到 TextField，照常插入换行。</p>

<p>修复只需把两个键码都匹配：</p>

<pre><code class="language-dart">if (event.logicalKey == LogicalKeyboardKey.enter ||
    event.logicalKey == LogicalKeyboardKey.numpadEnter) {
</code></pre>

<p><strong>教训</strong>：桌面端键盘不是「一个键 = 一个语义」。同一个物理动作（按回车）在不同区域可能对应不同键码——尤其当你匹配键位时，要想到主键盘区之外的存在。</p>

<h2 id="三-shift-enter-的语义不能丢">三、Shift+Enter 的语义不能丢</h2>

<p>桌面端有一个通行惯例：<strong>Enter 发送、Shift+Enter 换行</strong>。这在聊天工具、终端、编辑器里几乎一致。</p>

<p>实现时要注意的是：Shift+Enter 应该<strong>放行</strong>给 <code>EditableText</code> 处理，而不是自己构造换行：</p>

<pre><code class="language-dart">if (HardwareKeyboard.instance.isShiftPressed) {
  // Shift+Enter → 交给 TextField 插入换行
  return KeyEventResult.ignored;
}
</code></pre>

<p>为什么「放行」比「自己插入换行」可靠？</p>

<ul>
<li>让 EditableText 处理换行，能正确维护光标位置、选择区、IME 组合状态</li>
<li>自己往 controller 里塞 <code>\n</code>，在输入法（中文拼音等）组合期间容易破坏光标上下文</li>
</ul>

<p>判断 Shift 状态用的是 <code>HardwareKeyboard.instance.isShiftPressed</code>——这是 Flutter 当前提供的全局硬件键盘状态查询。需要说明的是：<code>KeyDownEvent</code> 本身<strong>并不携带修饰键状态</strong>（<code>KeyEvent</code> 只有 <code>physicalKey</code> / <code>logicalKey</code> / <code>character</code> / <code>timeStamp</code> 等字段，没有 <code>modifiers</code>），所以判断 Shift 必须依赖 <code>HardwareKeyboard</code> 这个全局单例。</p>

<p>全局状态有一个应注意的边界：它反映的是「<strong>此刻</strong>」的硬件状态，而非「该事件发生的那一刻」。在快速连续按键、弹窗切换焦点后突然松开修饰键等场景，理论上可能读到滞后状态。Flutter 未来的 <code>KeyEvent</code> API 演进方向是让事件携带 <code>modifiers</code> 快照（类似 Web 的 <code>KeyboardEvent</code>），届时事件级判断会比全局状态更可靠——但在当前 Flutter 版本，<code>HardwareKeyboard.instance.isShiftPressed</code> 就是可用的标准做法。</p>

<p>值得一提的是，这里<strong>不需要也不建议</strong>自建「修饰键状态缓存」（自行在 KeyDown 置 true、KeyUp 置 false）——因为 <code>HardwareKeyboard</code> 本身就是 Flutter 框架层维护的全局状态：它内部通过 KeyDown / KeyUp 事件流 + 合成事件（synthesized event）同步机制，保证其状态与事件序列严格一致。举例：焦点切换导致 Shift 松开事件丢失时，Flutter 会注入合成事件来修正状态。自建缓存反而更容易在焦点切换、合成事件等边界场景出错——这正是框架替你处理掉的那部分复杂度。</p>

<h2 id="四-输入历史-widget-生命周期-数据生命周期">四、输入历史：Widget 生命周期 ≠ 数据生命周期</h2>

<h3 id="问题-退出重进后-失效">问题：退出重进后 ↑ / ↓ 失效</h3>

<p>桌面端加了「↑ / ↓ 回溯最近 5 条输入」的快捷键后，第一轮测试一切正常——发送几条消息，按 ↑ 能逐条找回。但用户又说：「退出重新进入之后，↑ 键就没用了。」</p>

<p>原因很简单：</p>

<pre><code class="language-dart">class _InputBarState extends State&lt;InputBar&gt; {
  final List&lt;String&gt; _history = []; // ← 纯内存，Widget 销毁即清空
}
</code></pre>

<p>输入历史被放在了 <code>State</code> 里。当前游玩时 <code>InputBar</code> 一直活着，历史正常累积；一旦退出叙事页、<code>InputBar</code> 被销毁重建，<code>_history</code> 被重置为空。</p>

<p><strong>Widget 生命周期 ≠ 数据生命周期</strong>。<code>State</code> 是为「界面状态」而生的（滚动位置、当前输入框内容），而不是为「用户数据」而生的（跨会话要保留的输入历史）。把持久化数据放进 <code>State</code> 是反模式。</p>

<h3 id="正确方案-state-提升-持久化">正确方案：State 提升 + 持久化</h3>

<p>借鉴 Riverpod 的 <code>Notifier</code> 模式，把输入历史提升为一个全局 Provider，用 <code>SharedPreferences</code> 持久化：</p>

<pre><code class="language-dart">class InputHistoryNotifier extends Notifier&lt;List&lt;String&gt;&gt; {
  static const int maxHistory = 5;
  static const String key = 'mephisto_input_history';

  @override
  List&lt;String&gt; build() =&gt; const [];

  Future&lt;void&gt; push(String text) async {
    if (state.isNotEmpty &amp;&amp; state.last == text) return; // 相邻去重
    final next = [...state, text];
    if (next.length &gt; maxHistory) next.removeAt(0);
    state = next;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(key, jsonEncode(next));
  }
}

// 一个可选的初始化器：从持久化恢复
</code></pre>

<p><code>InputBar</code> 从 <code>State</code> 改为 <code>ConsumerState</code> 后：</p>

<pre><code class="language-dart">List&lt;String&gt; get _history =&gt; ref.watch(inputHistoryProvider);
</code></pre>

<p>发送时写入 Provider，重建后从 Provider 读——历史跨会话保留。</p>

<h3 id="一个设计决策-全局共享-还是按契约隔离">一个设计决策：全局共享，还是按契约隔离？</h3>

<p>用户有过一个很合理的顾虑：&rdquo;如果我有多个正在进行的子版，历史都会保存吗？性能有影响吗？&rdquo;</p>

<p>我最终选了<strong>全局单列表</strong>：</p>

<ul>
<li>存储恒定：单个 <code>SharedPreferences</code> key，最多 5 条短文本（约几 KB），<strong>不随子版文件数量增长</strong></li>
<li>语义合理：不同剧本/分支经常使用类似的方向词（「调查」「询问」「前往」），全局共享反而更方便</li>
<li>实现简单：无需 <code>Map&lt;文件名, List&lt;String&gt;&gt;</code> 序列化</li>
</ul>

<p>按子版隔离（<code>Map</code> 结构）性能上其实也毫无压力（每个子版也就几 KB），但实现复杂、收益有限。对个人项目而言，全局单列表是「够用且简单」的正确取舍。</p>

<p>一个前瞻风险：全局单列表的写入是<strong>异步 <code>setString</code></strong>，如果未来支持<strong>多窗口 / 多 Tab 同时编辑</strong>，存在理论上并发写覆盖的可能（两个窗口各自 push 后互相覆盖）。当前方案适用于单窗口串行场景；若将来引入多窗口，需为写入加防抖合并，或改用文件锁 / 数据库（如 <code>sqlite</code>）保证原子性。</p>

<p>还有一个极端场景的权衡：<code>SharedPreferences.setString</code> 是异步写入，如果使用者在 <code>await</code> 完成前直接关闭应用或系统强杀进程，最后一次写入可能丢失。鉴于输入历史属于<strong>「辅助体验」而非「核心资产」</strong>（丢了只是 ↑ 键少回溯一次，不会损坏叙事数据），这个极低概率的丢失是可接受的——因此未采用双写或事务日志这类过度设计。</p>

<h2 id="五-如何测试这些交互边界">五、如何测试这些交互边界</h2>

<h3 id="键盘事件-模拟桌面端平台">键盘事件：模拟桌面端平台</h3>

<p><code>testWidgets</code> 默认在 FakeAsync 中运行，可以用 <code>sendKeyEvent</code> 直接模拟按键。关键是<strong>指定平台</strong>——<code>InputBar._isDesktop</code> 依据 <code>Theme.of(context).platform</code> 判定，默认是 Android 而非桌面端：</p>

<pre><code class="language-dart">await tester.pumpWidget(buildInputBar(onSend: sent.add)); // 内部设置 ThemeData(platform: linux)
await tester.enterText(find.byType(TextField), '命运指引');
await tester.sendKeyEvent(LogicalKeyboardKey.enter, platform: 'linux');
await tester.pump();

expect(sent, ['命运指引']); // 第一次回车即提交
</code></pre>

<p>同理可以测 Numpad Enter、↑ / ↓ 回溯。</p>

<h3 id="fakeasync-的局限-真实异步-io-不会自动完成">FakeAsync 的局限：真实异步 IO 不会自动完成</h3>

<p>当测试涉及「持久化 → 重建 → 恢复」时，我踩到了一个坑：<code>testWidgets</code> 的 FakeAsync 中，<strong>SharedPreferences 的读取 Future 不会自动完成</strong>——<code>pumpAndSettle</code> 只驱动 scheduled frames，不驱动纯异步 IO。</p>

<p>我最初的「输入历史持久化」widget 测试怎么都过不了：第一次会话写入历史 → 销毁重建 → 按 ↑ 却得不到之前的历史。尝试了 <code>runAsync</code>、多段 <code>pump</code>，最终在这两者之间总是有一层时序矛盾。</p>

<p><strong>结论：不要把「持久化 round-trip」和「UI 回溯」强行塞进一个 widget 测试</strong>。测试拆解更稳定：</p>

<ul>
<li><strong>Provider 层单测</strong>：验证 <code>push</code> 写入、重建容器后能恢复（round-trip）、去重、上限、JSON 损坏容错</li>
<li><strong>Widget 层测试</strong>：验证 ↑ / ↓ 回溯交互行为（在已 mock 持久化的 Provider 之上）</li>
</ul>

<p>两者各自聚焦，互不干扰，避免了组合测试受 FakeAsync 与真实 IO 时序矛盾影响的脆弱性。</p>

<p>Provider 层单测的骨架长这样——<code>SharedPreferences.setMockInitialValues</code> 一行就搞定了内存 mock：</p>

<pre><code class="language-dart">test('round-trip：push 后重建容器可恢复', () async {
  SharedPreferences.setMockInitialValues({});

  final container1 = ProviderContainer();
  await container1.read(inputHistoryProvider.notifier).push('测试历史');
  container1.dispose();

  // 重建容器（模拟应用重启）→ AutoLoadNotifier 从内存 mock 中恢复
  final container2 = ProviderContainer();
  await container2.read(inputHistoryProvider.notifier).load();
  expect(container2.read(inputHistoryProvider), ['测试历史']);
});
</code></pre>

<p>注意：Provider 层的 <code>load()</code> 是纯异步方法，可以在普通 <code>test()</code> 中直接 <code>await</code>，不必碰 <code>testWidgets</code> 的 FakeAsync——这正是把持久化逻辑从 widget 中剥离出来带来的可测性红利。</p>

<p>更进一步的架构取向：可以把持久化抽象为接口（如 <code>InputHistoryStore</code>），Provider 依赖接口而非直接依赖 <code>SharedPreferences</code>——测试时注入内存实现，<strong>彻底摆脱 FakeAsync 与真实 IO 的时序矛盾</strong>。<code>setMockInitialValues</code> 是 Flutter 自带的轻量 mock，足够覆盖当前场景；接口注入则是在需要更严格隔离时的升级路径。</p>

<h2 id="结语">结语</h2>

<p>桌面端输入框的「边界感」来自对三件事的理解：</p>

<ol>
<li><strong>键盘事件的传播路径</strong>：拦截要挂在真正的焦点节点（<code>FocusNode</code>）上，而非外层包裹 widget——事件在 <code>handled</code> 后不会冒泡</li>
<li><strong>键码的物理身份</strong>：匹配物理键（Enter）要考虑 <code>numpadEnter</code>；修饰键状态（Shift）在事件不携带时，可通过 <code>HardwareKeyboard</code> 全局状态查询（并留意其「此刻而非事件当下」的边界）</li>
<li><strong>数据的生命周期</strong>：什么数据放 State、什么数据提升到 Provider + 持久化，取决于它是否需要跨 Widget 生命周期存活——并用分层测试验证（round-trip 放 Provider 层，UI 交互放 widget 层）</li>
</ol>

<p>这些细节在移动端几乎不会遇到——移动端只有一个软键盘回车，也没有「退出重进后状态要保留」的实体文件概念。但一旦要做桌面端，「功能正确」和「体验正确」之间就出现了这道需要认真思考的边界。</p>

<h2 id="术语表">术语表</h2>

<table>
<thead>
<tr>
<th>术语</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>LogicalKeyboardKey</code></td>
<td>Flutter 对按键的「逻辑键」抽象（键位 + 键盘布局映射后），如 <code>enter</code> / <code>numpadEnter</code></td>
</tr>

<tr>
<td><code>PhysicalKeyboardKey</code></td>
<td>物理键位（USB HID 码），与键盘布局无关</td>
</tr>

<tr>
<td><code>KeyEventResult</code></td>
<td>键盘事件处理结果：<code>handled</code>（已消费，不再向上传播）/ <code>ignored</code>（放行，继续传播）</td>
</tr>

<tr>
<td>焦点链（Focus Chain）</td>
<td>键盘事件沿「焦点节点 → 祖先」传播的路径，与 widget 包裹关系无关</td>
</tr>

<tr>
<td><code>HardwareKeyboard</code></td>
<td>Flutter 维护的全局键盘状态（按键 / 修饰键 / 锁定键）查询入口</td>
</tr>
</tbody>
</table>

<hr>

<p>项目：<a href="https://github.com/yuelinghuashu/mephisto-gui" target="_blank">Mephisto</a></p>
]]></content:encoded>
      <description><![CDATA[从「第一次回车换行，第二次才提交」这个 bug 说起，深入 Flutter 的 Focus 链、键码区分、Shift+Enter 语义与输入历史持久化。]]></description>
      <category><![CDATA[Flutter]]></category>
      <category><![CDATA[Dart]]></category>
      <category><![CDATA[State Management]]></category>
      <category><![CDATA[Design System]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:deployment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Flutter 流式 UI：AI 回复的打字机体验是怎么实现的]]></title>
      <link>https://moongate.top/docs/flutter-streaming-typewriter</link>
      <guid isPermaLink="true">5c50dd33-81e8-4281-a26d-32fe5765fc94</guid>
      <pubDate>Thu, 13 Aug 2026 22:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>打字机效果看起来简单：文字一个字一个字蹦出来。但「跳过动画」「不截断」「性能不退化」这些细节，藏着一整套工程决策。</p>

<p>文中实现基于 Flutter/Dart，但「跳过 ≠ 中止」「缓冲合并」等核心语义决策<strong>跨框架通用</strong>——Web 的 EventSource、原生/RN 的 SSE 客户端都会遇到同样的选择。</p>
</blockquote>

<h2 id="引子-一个-跳过打字机-按钮的翻车过程">引子：一个「跳过打字机」按钮的翻车过程</h2>

<p>我在一个 AI 叙事应用（用户通过输入命运指令影响 AI 驱动的交互式故事走向）里做了一个「⏩ 跳过打字机」按钮——用户点击后，应该直接看到完整的 AI 回复，而不是等文字慢慢蹦完。</p>

<p>这个按钮在开发日志里经历了三个阶段：</p>

<ul>
<li><strong>第一版</strong>：点了没反应——回调被调用了，用户却什么都没感受到</li>
<li><strong>第二版</strong>：点了内容被截断——动画确实没了，但回复也残缺了</li>
<li><strong>最终版</strong>：点了一下子显示已有全文，LLM 仍在后台悄悄生成完整内容，生成完毕后一次性补齐</li>
</ul>

<p>这三个版本背后，是「流式 UI」这个领域里最容易踩的三个坑。这篇文章把它拆开讲清楚。</p>

<h2 id="一-从-sse-到屏幕-流式渲染的管道">一、从 SSE 到屏幕：流式渲染的管道</h2>

<h3 id="llm-为什么是-蹦-着出字的">LLM 为什么是「蹦」着出字的</h3>

<p>LLM 的回复通过 HTTP 的 SSE（Server-Sent Events）分 chunk 返回。一个典型 chunk 长这样：</p>

<pre><code class="language-text">data: {&quot;choices&quot;:[{&quot;delta&quot;:{&quot;content&quot;:&quot;梅菲斯特出现在&quot;}}]}

data: {&quot;choices&quot;:[{&quot;delta&quot;:{&quot;content&quot;:&quot;书斋门口。&quot;}}]}

data: [DONE]
</code></pre>

<p>每个 chunk 到达的间隔取决于模型生成速度——快的时候几十毫秒，慢的时候可能上秒级。这段「逐字出现」的效果，就是用户感知到的打字机动画。</p>

<h3 id="为什么不能每-chunk-都更新-ui">为什么不能每 chunk 都更新 UI</h3>

<p>如果每收到一个 chunk 就触发一次状态更新，一个几百字的回复可能触发几十上百次 UI 重建，长文本下明显卡顿。所以要做<strong>节流合并</strong>：</p>

<pre><code class="language-dart">final _buffer = StringBuffer();
Timer? _timer;

void onChunk(String chunk) {
  _buffer.write(chunk);
  // 50ms 窗口内累积，窗口结束统一提交一次
  _timer ??= Timer(const Duration(milliseconds: 50), flush);
}

void flush() {
  _timer?.cancel();
  _timer = null;
  state = state.copyWith(content: _buffer.toString());
}
</code></pre>

<p>50ms 的节流窗口把「几十次通知」降为「几百毫秒一次通知」。这个数字不是随便定的：人眼能感知的流畅动画通常在 60fps 以上（约 16.6ms/帧），但<strong>打字机「蹦字」的间隔通常在 100-300ms</strong>——50ms 的合并窗口远小于打字机的感知粒度，对使用者视觉零影响，却能有效合并高频的 chunk 帧渲染，显著降低 UI 开销。</p>

<h3 id="stringbuffer-避免-o-n²-拼接">StringBuffer：避免 O(n²) 拼接</h3>

<p>每次提交时，如果把完整内容存为 <code>pending</code>，再 <code>state.streamingContent = current + pending</code>，长回复下就是反复全量拼接——<strong>O(n²) 复杂度</strong>。</p>

<p>正确做法是维护一个 <code>StringBuffer</code> 累积器，提交时用 <code>toString()</code> 一次性构建：</p>

<pre><code class="language-dart">final _streamingBuffer = StringBuffer();

String applyAndGet(String pending) {
  _streamingBuffer.write(pending);
  return _streamingBuffer.toString(); // O(总长)，而不是 O(n²)
}
</code></pre>

<h2 id="二-跳过动画-的正确语义-静默累积-中止生成">二、「跳过动画」的正确语义：静默累积 ≠ 中止生成</h2>

<p>这是整个流式体验里最微妙、最容易搞反的一个点。</p>

<h3 id="第一版误区-只跳过-ui-节流">第一版误区：只跳过 UI 节流</h3>

<p>我最初的实现是这样：</p>

<pre><code class="language-dart">void revealStreaming() {
  _revealInstant = true;    // 后续 chunk 跳过 50ms 节流
  _flushStreamBuffer();     // 立即提交已缓冲内容
}
</code></pre>

<p><code>_revealInstant = true</code> 后，后续 chunk 不再经过节流，直接提交到 UI。<strong>但这完全没有解决使用者的问题</strong>——因为打字机效果的真实来源是 <strong>LLM 逐 chunk 返回</strong>，而不是 UI 的 50ms 节流。</p>

<p>LLM 仍然是几秒内慢慢吐完整个回复。使用者看到的效果：点击按钮后，文字依然一个接一个出现。「跳过打字机」变成了「跳过节流」，在感知上约等于什么都没做。</p>

<h3 id="第二版误区-触发取消信号">第二版误区：触发取消信号</h3>

<p>为了让「跳过」真正生效，我加了取消信号：</p>

<pre><code class="language-dart">void revealStreaming() {
  _revealInstant = true;
  _flushStreamBuffer();
  cancelGeneration(); // 让 LLM 停止生成
}
</code></pre>

<p>这次确实「立即」了——因为点击后 LLM 被中止，已到达的内容连同 <code>[DONE]</code> 一起作为最终回复返回，所有「剩余未生成的内容」也就永远丢失了。</p>

<p>用户反馈：「点跳过之后，内容直接截断了，之后的内容不再出现。」</p>

<p><strong>问题</strong>：我把「跳过动画」理解成了「跳过生成」。但用户想要的只是「不想看动画」，而不是「不让 AI 把话说完了」。</p>

<h3 id="正解-静默累积-让-llm-把话说完">正解：静默累积，让 LLM 把话说完</h3>

<p>「跳过动画」的正确语义是：<strong>停止 UI 逐字更新</strong>——打字机光标消失、文字不再一点点蹦——但 <strong>LLM 继续在后台完整生成</strong>，生成完毕后一次性把完整回复写进消息列表。</p>

<pre><code class="language-dart">void _appendStreamChunk(String chunk) {
  if (_revealInstant) {
    // 跳过打字机：静默忽略后续 chunk，不触发 UI 重建
    // 完整内容由生成完毕后的 ReplySucceeded 一次性写入
  } else {
    _streaming.append(chunk, _applyStreamChunk);
  }
}
</code></pre>

<p>点击 ⏩ 后：</p>

<ol>
<li><strong>立即 flush</strong> 当前已到达的内容到消息气泡（使用者立刻看到已有全文）</li>
<li><code>_revealInstant = true</code>，后续 chunk <strong>静默忽略</strong>——UI 不再逐字更新</li>
<li>LLM 继续后台生成完毕，<code>ReplySucceeded</code> 携带<strong>完整 <code>reply</code></strong> 一次性写入消息列表</li>
</ol>

<p>关键区分就一句话：</p>

<blockquote>
<p><strong>停止 UI 逐字更新 ≠ 中止 LLM 生成</strong></p>
</blockquote>

<h3 id="一个需要正视的体验空窗">一个需要正视的体验空窗</h3>

<p>静默累积带来一个极端的体验场景：如果模型极慢且回复超长，使用者点击 ⏩ 时 LLM 可能才生成了前 10 个字，而剩余 2000 字还在排队——从点击跳到 <code>ReplySucceeded</code> 之间可能有长达 10-20 秒的<strong>完全无 UI 反馈</strong>空窗期。此时仅靠「光标消失」是不够的，使用者可能误以为应用卡死了。</p>

<p>产品层面的解法：在跳过模式下显示一个极淡的「后台生成中…」指示条（弱化视觉打扰，但让使用者知道系统仍在工作）。这是静默累积「稳住即时体验」与「保护使用者耐心」的平衡点——技术决策之下，往往还跟着一个体验决策。</p>

<h3 id="一个真实存在的兜底缺陷">一个真实存在的兜底缺陷</h3>

<p>静默累积有一个必须正视的风险：<strong>如果 LLM 生成失败（网络超时、服务端 5xx），<code>ReplySucceeded</code> 永远不会到来</strong>。此时不能永远卡在「加载中」。</p>

<p>我的做法是依赖生成流程的全局兜底：</p>

<pre><code class="language-dart">} catch (e, st) {
  debugPrint('生成回复异常: $e\n$st');
  _dispatch(const GenerationFailed(narrativeErrorGenFailed)); // 复位生成状态
} finally {
  endGeneration(); // 释放防重入标志，允许下一条消息
}
</code></pre>

<p>但这暴露了一个真实的体验缺陷：<code>GenerationFailed</code> 会清空 <code>streamingContent</code>——<strong>使用者点 ⏩ 时已经看到的部分内容会消失</strong>，只剩下一条错误提示。</p>

<p>这个问题其实可以<strong>立即修复</strong>，不必等到下一版。既然 <code>_revealInstant = true</code> 后是「静默忽略」后续 chunk，那么失败时的兜底逻辑应当是：<strong>若已进入跳过模式，失败时先 flush 累积缓冲，将已生成的部分作为一条「不完整回复」归档，再标记失败</strong>：</p>

<blockquote>
<p>说明：<code>_flushStreamBuffer()</code> 会把节流缓冲中已累积的内容提交到界面（即 <code>streamingContent</code>）；<code>_streamingContent</code> 就是当前已显示的流式文本。失败兜底时先把这部分作为「不完整回复」归档，再置错误。</p>
</blockquote>

<pre><code class="language-dart">} catch (e, st) {
  debugPrint('生成回复异常: $e\n$st');
  if (_revealInstant) {
    // 跳过模式下：先把已累积的内容作为「不完整回复」归档，保住使用者看到的部分。
    // _flushStreamBuffer() 提交缓冲 → _streamingContent 为当前已显示的流式文本
    _flushStreamBuffer();
    final partial = _streamingContent;
    _dispatch(ReplySucceeded(
      reply: partial,
      // 状态/记忆等沿用当前值……
    ));
  }
  _dispatch(const GenerationFailed(narrativeErrorGenFailed));
} finally {
  endGeneration();
}
</code></pre>

<p>这只改失败分支、不动主框架，就能做到「跳过动画后即使生成失败，已看到的内容也不丢失」。</p>

<h2 id="三-数据一致性-流式中断不丢内容">三、数据一致性：流式中断不丢内容</h2>

<p>Mephisto 里有两个「打断生成」的入口：</p>

<ul>
<li><strong>⏹ 停止</strong>：使用者明确想中断，保留已生成内容</li>
<li><strong>⏩ 跳过</strong>：使用者不想看动画，但希望 LLM 把话说完</li>
</ul>

<p>它们底层复用同一个「协作式取消」信号——<code>LlmClient</code> 收到取消信号后，在下一条 SSE 数据行处停止读取，并返回<strong>已累积的完整内容</strong>：</p>

<pre><code class="language-dart">// LlmClient 的 SSE 循环内
await for (final line in response.stream...) {
  if (isCancelled()) break; // 协作式：下一条数据行处停止
  // ...解析 delta、写入 fullContent、回调 onChunk
}
return fullContent.toString(); // 返回已累积内容，而非抛异常
</code></pre>

<ul>
<li>⏹ <code>stopGenerating()</code>：触发取消 <code>+</code> flush 已显示内容 → 生成流程以「已累积内容」正常收尾</li>
<li>⏩ <code>revealStreaming()</code>：只置 <code>_revealInstant</code>，<strong>不触发取消</strong> → LLM 继续生成，最终完整提交</li>
</ul>

<p>两者都靠「取消后返回已累积内容」保证<strong>不丢已有数据</strong>；区别在于「是否让 LLM 继续」。</p>

<blockquote>
<p><strong>术语澄清</strong>：这里的「取消信号」是<strong>客户端主动停止读取 SSE 流</strong>（下一条数据行处 break），等于客户端断开接收——<strong>服务端/模型可能仍在后台继续生成、继续消耗算力</strong>。它不是一个「向服务端发送中止指令」的操作；LLM API 通常不支持 client-side cancellation。理解这点很重要：你点了「停止」只是自己不再接收，服务端的成本并不会因此立刻终止。</p>
</blockquote>

<h2 id="四-如何测试流式场景">四、如何测试流式场景</h2>

<p>流式 UI 是最难测的场景之一：时序敏感、涉及真实网络 IO、还夹着节流缓冲。好在 Flutter 测试里可以用 <code>MockClient.streaming</code> + <code>StreamController</code> 精确控制时间点。</p>

<h3 id="可控分块流">可控分块流</h3>

<p>核心思路：用一个 <code>StreamController</code> 手动推送 SSE chunk，在<strong>任意时间点</strong>注入「使用者点击 ⏩」的调用，验证后续行为。</p>

<pre><code class="language-dart">final controller = StreamController&lt;List&lt;int&gt;&gt;();
final streamingClient = MockClient.streaming((request, bodyStream) async {
  return http.StreamedResponse(
    controller.stream,
    200,
    headers: {'content-type': 'text/event-stream; charset=utf-8'},
  );
});
</code></pre>

<h3 id="测试-跳过动画不截断-的关键三步">测试「跳过动画不截断」的关键三步</h3>

<pre><code class="language-dart">// 1. 第一段内容到达
controller.add(utf8.encode(sseChunk('梅菲斯特出现在书斋门口。')));
await Future&lt;void&gt;.delayed(const Duration(milliseconds: 50));

// 2. 使用者点击 ⏩ 跳过打字机
notifier.revealStreaming();
expect(state.streamingContent, contains('梅菲斯特出现在书斋门口'));

// 3. 剩余内容继续到达（reveal 后不应中止生成）
controller.add(utf8.encode(sseChunk('他轻声提议进行一场交易。')));
controller.add(utf8.encode('data: [DONE]\n\n'));
await controller.close();
// waitForGeneration：轮询检查状态.isGenerating 直至复位，
// 确保 LLM 生成的异步流程（含自动存档）完全收尾后再断言
await waitForGeneration(container);

// 最终回复 = 完整内容（不截断）
expect(state.messages.last.content,
    '梅菲斯特出现在书斋门口。他轻声提议进行一场交易。');
</code></pre>

<p>这个测试一举验证了三个关键不变量：</p>

<ol>
<li>点击 ⏩ 后，<strong>已有内容立即显示</strong></li>
<li>reveal 后，后续 chunk <strong>仍然被接收</strong>（未被中止）</li>
<li>生成结束后，消息列表写入的是<strong>完整拼接内容</strong>（不截断）</li>
</ol>

<p>如果当时先写了这个测试，第二版「取消信号导致截断」的 bug 会在测试里立刻暴露——而不是等用户反馈。</p>

<h2 id="结语">结语</h2>

<p>流式 UI 的「顺滑」不是靠运气，而是靠一套清晰的语义决策。总结三条：</p>

<ol>
<li><strong>节流不等于动画</strong>：动画来自 LLM 的逐 chunk 输出，节流只是减少 UI 重建的优化手段</li>
<li><strong>跳过不是中止</strong>：跳过动画的语义是「停止逐字更新」，不是「停止生成」——让 LLM 把话说完，再一次性补齐</li>
<li><strong>中断要可测</strong>：用 <code>StreamController</code> 可控模拟分块流，把「中途打断」这个最脆弱的时序场景变成可重复验证的测试</li>
</ol>

<h2 id="术语表">术语表</h2>

<table>
<thead>
<tr>
<th>术语</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td>SSE（Server-Sent Events）</td>
<td>HTTP 长连接的服务器推送协议，LLM 逐 token/chunk 返回的载体</td>
</tr>

<tr>
<td>节流（Throttle）</td>
<td>在时间窗口内合并多次到达为一次提交，减少 UI 重建次数</td>
</tr>

<tr>
<td><code>StringBuffer</code></td>
<td>Dart 的可变字符串累积器，<code>toString()</code> 一次性构建，避免 O(n²) 拼接</td>
</tr>

<tr>
<td><code>StreamController</code></td>
<td>Dart 的可控流控制器，测试中手动推送 chunk 模拟服务器时序</td>
</tr>

<tr>
<td>协作式取消（Collaborative Cancellation）</td>
<td>客户端停止读取 SSE 流的机制：下一条数据行处 break，而非中断底层连接</td>
</tr>

<tr>
<td><code>MockClient.streaming</code></td>
<td><code>package:http/testing</code> 提供的流式响应 mock，可配 <code>StreamedResponse</code></td>
</tr>
</tbody>
</table>

<hr>

<p>项目：<a href="https://github.com/yuelinghuashu/mephisto-gui" target="_blank">Mephisto</a></p>
]]></content:encoded>
      <description><![CDATA[为什么「跳过动画」不等于「中止生成」？从 SSE 到屏幕，拆解流式 UI 的节流、取消语义与可测试性设计。]]></description>
      <category><![CDATA[Flutter]]></category>
      <category><![CDATA[Dart]]></category>
      <category><![CDATA[LLM]]></category>
      <category><![CDATA[Performance]]></category>
      <category><![CDATA[State Management]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:deployment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[拆超大 Flutter State 类的三种尝试与最终方案]]></title>
      <link>https://moongate.top/docs/refactoring-flutter-state-class</link>
      <guid isPermaLink="true">5f6571c9-eff4-44b3-8704-05e8fc6ed750</guid>
      <pubDate>Tue, 11 Aug 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>从 Mixin / part 到 Widget 组合的踩坑实录。
当你遇到 800 行 <code>State</code> 类时，该怎么做？</p>
</blockquote>

<h2 id="1-问题信号-什么时候该拆">1. 问题信号：什么时候该拆</h2>

<p>当一个 <strong>State 类</strong>长到 <strong>几百行</strong>，通常意味着它承担了多种职责：</p>

<p>以真实项目里的首页为例，800+ 行的 <code>_HomeScreenState</code> 里混着：</p>

<table>
<thead>
<tr>
<th>职责</th>
<th>示例方法</th>
</tr>
</thead>

<tbody>
<tr>
<td>契约树操作</td>
<td>母版/子版 ⋮ 菜单、重命名、删除确认</td>
</tr>

<tr>
<td>舞台操作</td>
<td>舞台卡菜单、多选、角色行菜单</td>
</tr>

<tr>
<td>导航</td>
<td>进入叙事页 / 舞台页</td>
</tr>

<tr>
<td>文件操作</td>
<td>导入 / 新建 / 恢复内置</td>
</tr>

<tr>
<td>纯渲染</td>
<td>契约树列表、舞台聚合区、最近编辑计算</td>
</tr>
</tbody>
</table>

<blockquote>
<p><strong>判断准则</strong>：如果同一个类里有超过 3 种「互不相关的关注点」，就该拆了。
行数不是目标，<strong>职责混杂</strong>才是根因。</p>
</blockquote>

<h2 id="2-尝试一-抽-mixin-败给库级私有">2. 尝试一：抽 Mixin → 败给库级私有</h2>

<p>最常见的直觉是「把方法抽成 Mixin，然后 <code>with</code> 进来」：</p>

<pre><code class="language-dart">// home_screen.dart
class _HomeScreenState extends ConsumerState&lt;HomeScreen&gt;
    with HomeContractActions, HomeStageActions { ... }

// home_contract_actions.dart（另一个文件）
mixin HomeContractActions on ConsumerState&lt;HomeScreen&gt; {
  Future&lt;void&gt; _handleMasterMenu(...) { ... }  // ❌ 宿主访问不到！
}
</code></pre>

<p><strong>报错</strong>：<code>The method '_handleMasterMenu' isn't defined for the type '_HomeScreenState'</code></p>

<h3 id="为什么">为什么？</h3>

<p>Dart 的 <code>_</code> 前缀私有是 <strong>library-level（库级）私有</strong>，而不是 class-level（类级）私有。</p>

<ul>
<li>很多语言（Java / C++ / TS）的 <code>private</code> 是「类内可见」——同一个类里混入的成员自然可见；</li>
<li>Dart 的 <code>_</code> 是「<strong>同一个库文件内</strong>可见」——跨文件的 Mixin 与方法定义处于不同库，即使 <code>with</code> 进同一个类，宿主也看不见 Mixin 里的私有方法。</li>
</ul>

<p>有个「看似可行」的桥接：让 Mixin 声明抽象 getter，宿主提供实现：</p>

<pre><code class="language-dart">mixin HomeStageActions on ConsumerState&lt;HomeScreen&gt; {
  HomeSelectionController get selection; // 抽象 getter
  void refreshLists();
  Future&lt;void&gt; _handleStageMenu(...) { ... } // ❌ 仍是私有，宿主看不到
}
</code></pre>

<p>数据桥接解决了（<code>selection</code> / <code>refreshLists</code>），但 <strong>方法可见性</strong>问题没解决——<code>_handleStageMenu</code> 依旧是另一个库的私有方法。</p>

<h3 id="澄清-问题不是-mixin-而是-跨文件">澄清：问题不是「Mixin」，而是「跨文件」</h3>

<p>如果你是<strong>在同一文件内</strong>定义 Mixin 再 <code>with</code>，是完全可行的：</p>

<pre><code class="language-dart">// home_screen.dart（同一个文件）
mixin HomeStageActions on ConsumerState&lt;HomeScreen&gt; {
  Future&lt;void&gt; _handleStageMenu(...) { ... } // ✅ 同文件，私有可见
}

class _HomeScreenState extends ConsumerState&lt;HomeScreen&gt;
    with HomeStageActions { ... }
</code></pre>

<p>所以「Mixin 拆 State」的直觉本身没错。真正无解的约束是：<strong>Dart 的库级私有 + 文件天然构成库边界</strong>。一旦方法搬去另一个文件，<code>_</code> 私有在跨库时就成了硬隔离。</p>

<blockquote>
<p>📌 <strong>核心教训</strong>：Dart 的私有不是「类私」，而是「库私」。
跨文件组织代码时，<code>_</code> 前缀成员默认互相隔离；Mixin 若留在同一文件则不受此限制。</p>
</blockquote>

<h2 id="3-尝试二-用-part-败给无-this">3. 尝试二：用 part → 败给无 this</h2>

<p>既然 <code>_</code> 是库级私有，那用 <code>part</code> 把同一个库拆成多个文件，不就能共享私有成员了吗？</p>

<pre><code class="language-dart">// home_screen.dart
part 'home_screen_contract.dart';
part 'home_screen_stage.dart';

// home_screen_contract.dart
part of 'home_screen.dart';

Future&lt;void&gt; _handleMasterMenu(...) { ... } // ✅ 私有可见了
</code></pre>

<p><code>part of</code> 确实让私有成员可见（我看到了 <code>_selection</code> / <code>_refreshLists</code>），<strong>但是新的问题来了</strong>：</p>

<blockquote>
<p><strong>顶层函数没有 <code>this</code> 上下文。</strong></p>
</blockquote>

<p><code>part of</code> 里的代码是<strong>库级顶层函数</strong>，不是宿主类的方法。于是：</p>

<pre><code class="language-dart">part of 'home_screen.dart';

Future&lt;void&gt; editContract(ContractInfo info) {
  return editContractFile(
    context,          // ❌ Undefined name 'context'
    onRefreshLists: _refreshLists, // ❌ Undefined name '_refreshLists'
  );
}
</code></pre>

<p><code>context</code> / <code>ref</code> / <code>mounted</code> / <code>_selection</code> 都是 <code>_HomeScreenState</code> 的<strong>实例成员</strong>，顶层函数无法访问。</p>

<h3 id="那-part-到底什么时候合理">那 <code>part</code> 到底什么时候合理？</h3>

<p>项目里早就有成功的先例——<strong>freezed 生成代码</strong>：</p>

<pre><code class="language-dart">// narrative_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'narrative_state.freezed.dart'; // ✅ 纯生成代码，无 this 依赖
</code></pre>

<p><code>narrative_state.freezed.dart</code> 是 freezed 工具生成的、只依赖 <code>@freezed</code> 注解产生的 <code>_NarrativeState</code> 类，它<strong>不需要访问宿主的实例成员</strong>。这种「纯声明 + 生成实现」的拆分才是 <code>part</code> 的正解。</p>

<blockquote>
<p>📌 <strong>核心教训</strong>：<code>part</code> 分享的是「库级私有」，而非「宿主实例上下文」。
如果你的拆分目标是一堆要访问 <code>this</code> 的方法，<code>part</code> 不是答案。</p>
</blockquote>

<h2 id="4-最终方案-widget-组合-回归框架哲学-与合理的边界">4. 最终方案：Widget 组合 → 回归框架哲学（与合理的边界）</h2>

<p>绕了两圈，最终回到 <strong>Flutter 组合（Composition）</strong> 的经典答案：</p>

<blockquote>
<p><strong>把「渲染」抽成独立 Widget，把「交互」用回调注入。</strong></p>
</blockquote>

<h3 id="核心原则">核心原则</h3>

<table>
<thead>
<tr>
<th>内容</th>
<th>归属</th>
<th>理由</th>
</tr>
</thead>

<tbody>
<tr>
<td>依赖 <code>ref</code> / <code>context</code> / <code>mounted</code> 的<strong>交互编排</strong></td>
<td>留在 State</td>
<td>这些本就是 State 的生命周期能力</td>
</tr>

<tr>
<td>只读的<strong>视图构建</strong></td>
<td>抽成独立 ConsumerWidget</td>
<td>纯函数式渲染，可独立维护与测试</td>
</tr>
</tbody>
</table>

<h3 id="具体做法">具体做法</h3>

<p>把「契约树 + 舞台聚合 + 最近编辑」这一大段<strong>纯渲染</strong>抽成 <code>ContractTreeSection</code>：</p>

<pre><code class="language-dart">class ContractTreeSection extends ConsumerWidget {
  final List&lt;ContractGroup&gt; groups;
  final HomeSelectionController selection;

  // ---- 所有交互通过回调注入 ----
  final void Function(BuildContext, ContractGroup) onMasterTap;
  final void Function(ContractInfo child, String action) onChildMenu;
  final void Function(String stagePath) onStageTap;
  // ...

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 只剩渲染：ListView + ContractCard + StageSection ...
  }
}
</code></pre>

<p>宿主 <code>_HomeScreenState</code> 只剩「组装 + 委托」：</p>

<pre><code class="language-dart">return ContractTreeSection(
  groups: groups,
  selection: _selection,
  onMasterTap: _onMasterTap,
  onChildMenu: (child, action) =&gt; _handleChildMenu(context, child, action),
  onStageTap: _openStageNarrative,
  // ...
);
</code></pre>

<p><strong>效果（拆分 ≠ 删代码，而是职责重新归位）</strong>：</p>

<table>
<thead>
<tr>
<th>文件</th>
<th>拆分前行数</th>
<th>拆分后行数</th>
<th>职责</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>home_screen.dart</code></td>
<td>816</td>
<td>640</td>
<td>交互编排（组装 + 导航 + 委托）</td>
</tr>

<tr>
<td><code>contract_tree_section.dart</code></td>
<td>—</td>
<td>263</td>
<td>契约树 + 舞台聚合 + 最近编辑（纯渲染）</td>
</tr>

<tr>
<td><code>stage_card.dart</code></td>
<td>806</td>
<td>529</td>
<td>纯展示 <code>StageCard</code></td>
</tr>

<tr>
<td><code>stage_section.dart</code></td>
<td>—</td>
<td>192</td>
<td>舞台列表区</td>
</tr>

<tr>
<td><code>stage_card_with_meta.dart</code></td>
<td>—</td>
<td>92</td>
<td>舞台数据加载胶水层</td>
</tr>
</tbody>
</table>
<p><strong>总代码量不变</strong>，但每个文件的职责从「混杂」变成「单一」；纯展示组件（<code>StageCard</code> / <code>ContractTreeSection</code>）还能脱离 State 独立测试。</p>

<blockquote>
<p><code>stage_card_with_meta.dart</code> 是「数据加载」与「纯渲染」之间的薄胶水：它负责从 Riverpod 读取舞台数据（角色列表 / 存档探测 / 最近活动时间），再原样传给纯展示的 <code>StageCard</code>——这样 <code>StageCard</code> 自身可以保持零 Riverpod 依赖、便于独立测试。</p>
</blockquote>

<h3 id="为什么这才是-flutter-的方式">为什么这才是「Flutter 的方式」？</h3>

<p>Flutter 的组合模型（Composition）天然就是「一个 Widget 渲染，回调交给上层」：</p>

<ul>
<li><code>Button.onPressed</code>、<code>TextField.onChanged</code>、<code>ListView.builder</code> 全是回调解耦的例子；</li>
<li>项目里 <code>ContractCard</code> / <code>StageSection</code> 早就这么做了——<strong>只是这次把「完整页面的一整块渲染」也套用了同一模式</strong>；</li>
<li><strong>同时缩小重绘范围</strong>：抽成独立 <code>ConsumerWidget</code> 后，State 中其他字段变化不会触达这棵子树——Flutter 的 <code>Element</code> 复用 + 子树隔离让无关更新不再重绘 <code>ContractTreeSection</code>。</li>
</ul>

<p>Mixin 是给<strong>行为复用</strong>用的（如 <code>GenerationCoordinator</code>）；用它拆「State 的组织」，本身就是错配。</p>

<blockquote>
<p>💡 <strong>TIPS：回调里的 <code>String</code> 该不该换成 <code>enum</code>？</strong>
<code>action</code> 用 <code>String</code> 可能触发类型安全直觉。但 PopupMenu 场景下这是务实取舍：<code>PopupMenuItem&lt;String&gt;</code> 天然以 String 为载荷、<code>menu_actions.dart</code> 常量层已提供编译期防护、菜单动作是持续演进的集合（enum 每加一个动作要改定义 + 所有 <code>switch</code>）。
<strong>结论</strong>：若 action 是「封闭且稳定」的集合（如 <code>MessageRole</code>），用 enum 换 exhaustiveness 值得；否则 String + 常量更贴合生态。</p>
</blockquote>

<h3 id="我们为什么停在这里-不继续抽协调器">我们为什么停在这里（不继续抽协调器）</h3>

<p>把交互编排抽到 State 之外（如 Riverpod <code>Notifier</code> 协调器）是常见的进阶建议，但我们评估后<strong>选择不做</strong>：</p>

<ul>
<li>State 里剩下的导航 / 弹窗 / 菜单分发<strong>本质是 UI 职责</strong>——<code>Navigator.push</code>、<code>ScaffoldMessenger</code>、确认弹窗无论如何都需要 <code>BuildContext</code>，抽进 Notifier 只是把壳搬走，参数一个不少；</li>
<li>这些逻辑<strong>早已抽到顶层函数</strong>（<code>home_operations.dart</code> / <code>home_menu_actions.dart</code>），State 里剩的是清晰的一行委托；</li>
<li>再抽协调器会引入「<code>ref.read(coordinator.notifier).xxx()</code> + <code>context.mounted</code>」的额外间接层，认知负担不降反升。</li>
</ul>

<blockquote>
<p>所以 640 行不是「没拆完」，而是<strong>拆到职责单一后的合理停点</strong>。维护性看的是「每个方法一眼可见它干什么」，不是行数。
📌 <strong>核心教训</strong>：遇到「语言机制」限制，不要用 <code>dynamic</code> 或变通去硬绕。
换个更符合框架哲学的架构，通常才是正解；<strong>知道何时停下来，同样是架构判断的一部分</strong>。</p>
</blockquote>

<h2 id="5-决策树-三种方案怎么选">5. 决策树：三种方案怎么选</h2>

<pre><code class="language-text">遇到「State 类太大」
│
├─ 想拆的是「行为复用」？（如流式节流、生成协调）
│    └─ ✅ 用 Mixin（同一文件内定义，避免库私坑）
│
├─ 拆的是「纯数据 / 生成代码」？（如 freezed .g.dart）
│    └─ ✅ 用 part / part of
│
└─ 拆的是「要访问 this 的 State 方法」？
     ├─ 纯渲染部分 → ✅ 抽独立 Widget + 回调注入
     └─ 交互编排部分 → ✅ 留在 State
</code></pre>

<table>
<thead>
<tr>
<th>方案</th>
<th>适用场景</th>
<th>关键限制</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>Mixin</strong></td>
<td>行为复用（无私有跨库）</td>
<td><code>_</code> 是库级私有，跨文件不可见</td>
</tr>

<tr>
<td><strong>part</strong></td>
<td>生成代码 / 纯声明</td>
<td>共享私有但无 <code>this</code> 上下文</td>
</tr>

<tr>
<td><strong>独立 Widget + 回调</strong></td>
<td>渲染与交互解耦</td>
<td>需显式传参（回调注入）</td>
</tr>
</tbody>
</table>

<h2 id="6-落地的工程保障">6. 落地的工程保障</h2>

<p>重构最容易翻车，所以<strong>纯移动 + 纯提参 = 零行为变更</strong>是底线：</p>

<ol>
<li><strong>保持行为不变</strong>：只移动方法体、改写调用点，不改任何逻辑；</li>
<li><strong>静态分析兜底</strong>：<code>flutter analyze</code> 必须是 <code>No issues found!</code>；</li>
<li><strong>全量测试兜底</strong>：406 个测试全绿再算完成；</li>
<li><strong>小步提交</strong>：每次拆分一个关注点，独立验证，避免「大爆炸式」重构。</li>
</ol>

<h2 id="总结">总结</h2>

<ul>
<li>Dart 的 <code>_</code> 是<strong>库级私有</strong>，不是类级私有 → Mixin 跨文件失败；</li>
<li><code>part</code> 分享私有但无 <code>this</code> → 不适合拆 State 方法；</li>
<li><strong>抽独立 Widget + 回调注入</strong> 是拆超大 State 类的 Flutter 正解；</li>
<li>渲染与交互解耦，既符合框架哲学，又能独立维护与测试。</li>
</ul>
]]></content:encoded>
      <description><![CDATA[当 800 行 State 类混杂契约树、舞台操作、导航等多种职责时，如何安全拆分？本文记录了从 Mixin、part 到 Widget 组合的完整重构历程，深入剖析 Dart 库级私有特性，并给出可复用的决策树与工程保障策略。]]></description>
      <category><![CDATA[Flutter]]></category>
      <category><![CDATA[Dart]]></category>
      <category><![CDATA[State Management]]></category>
      <category><![CDATA[Refactoring]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P3</dc:subject>
      
    </item>

    <item>
      <title><![CDATA[品牌生态：设计哲学与视觉契约]]></title>
      <link>https://moongate.top/docs/create-vscode-theme-brand-ecosystem</link>
      <guid isPermaLink="true">50fad7d3-a637-4908-87f6-e74f2ac069b2</guid>
      <pubDate>Thu, 06 Aug 2026 08:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共五篇，覆盖从零基础创建到工业级设计系统的 VS Code 主题开发全流程（对应 <strong>Moongate v2.6.0</strong>）：</p>

<ol>
<li><p><a href="./create-vscode-theme-basics"><strong>VS Code 主题：从手写 JSON 到可发布</strong></a>
—— 不依赖脚手架，手写最小主题 JSON，掌握 <code>colors</code> 与 <code>tokenColors</code> 的核心机制与发布流程。</p></li>

<li><p><a href="./create-vscode-theme-engineering"><strong>主题工程化：从单体 JSON 到模块化 YAML</strong></a>
—— 将单体 JSON 重构为模块化 YAML 项目，用构建脚本实现变量替换与自动生成。</p></li>

<li><p><a href="./create-vscode-theme-design-system"><strong>设计系统：DTCG 三层架构与昼夜双变体</strong></a>
—— 用 DTCG 设计令牌标准管理颜色，通过语义层与重力补偿构建深色/浅色双变体。</p></li>

<li><p><a href="./create-vscode-theme-build-system"><strong>构建体系：可测试、可验证的工程实践</strong></a>
—— 模块化构建体系、WCAG 对比度校验、scope 自动验证、自动化测试与多格式产物生成。</p></li>

<li><p><a href="./create-vscode-theme-brand-ecosystem"><strong>品牌生态：设计哲学与视觉契约</strong></a>
—— 为你的主题赋予设计哲学、视觉契约和品牌生态，打造完整的设计系统。</p></li>
</ol>

<hr>

<h2 id="引言-从工程化到设计系统的跃迁">🌕 引言：从工程化到设计系统的跃迁</h2>

<p>在前四篇中，我们一步步构建了 Moongate 主题的工程基础：从手动 JSON 到模块化 YAML，从 DTCG 三层架构到可测试的工业级构建体系。至此，我们拥有了一套高效、可扩展、可自我验证的主题生产系统。</p>

<p>但一个真正优秀的主题，不应只是颜色规则的集合，而应是一套<strong>完整的设计系统</strong>——它包含明确的设计哲学、可复用的视觉语言、与用户沟通的契约，以及可跨平台复用的品牌生态。</p>

<p>本篇将完成最后的跃迁：从「工程化」升维到「设计系统」。你会看到，前四篇建立的工程能力（DTCG 令牌、构建产物、自动化验证）如何支撑起一套可跨平台复用的品牌生态。</p>

<hr>

<h2 id="第一部分-设计哲学-让每个颜色都有意义">🎨 第一部分：设计哲学——让每个颜色都有意义</h2>

<p>任何设计系统都必须建立在清晰的设计哲学之上。没有哲学的颜色只是随机的搭配，有哲学的颜色才能形成品牌识别。</p>

<h3 id="1-1-冷调基底-消除视觉脏感">1.1 冷调基底：消除视觉脏感</h3>

<p><strong>通用原则</strong>：为背景、边框、灰阶添加微弱的冷色偏置（如蓝或绿），避免纯黑或纯白带来的视觉「脏感」。纯黑背景会让高亮色产生「发光溢散」，纯白背景则容易发黄发灰。微量的冷调能让底色干净、深邃，让叠加的彩色语义色更纯粹地呈现。</p>

<p><strong>Moongate 实例</strong>：</p>

<ul>
<li>深色背景：<code>#0f172a</code>（深空蓝黑）</li>
<li>浅色背景：<code>#f9fafb</code>（冷月白）</li>
</ul>

<h3 id="1-2-语义分层-信息的三级阶梯">1.2 语义分层：信息的三级阶梯</h3>

<p><strong>通用原则</strong>：代码不是平面的，天然具有层次。将所有代码元素划分为三个视觉层级——前景（核心逻辑）、中景（普通代码）、背景（辅助信息），通过对比度、饱和度、字体样式（加粗、斜体）等区分，让代码结构自然「浮现」。</p>

<p><strong>Moongate 实例</strong>：</p>

<table>
<thead>
<tr>
<th>层级</th>
<th>作用</th>
<th>视觉特征</th>
<th>示例</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>前景</strong></td>
<td>核心逻辑</td>
<td>高对比度、加粗或高饱和色</td>
<td>关键字、函数定义、类名</td>
</tr>

<tr>
<td><strong>中景</strong></td>
<td>普通代码</td>
<td>中等对比度，视觉自然</td>
<td>变量、字符串、数字</td>
</tr>

<tr>
<td><strong>背景</strong></td>
<td>辅助信息</td>
<td>弱化但可读</td>
<td>注释、标点、操作符</td>
</tr>
</tbody>
</table>
<p>这套分层贯穿所有语言、所有主题变体，是 Moongate 视觉一致性的底层保证。</p>

<h3 id="1-3-重力补偿-昼夜视觉重量对等">1.3 重力补偿：昼夜视觉重量对等</h3>

<p><strong>通用原则</strong>：浅色主题不是深色主题的简单反相。深色背景上的亮色是「发光体」，浅色背景上的暗色是「吸光体」。要让同一语义角色在不同背景下拥有对等的视觉重量，必须保持色相不变，科学调整明度和饱和度。这称为<strong>重力补偿</strong>。</p>

<p><strong>Moongate 实例</strong>：</p>

<table>
<thead>
<tr>
<th>语义角色</th>
<th>深色版</th>
<th>浅色版</th>
<th>调整方法</th>
</tr>
</thead>

<tbody>
<tr>
<td>主色</td>
<td><code>#3b82f6</code> (60% 明度)</td>
<td><code>#0284c7</code> (48% 明度)</td>
<td>色相不变，明度降低约 20%</td>
</tr>

<tr>
<td>成功</td>
<td><code>#34d399</code> (65%)</td>
<td><code>#059669</code> (40%)</td>
<td>明度降低，饱和度略降</td>
</tr>

<tr>
<td>警告</td>
<td><code>#fbbf24</code> (75%)</td>
<td><code>#b45309</code> (35%)</td>
<td>从亮黄转为橙黄，避免在白底上「消失」</td>
</tr>

<tr>
<td>错误</td>
<td><code>#f87171</code> (60%)</td>
<td><code>#b91c1c</code> (35%)</td>
<td>深红保持警示感</td>
</tr>
</tbody>
</table>
<p>用户切换主题时，同一语法元素的视觉重量几乎不变，无需重新适应。</p>

<h3 id="1-4-海拔系统-为-ui-注入物理深度">1.4 海拔系统：为 UI 注入物理深度</h3>

<p><strong>通用原则</strong>：通过定义多级背景明度阶梯，表达 UI 元素的物理深度。深色模式下，海拔越高表面越亮（明度递增）；浅色模式下，海拔越高表面也越亮（但使用更高亮度的白色或浅色）。阶梯步长应保持一致，形成平滑的层次感。</p>

<p><strong>Moongate 实例</strong>（v2.6.0 最新值）：</p>

<table>
<thead>
<tr>
<th>海拔层级</th>
<th>用途</th>
<th>深色模式</th>
<th>浅色模式</th>
<th>明度变化</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>surfaceGround</code></td>
<td>底层背景</td>
<td><code>#0f172a</code></td>
<td><code>#f9fafb</code></td>
<td>基准层</td>
</tr>

<tr>
<td><code>surfaceRaised</code></td>
<td>侧边栏、活动栏</td>
<td><code>#1a2538</code></td>
<td><code>#ffffff</code></td>
<td>深色 +5%，浅色纯白</td>
</tr>

<tr>
<td><code>surfaceFloating</code></td>
<td>面板、悬浮卡片</td>
<td><code>#25364a</code></td>
<td><code>#f1f5f9</code></td>
<td>深色再 +5%，浅色浅灰蓝</td>
</tr>

<tr>
<td><code>surfaceTooltip</code></td>
<td>提示框、弹窗</td>
<td><code>#2e3b4d</code></td>
<td><code>#e2e8f0</code></td>
<td>最高层</td>
</tr>
</tbody>
</table>
<p>这种设计让侧边栏微微隆起，弹窗轻盈浮现，代码区沉静深邃——编辑器从平面走向立体，物理隐喻让界面层次一目了然。</p>

<hr>

<h2 id="第二部分-视觉契约-连接用户与硬件">📄 第二部分：视觉契约——连接用户与硬件</h2>

<p><strong>通用原则</strong>：主题设计得再好，如果用户显示器未校准，效果也会大打折扣。提供一份显示器校准指南（视觉契约），帮助用户调整 Gamma、亮度、对比度、色温，并提醒关闭「动态对比度」「生动模式」等味精功能。校准的终点不是理论完美，而是找到用户最舒服的平衡点。</p>

<p><strong>Moongate 实例</strong>：<code>extras/VISUAL_CONTRACT.md</code> 提供了详细的《视觉契约》文档（v2.0 版），包含以下核心步骤：</p>

<table>
<thead>
<tr>
<th>步骤</th>
<th>操作</th>
<th>目标</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>1. 设置 Gamma</strong></td>
<td>选择 <code>Gamma 2.2</code></td>
<td>确保灰阶过渡平滑</td>
</tr>

<tr>
<td><strong>2. 调整亮度</strong></td>
<td>深色模式：让 2% 灰块刚可见；浅色模式：让 250–255 亮块层次分明</td>
<td>保留暗部/亮部细节</td>
</tr>

<tr>
<td><strong>3. 调整对比度</strong></td>
<td>让 100% 白色块清晰但不刺眼</td>
<td>防止过曝</td>
</tr>

<tr>
<td><strong>4. 色温</strong></td>
<td>推荐 <code>6500K</code> 或 <code>暖色</code> 模式</td>
<td>中和蓝光</td>
</tr>
</tbody>
</table>
<p>v2.0 的视觉契约特别强调<strong>分模式校准</strong>：</p>

<ul>
<li><strong>深色模式</strong>（夜间环境）：在黑暗房间中，调节亮度使 <a href="http://www.lagom.nl/lcd-test/black.php" target="_blank">Black level 测试页</a> 上 2% 灰块刚能被分辨。</li>
<li><strong>浅色模式</strong>（白天环境）：在典型照明下，使用 <a href="http://www.lagom.nl/lcd-test/white_saturation.php" target="_blank">White saturation 测试页</a>，让 250-255 亮块层次分明且 255 纯白不刺眼。</li>
</ul>

<p>并列举了常见显示器陷阱及其对策：</p>

<table>
<thead>
<tr>
<th>陷阱</th>
<th>症状</th>
<th>对策</th>
</tr>
</thead>

<tbody>
<tr>
<td>黑色稳定器</td>
<td>深色背景发灰、暗色文字变淡</td>
<td>锁定为 50 或关闭</td>
</tr>

<tr>
<td>生动模式/高色域</td>
<td>色彩偏离设计、冷色调泛暖</td>
<td>优先选择 sRGB 模式</td>
</tr>

<tr>
<td>锐利度过高</td>
<td>字符边缘出现「重影」</td>
<td>下调至 50-60</td>
</tr>
</tbody>
</table>

<blockquote>
<p>💡 <strong>为什么视觉契约是「契约」而不是「教程」？</strong> 因为它不是指导用户如何校准显示器，而是<strong>双方共同遵守的约定</strong>：主题开发者承诺「颜色经过精密设计」，用户承诺「通过合理校准让设计被忠实呈现」。这份契约让主题在各种硬件上的表现可控、可预期。</p>
</blockquote>

<h3 id="快速版-3-步-够用就好">🚀 快速版：3 步「够用就好」</h3>

<p>完整校准对追求精准的用户很有价值，但如果你不想折腾硬件参数，3 步就能获得够好的体验：</p>

<ol>
<li><strong>选对模式</strong>：显示器切到 <strong>sRGB / 标准模式</strong>，关闭「动态对比度」「生动模式」「黑色稳定器」等味精功能——这一步解决 80% 的颜色偏移问题。</li>
<li><strong>看效果微调</strong>：深色模式下发灰、浅色模式下文字刺眼，就微调亮度直到观感舒服。</li>
<li><strong>色温保底</strong>：选择 <strong>6500K 或暖色</strong>，中和蓝光即可。</li>
</ol>

<p>不追求 Gamma 曲线和测试块的硬件级精确，「观感舒适」就是第一标准。等想要更精细的效果时，再按上文的完整步骤校准。</p>

<hr>

<h2 id="第三部分-品牌生态-从主题到社区">🌐 第三部分：品牌生态——从主题到社区</h2>

<p><strong>通用原则</strong>：完整的品牌体系包括：文档体系（README、CHANGELOG、设计文档）、社区互动（开源、反馈渠道）、生态集成（与周边工具深度整合，提供一致体验）。</p>

<p><strong>Moongate 实例</strong>：</p>

<h3 id="3-1-文档体系">3.1 文档体系</h3>

<ul>
<li><strong>README</strong>：中英双语，包含预览图、设计理念、优化项清单、推荐配置。</li>
<li><strong>CHANGELOG</strong>：按版本记录所有变更，折叠旧版本，突出当前亮点。</li>
<li><strong>视觉契约</strong>：独立文档，作为主题附件提供。</li>
<li><strong>设计系统文档</strong>：自动生成的 <code>DESIGN_SYSTEM.md</code>，包含完整色板、海拔系统、WCAG 对比度数据，以及「变量选择协议」——<strong>所有颜色必须经过「原始值 → 语义层 → 组件层」的传递链条，任何跨层直接引用都是架构污染</strong>。</li>
</ul>

<h3 id="3-2-社区互动">3.2 社区互动</h3>

<ul>
<li><strong>GitHub 开源</strong>：公开源码，接受 PR。</li>
<li><strong>反馈渠道</strong>：鼓励用户反馈校准体验，持续优化视觉契约。</li>
</ul>

<h3 id="3-3-生态集成">3.3 生态集成</h3>

<ul>
<li><strong>Better Comments 预设</strong>：官方配色<strong>内置</strong>主题，零配置开箱即用（双源消除机制见<a href="./create-vscode-theme-build-system">构建体系</a>）。</li>
<li><strong>终端 ANSI 色同步</strong>：16 色 ANSI 配色映射到主题语义色，消除编辑器与终端的视觉割裂。</li>
<li><strong>跨平台令牌资产</strong>：自动导出 CSS / SCSS / TypeScript 三种令牌（生成方式见<a href="./create-vscode-theme-build-system">构建体系</a>），供博客、文档站、UI 组件库直接引用——一套颜色贯穿所有产品。</li>
</ul>

<h3 id="3-4-从作品到品牌">3.4 从作品到品牌</h3>

<p>Moongate 不再只是一个主题，它是：</p>

<ul>
<li>一套设计哲学（冷调基底、语义分层、重力补偿、海拔系统）</li>
<li>一份视觉契约（显示器校准指南）</li>
<li>一个可扩展的品牌（昼夜双星，未来更多变体）</li>
</ul>

<hr>

<h2 id="第四部分-总结与展望">📌 第四部分：总结与展望</h2>

<p>至此，我们的五部曲系列已全部完成。回顾这段旅程：</p>

<ul>
<li><strong>VS Code 主题</strong>：你从手动 JSON 出发，创建并发布了第一个 VS Code 主题，掌握了核心机制与发布流程。</li>
<li><strong>主题工程化</strong>：你通过 YAML 模块化 + 构建脚本，让主题变得可维护、可自动构建。</li>
<li><strong>设计系统</strong>：你用 DTCG 三层架构管理颜色，通过语义层与重力补偿构建了昼夜双变体。</li>
<li><strong>构建体系</strong>：你构建了可测试、可验证的构建脚本架构，让质量保障自动化。</li>
<li><strong>品牌生态</strong>：你将主题升华为设计系统，用设计哲学、视觉契约和跨平台资产建立了完整的品牌生态。</li>
</ul>

<p><strong>v2.6.0 正是这套系统的实践成果</strong>——所有颜色经过工业级校验，布局令牌导出为 CSS 变量，设计文档自动生成，跨平台令牌同时产出 SCSS 与 TypeScript 格式。</p>

<p>Moongate 主题正是这一系列理念的实践成果。如果你希望亲身体验这套设计哲学，欢迎在 VS Code 市场中搜索 <strong>Moongate Theme</strong>，或通过以下链接探索：</p>

<ul>
<li><a href="https://marketplace.visualstudio.com/items?itemName=yuelinghuashu.moongate-theme" target="_blank">VS Code 市场页面</a></li>
<li><a href="https://github.com/yuelinghuashu/moongate-theme" target="_blank">GitHub 仓库</a></li>
</ul>

<p>如果你按照本系列的方法创建了自己的主题，或者有任何问题与想法，欢迎在评论区分享。代码世界那么大，愿你的主题也能被看见。</p>

<blockquote>
<p>💡 另外，如果你读完本系列，希望有一个<strong>可以直接 fork 的工程模板</strong>——无论是想快速替换配色得到自己的主题，还是想要一份干净的体系代码作为参照——欢迎在评论区告诉我们你的偏好。这将帮助我们决定是否、以及以何种形态提供一个 starter 模板。</p>
</blockquote>

<hr>

<h2 id="附-版本与维护策略">📌 附：版本与维护策略</h2>

<p>本系列文章描述的是 <strong>Moongate v2.6.0</strong> 的真实状态。为了让读者对文章的时效性有合理预期，这里说明系列的维护策略：</p>

<h3 id="版本锁定">版本锁定</h3>

<ul>
<li>本系列所有色值、scope、脚本示例均以 <strong>Moongate v2.6.0</strong> 为准。如果你安装的主题版本不同，部分细节（如海拔色值、语言规则）可能与文章存在出入。</li>
<li>系列导航与每篇文章开头的「对应 Moongate v2.6.0」标注，就是版本锁定的标识。</li>
</ul>

<h3 id="更新节奏">更新节奏</h3>

<ul>
<li><strong>主要版本更新</strong>（如 v3.0 引入新的架构变化）时，系列文章会同步修订，确保读者学到的是当前最佳实践。</li>
<li><strong>小版本更新</strong>（如新增语言支持、微调配色）不逐篇回改正文，通过项目的 <code>CHANGELOG.md</code> 记录。读者可以从更新日志追踪这些增量变化。</li>
</ul>

<h3 id="兼容性考虑">兼容性考虑</h3>

<ul>
<li><strong>VS Code 版本</strong>：<code>package.json</code> 中的 <code>engines</code> 字段（当前 <code>^1.130.0</code>）决定了 <code>workbench.yaml</code> 可用的 UI 键范围。如果 VS Code 后续新增了大量 UI 键，主题工程可能需要相应扩展——这正是工程化体系的意义所在。</li>
<li><strong>DTCG 标准</strong>：DTCG 规范仍在演进。Moongate 的策略是保持「原始值 → 语义层 → 组件层」三层架构的稳定，在各层内部拥抱规范变化。</li>
</ul>

<h3 id="读者如何跟进">读者如何跟进</h3>

<ul>
<li>关注 <a href="https://github.com/yuelinghuashu/moongate-theme" target="_blank">GitHub 仓库</a> 的 CHANGELOG，了解版本演进。</li>
<li>如果你按照本系列复刻了自己的主题工程，遇到与文章不符之处，欢迎在评论区反馈，这有助于我们校准文档与代码的一致性。</li>
</ul>

<hr>

<p><a href="#">⬆ 返回顶部</a></p>

<hr>

<p><em>本文是 VS Code 主题开发系列「从主题到品牌」的收官之作。五篇连读，助你从零成长为设计系统工程师。</em></p>
]]></content:encoded>
      <description><![CDATA[将主题升华为设计系统——定义设计哲学、建立视觉契约、提供显示器校准指南，让主题从代码工具进化为可复用的品牌资产，连接社区与产品生态。]]></description>
      <category><![CDATA[Design System]]></category>
      <category><![CDATA[Theme]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:design-system]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[构建体系：可测试、可验证的工程实践]]></title>
      <link>https://moongate.top/docs/create-vscode-theme-build-system</link>
      <guid isPermaLink="true">ef9e2761-be41-4778-8552-22cb86ed3407</guid>
      <pubDate>Thu, 06 Aug 2026 06:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共五篇，覆盖从零基础创建到工业级设计系统的 VS Code 主题开发全流程（对应 <strong>Moongate v2.6.0</strong>）：</p>

<ol>
<li><p><a href="./create-vscode-theme-basics"><strong>VS Code 主题：从手写 JSON 到可发布</strong></a>
—— 不依赖脚手架，手写最小主题 JSON，掌握 <code>colors</code> 与 <code>tokenColors</code> 的核心机制与发布流程。</p></li>

<li><p><a href="./create-vscode-theme-engineering"><strong>主题工程化：从单体 JSON 到模块化 YAML</strong></a>
—— 将单体 JSON 重构为模块化 YAML 项目，用构建脚本实现变量替换与自动生成。</p></li>

<li><p><a href="./create-vscode-theme-design-system"><strong>设计系统：DTCG 三层架构与昼夜双变体</strong></a>
—— 用 DTCG 设计令牌标准管理颜色，通过语义层与重力补偿构建深色/浅色双变体。</p></li>

<li><p><a href="./create-vscode-theme-build-system"><strong>构建体系：可测试、可验证的工程实践</strong></a>
—— 模块化构建体系、WCAG 对比度校验、scope 自动验证、自动化测试与多格式产物生成。</p></li>

<li><p><a href="./create-vscode-theme-brand-ecosystem"><strong>品牌生态：设计哲学与视觉契约</strong></a>
—— 为你的主题赋予设计哲学、视觉契约和品牌生态，打造完整的设计系统。</p></li>
</ol>

<hr>

<p>在<a href="./create-vscode-theme-design-system">设计系统</a>中，我们已经拥有了一套基于 DTCG 三层架构的主题生产系统，构建脚本能自动生成深色/浅色双主题，并能导出 CSS 变量。</p>

<p>但随着语言数量增长到 15 种、构建脚本功能越来越复杂，新的问题浮现了：</p>

<ol>
<li><strong>构建脚本本身缺乏架构</strong>——所有逻辑堆在 <code>build.js</code> 一个文件里，难以测试和维护。</li>
<li><strong>质量无法自动保证</strong>——颜色是否符合 WCAG 对比度标准？引用的变量是否都存在？有没有产生「组件层直接引用原始值」的架构污染？</li>
<li><strong>语言 scope 无法验证</strong>——写了 20 条语言规则，怎么确认每个 scope 真的存在于 VS Code 的 TextMate 语法中？「规则写了对不上」如何自动化检测？</li>
<li><strong>产物只有主题 JSON 和 CSS</strong>——能否进一步导出 SCSS、TypeScript 等更多格式，让设计资产在更多平台复用？</li>
</ol>

<p>本篇将回答这些问题，把构建脚本从「能用」升级为「工业级」——<strong>一套可测试、可验证、可维护的工程基础设施</strong>。</p>

<blockquote>
<p>🗺️ <strong>本篇路线图</strong>：五个步骤，层层递进——</p>

<ol>
<li><strong>架构</strong>：把单体脚本拆成可维护的模块（<code>一</code>）</li>
<li><strong>验证</strong>：让构建过程自我证明——质量校验与 scope 验证（<code>二</code>、<code>四</code>）</li>
<li><strong>优化</strong>：让生成的产物更精简（<code>三</code>）</li>
<li><strong>测试</strong>：让构建系统自身可回归验证（<code>五</code>）</li>
<li><strong>发布</strong>：把验证串进生态整合与交付链路（<code>六</code>、<code>七</code>、<code>八</code>）</li>
</ol>

<p>篇幅较长，建议先通读一遍建立全局认知，再结合自己的项目逐节实践——每节结尾都有与下一节的衔接说明。</p>

<p>💡 <strong>提示</strong>：本篇内容涉及较深的工程实践，建议先完成前三篇再阅读。如果你希望先了解「这套体系支撑起什么样的品牌生态」，也可以先读第五篇再回来。</p>
</blockquote>

<hr>

<h2 id="一-模块化架构-从单体脚本到-scripts-lib">📁 一、模块化架构：从单体脚本到 <code>scripts/lib/</code></h2>

<p>当构建脚本超过 200 行时，它本身也需要架构。Moongate 将构建系统拆分为 <code>scripts/lib/</code> 下的多个单职责模块：</p>

<pre><code class="language-text">your-theme/
├── scripts/
│   ├── build.js                     # 主流程（编排者，不包含具体实现）
│   ├── verify-scopes.js             # scope 验证 CLI
│   ├── generate-better-comments.js  # Better Comments 配置生成器
│   └── lib/
│       ├── config.js                # 路径配置
│       ├── tokens.js                # 令牌解析（resolveTokens、replaceVariables）
│       ├── utils.js                 # 通用工具（safeLoadYaml、normalizeHex 等）
│       ├── validators.js            # 质量验证（WCAG 对比度、结构验证、未使用令牌检测）
│       ├── optimizers.js            # 输出优化（token 合并、语义色精简）
│       ├── generators.js            # 多格式产物生成（CSS/SCSS/TS/设计文档）
│       └── scope-validator.js       # scope 验证逻辑
├── src/
│   ├── core/
│   │   ├── primitives/colors.yaml
│   │   ├── semantics/dark.yaml + light.yaml
│   │   └── layout.yaml
│   ├── languages/*.yaml
│   ├── workbench.yaml
│   ├── semantic.yaml
│   └── special/better-comments.yaml
├── themes/                          # 生成产物
├── docs/DESIGN_SYSTEM.md            # 自动生成的文档
├── test/*.test.js                   # 自动化测试
└── package.json
</code></pre>

<p>各模块的职责：</p>

<table>
<thead>
<tr>
<th>模块</th>
<th>职责</th>
<th>关键导出</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>config.js</code></td>
<td>集中管理所有文件路径</td>
<td><code>PATHS</code>、<code>ROOT_DIR</code></td>
</tr>

<tr>
<td><code>tokens.js</code></td>
<td>令牌解析与变量替换</td>
<td><code>resolveTokens</code>（<code>{token}</code> 层间引用）、<code>replaceVariables</code>（<code>${var}</code> 变量替换）、<code>detectPrimitiveReference</code>（架构污染检测）</td>
</tr>

<tr>
<td><code>utils.js</code></td>
<td>通用工具函数</td>
<td><code>safeLoadYaml</code>、<code>normalizeHex</code>、<code>detectDuplicateColors</code>、<code>getThemeInfo</code></td>
</tr>

<tr>
<td><code>validators.js</code></td>
<td>质量验证</td>
<td><code>checkContrast</code>（WCAG）、<code>validateThemeStructure</code>、<code>detectUnusedPrimitives</code></td>
</tr>

<tr>
<td><code>optimizers.js</code></td>
<td>输出精简</td>
<td><code>mergeTokenColors</code>、<code>optimizeSemanticTokenColors</code></td>
</tr>

<tr>
<td><code>generators.js</code></td>
<td>多格式产物</td>
<td><code>generateColorCss</code>、<code>generateLayoutCss</code>、<code>generateScssTokens</code>、<code>generateTsTokens</code>、<code>generateDesignSystemDoc</code></td>
</tr>

<tr>
<td><code>scope-validator.js</code></td>
<td>scope 验证逻辑</td>
<td><code>verifyAllScopes</code>、<code>formatVerificationResult</code></td>
</tr>
</tbody>
</table>
<p><strong>为什么用 ESM 而不是 CommonJS？</strong></p>

<ul>
<li>现代 Node.js（≥ 14）原生支持 ESM，无需额外构建工具。</li>
<li><code>import</code> / <code>export</code> 是静态的，工具可以静态分析依赖关系，更容易重构和调试。</li>
<li>项目 <code>package.json</code> 中声明 <code>&quot;type&quot;: &quot;module&quot;</code>，所有 <code>.js</code> 文件默认视为 ESM。</li>
</ul>

<p><code>build.js</code> 作为主流程，只负责<strong>编排</strong>——加载模块、调用函数、捕获错误，不包含具体实现。</p>

<blockquote>
<p>📌 <strong>与上一篇的分工</strong>：<code>resolveTokens</code>、<code>replaceVariables</code> 的完整实现在<a href="./create-vscode-theme-design-system">设计系统</a>中已经见过，本篇<strong>不再重复</strong>。你要关注的是四个<strong>新增</strong>模块：<code>validators</code>（质量验证）、<code>optimizers</code>（输出精简）、<code>generators</code>（多格式产物）、<code>scope-validator</code>（scope 验证）——数据流地图里的每一步，都会落到这四个模块上。</p>
</blockquote>

<p>在阅读下面的代码之前，先建立一张「数据流地图」——每个关键步骤输入什么、输出什么：</p>

<table>
<thead>
<tr>
<th>步骤</th>
<th>输入</th>
<th>输出</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>loadPrimitives()</code></td>
<td><code>primitives/colors.yaml</code></td>
<td>标准化后的原始色值字典</td>
</tr>

<tr>
<td><code>resolveTokens()</code></td>
<td>原始值 + <code>semantics/*.yaml</code></td>
<td>每个变体的最终语义色值字典</td>
</tr>

<tr>
<td><code>loadTokenColors()</code></td>
<td><code>languages/</code> + <code>special/</code> 下的规则</td>
<td>合并后的 tokenColors 原始规则</td>
</tr>

<tr>
<td><code>replaceVariables()</code></td>
<td>规则文件 + 语义色值字典</td>
<td>变量已替换为实际色值的规则</td>
</tr>

<tr>
<td><code>mergeTokenColors()</code> / <code>optimizeSemanticTokenColors()</code></td>
<td>替换后的规则</td>
<td>精简后的产物</td>
</tr>

<tr>
<td><code>generate*()</code></td>
<td>最终色值字典 + 布局令牌</td>
<td>CSS / SCSS / TS 令牌与设计文档</td>
</tr>
</tbody>
</table>
<p>带着这张地图读代码，你会更清楚每个模块函数在整条流水线中的位置。</p>

<pre><code class="language-javascript">// scripts/build.js（主流程精简示意）
import { PATHS } from &quot;./lib/config.js&quot;
import { ensureFileExists, safeLoadYaml } from &quot;./lib/utils.js&quot;
import { resolveTokens, replaceVariables } from &quot;./lib/tokens.js&quot;
import { detectUnusedPrimitives, validateThemeStructure, checkContrast } from &quot;./lib/validators.js&quot;
import { mergeTokenColors, optimizeSemanticTokenColors } from &quot;./lib/optimizers.js&quot;
import { generateColorCss, generateLayoutCss, generateDesignSystemDoc, generateScssTokens, generateTsTokens } from &quot;./lib/generators.js&quot;

function main() {
  console.log(&quot;🚀 开始构建主题 (DTCG 标准 + 工业级质检)...\n&quot;)
  try {
    // 1. 检查必要文件
    // 2. 加载并标准化原始色值
    // 3. 生成布局 CSS
    // 4. 加载公共规则（workbench + semantic）
    // 5. 加载语言与特殊规则
    // 6. 扫描语义文件并检测未使用原始值
    // 7. 为每个语义层文件构建主题
    // 8. 生成 CSS 变量、跨平台令牌和设计系统文档
    console.log(&quot;\n🎉 所有主题构建完毕！&quot;)
  } catch (err) {
    console.error(err.message)
    process.exit(1)
  }
}

main()
</code></pre>

<p><strong>核心原则</strong>：每个函数只做一件事。<code>main()</code> 中每个步骤对应一个命名清晰的函数（<code>loadPrimitives()</code>、<code>loadCommonRules()</code>、<code>loadTokenColors()</code>……），调用者一眼就能看出构建流程的每一步在做什么。</p>

<p>模块化的骨架搭好了，接下来要解决真正的工程问题：<strong>构建过程必须能够自我证明</strong>。</p>

<hr>

<h2 id="二-质量验证-让构建过程-自我证明">🛡️ 二、质量验证：让构建过程「自我证明」</h2>

<p>工业级构建脚本的核心特征：<strong>它不仅仅生成文件，还会验证生成的文件是否正确</strong>。验证失败时中断构建，让错误在发布之前就被发现。</p>

<h3 id="2-1-wcag-对比度自动校验">2.1 WCAG 对比度自动校验</h3>

<p>主题的每个颜色都必须在背景上清晰可读。Moongate 为关键文本角色设置了对 <code>editor.background</code> 的最低对比度要求：</p>

<table>
<thead>
<tr>
<th>角色</th>
<th>最低对比度</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>text</code>（正文）</td>
<td>4.5:1</td>
<td>WCAG AA</td>
</tr>

<tr>
<td><code>textDim</code>（次要文字）</td>
<td>4.0:1</td>
<td>略低于 AA，权衡视觉层次</td>
</tr>

<tr>
<td><code>textMuted</code>（辅助文字）</td>
<td>3.0:1</td>
<td>大字号/辅助信息可用</td>
</tr>
</tbody>
</table>
<p>校验决策可用一棵简单的树来表达：</p>

<pre><code>某个语义色 vs 背景
   ├─ ratio &lt; 3.0        → ❌ 构建中断（任何角色都不合格）
   ├─ 3.0 ≤ ratio &lt; 4.0  → ⚠️ 仅 textMuted 可接受（其余失败）
   ├─ 4.0 ≤ ratio &lt; 4.5  → ⚠️ 仅 textDim/comment 可接受（text 失败）
   └─ ratio ≥ 4.5        → ✅ 全部通过（达到 WCAG AA）
</code></pre>

<pre><code class="language-javascript">// scripts/lib/validators.js（简化）
import wcag from &quot;wcag-contrast&quot;

export function checkContrast(color1, color2, role, themeType) {
  if (!color1 || !color2) return
  const ratio = wcag.hex(color1, color2)

  let minRatio = 4.5
  if (role === &quot;textDim&quot; || role === &quot;comment&quot;) minRatio = 4.0
  if (role === &quot;textMuted&quot;) minRatio = 3.0

  if (ratio &lt; minRatio) {
    if (role === &quot;textMuted&quot;) {
      console.warn(`⚠️ 对比度略低: ${themeType} · ${role} = ${ratio.toFixed(2)}:1`)
    } else {
      throw new Error(
        `❌ 对比度不足: ${themeType} · ${role} = ${ratio.toFixed(2)}:1` +
        `\n   WCAG 要求 ≥${minRatio}:1`,
      )
    }
  } else {
    console.log(`✅ ${themeType} · ${role}: ${ratio.toFixed(2)}:1`)
  }
}
</code></pre>

<p>构建成功时，你会看到类似输出：</p>

<pre><code>✅ dark · text: 14.48:1
✅ dark · textDim: 12.02:1
✅ dark · textMuted: 6.96:1
✅ light · text: 17.08:1
✅ light · textDim: 7.25:1
✅ light · textMuted: 7.25:1
</code></pre>

<p><strong>关键设计</strong>：<code>textDim</code> 和 <code>textMuted</code> 使用<strong>阶梯式标准</strong>而非统一 4.5:1——因为「视觉退后」本身就是设计意图，只要保持最低可读性即可。如果所有辅助文字都强制 4.5:1，注释和辅助信息就无法在视觉上「退后」了。</p>

<h3 id="2-2-结构验证-生成的文件必须自洽">2.2 结构验证：生成的文件必须自洽</h3>

<p><code>validateThemeStructure</code> 验证生成的主题 JSON：</p>

<ul>
<li>必须包含 <code>name</code>、<code>type</code>、<code>colors</code>、<code>tokenColors</code>、<code>semanticTokenColors</code> 五个必需键。</li>
<li><code>colors</code> 不能为空对象，且所有值必须是合法的 6 位或 8 位十六进制色值。</li>
<li><code>tokenColors</code> 必须是数组。</li>
<li><strong>不能存在未解析的 <code>${var}</code> 或 <code>{token}</code> 残留</strong>——如果某个变量因为拼写错误没有被替换，这里会直接报错并中断构建。</li>
</ul>

<p>验证失败时，抛出 <code>ThemeValidationError</code> 并携带是哪个输出文件出错的信息，错误处理统一由主流程捕获，而不是散落在各个函数里。</p>

<h3 id="2-3-循环引用检测">2.3 循环引用检测</h3>

<p>语义层可能引用原始值，原始值理论上也可能引用另一个原始值。如果 <code>a → b → a</code> 形成循环，<code>resolveTokens</code> 会无限递归。</p>

<p><code>tokens.js</code> 的做法：<strong>设定深度上限（20 层），超过则抛出 <code>[ENGINEERING_FATAL]</code> 错误并输出引用链</strong>：</p>

<pre><code class="language-javascript">export function resolveTokens(obj, tokenMap, depth = 0, path = []) {
  const MAX_DEPTH = 20
  if (depth &gt; MAX_DEPTH) {
    throw new Error(`[ENGINEERING_FATAL] 令牌循环引用检测: ${path.join(&quot; → &quot;)}`)
  }
  // ...
}
</code></pre>

<p>20 层的上限远高于正常的令牌引用深度（正常不会超过 2-3 层），因此一旦触发，<strong>几乎可以确定是循环引用</strong>。</p>

<h3 id="2-4-重复色值与未使用令牌检测">2.4 重复色值与未使用令牌检测</h3>

<ul>
<li><strong><code>detectDuplicateColors</code></strong>：检测原始值中是否有两个不同名字指向同一个色值。这不是错误（有时同名色值用于语义区分），但值得提醒——可能是命名混乱的信号。</li>
<li><strong><code>detectUnusedPrimitives</code></strong>：检测哪些原始值没有被任何语义层引用。未被引用的原始值可能是「死代码」，也提示语义层可能存在缺口。</li>
</ul>

<h3 id="2-5-架构污染检测">2.5 架构污染检测</h3>

<p>上一篇文章介绍过：颜色必须经历「原始值 → 语义层 → 组件层」的传递链条，任何跨层直接引用都是架构污染。</p>

<p><code>tokens.js</code> 中的 <code>detectPrimitiveReference</code> 会在构建时检测<strong>组件层是否直接引用了原始值</strong>（如 <code>workbench.yaml</code> 中出现 <code>editor.background: &quot;${blue-500}&quot;</code> 而非 <code>${surfaceGround}</code>），并给出警告：</p>

<pre><code>[架构提醒] workbench 中直接引用了原始值 &quot;blue-500&quot;，建议通过语义层引用。
</code></pre>

<p>这个功能让「架构规范」从口头约定变成了<strong>可自动检查的工程约束</strong>。</p>

<p>构建必须正确，但正确还不够——<strong>生成的产物还要精简</strong>，否则文件会随语言增多而膨胀。</p>

<hr>

<h2 id="三-输出优化-让生成的-json-更精简">⚙️ 三、输出优化：让生成的 JSON 更精简</h2>

<p>工业级构建不仅要「生成正确」，还要「生成精致」。Moongate 通过两个优化步骤，让最终主题 JSON 大幅精简。</p>

<h3 id="3-1-mergetokencolors-合并相同样式的规则">3.1 <code>mergeTokenColors</code>：合并相同样式的规则</h3>

<p>不同语言中经常有为不同 scope 分配<strong>完全相同样式</strong>的规则。例如：</p>

<pre><code class="language-yaml"># python.yaml
- name: Python F-string Expression
  scope: [&quot;meta.fstring.python&quot;]
  settings: { foreground: &quot;#7dd3fc&quot; }

# jsx.yaml
- name: JSX Expression Braces
  scope: [&quot;meta.jsx.expression&quot;]
  settings: { foreground: &quot;#7dd3fc&quot; }
</code></pre>

<p>这两个规则的颜色相同，完全可以合并为一条规则、两个 scope。<code>mergeTokenColors</code> 自动完成这件事：</p>

<ul>
<li>将 <code>settings</code> 序列化为<strong>稳定键</strong>（按键名排序，避免 <code>{foreground, fontStyle}</code> 和 <code>{fontStyle, foreground}</code> 被当作不同规则）。</li>
<li>相同样式的 rule 合并，scope 聚合为数组。</li>
<li>结果按 scope 数量降序排序（更具体的规则在前）。</li>
</ul>

<p>实际效果：Moongate v2.4.0 中，<code>tokenColors</code> 规则数从约 <strong>89 条精简至约 34 条（减少 62%）</strong>，主题 JSON 体积减少约 16%。</p>

<h3 id="3-2-optimizesemantictokencolors-删除冗余-foreground">3.2 <code>optimizeSemanticTokenColors</code>：删除冗余 foreground</h3>

<p>VS Code 的语义角色支持<strong>父级继承</strong>：<code>function.declaration</code> 会自动继承 <code>function</code> 的样式，除非被显式覆盖。</p>

<p>因此，当 <code>function.declaration</code> 的 <code>foreground</code> 与父级 <code>function</code> 完全相同时，可以安全删除 <code>foreground</code>，只保留额外的 <code>fontStyle</code>：</p>

<pre><code class="language-javascript">// 优化前
{
  &quot;function&quot;: &quot;#87cefa&quot;,
  &quot;function.declaration&quot;: {
    &quot;foreground&quot;: &quot;#87cefa&quot;,  // 冗余！与父级相同
    &quot;fontStyle&quot;: &quot;bold&quot;
  }
}

// 优化后
{
  &quot;function&quot;: &quot;#87cefa&quot;,
  &quot;function.declaration&quot;: {
    &quot;fontStyle&quot;: &quot;bold&quot;  // VS Code 自动继承 function 的颜色
  }
}
</code></pre>

<p>这个优化需要小心：只有当父子关系确实存在、且 foreground 确实相同时才安全。<code>optimizeSemanticTokenColors</code> 精确实现这个逻辑，并在删除时计数输出。</p>

<p>输出精简了，还藏着一个更深的问题：<strong>规则本身对不对</strong>？scope 是不是真的存在？</p>

<hr>

<h2 id="四-scope-验证-让-规则写了对不上-成为历史">🔍 四、Scope 验证：让「规则写了对不上」成为历史</h2>

<p>主题开发中最令人沮丧的问题之一：<strong>精心编写的语言规则，在代码里却不生效</strong>。</p>

<p>原因通常是：规则里的 <code>scope</code> 并不存在于 VS Code 实际使用的 TextMate 语法中。每个语言（Python、Go、Rust……）的 scope 是<strong>由语法文件定义的</strong>，不同语言、不同版本之间可能有巨大差异。手动对照语法文档编写 scope 非常容易出错。</p>

<p>Moongate 的解决方案是 <strong><code>scripts/verify-scopes.js</code></strong>——自动解析 VS Code 内置的 TextMate 语法文件，比对语言配置中的每个 scope，找出「写了却永远不生效」的死规则。</p>

<h3 id="4-1-工作原理">4.1 工作原理</h3>

<pre><code class="language-javascript">// scripts/verify-scopes.js（CLI 入口）
import { verifyAllScopes, formatVerificationResult } from &quot;./lib/scope-validator.js&quot;

const result = verifyAllScopes({ verbose: true })

console.log(&quot;🔍 验证语言配置中的 scope...\n&quot;)
process.stdout.write(formatVerificationResult(result))

if (!result.isValid) {
  console.error(`\n❌ 发现 ${result.totalIssues} 个 scope 不匹配，验证失败！`)
  process.exit(1)
}
</code></pre>

<p><code>scope-validator.js</code> 的核心流程：</p>

<ol>
<li><strong>定位语法源</strong>：在 VS Code 安装目录中找到对应语言的 TextMate 语法 JSON 文件（如 <code>python.tmLanguage.json</code>）。</li>
<li><strong>提取全部作用域</strong>：遍历语法文件中的 <code>patterns</code>、<code>captures</code>、<code>repository</code> 等结构，收集该语言所有可用的 scope 列表。</li>
<li><strong>比对语言规则</strong>：将 <code>src/languages/*.yaml</code> 中定义的每个 scope 与语法中实际存在的 scope 比对。</li>
<li><strong>输出报告</strong>：列出每个不匹配的 scope、所在文件、是哪个语言规则定义的。</li>
</ol>

<h3 id="4-2-它发现了什么">4.2 它发现了什么？</h3>

<p>在 Moongate v2.6.0 中，<code>verify-scopes.js</code> 帮助修复了 <strong>8 个语言文件</strong>的 scope 问题。其中最有代表性的几个：</p>

<table>
<thead>
<tr>
<th>语言</th>
<th>修复前（错误的 scope）</th>
<th>修复后（正确的 scope）</th>
</tr>
</thead>

<tbody>
<tr>
<td>Rust</td>
<td><code>support.macro.rust</code></td>
<td><code>entity.name.function.macro.rust</code></td>
</tr>

<tr>
<td>Rust</td>
<td><code>lifetime</code></td>
<td><code>entity.name.type.lifetime.rust</code></td>
</tr>

<tr>
<td>Go</td>
<td><code>entity.name.package.go</code></td>
<td><code>keyword.package.go</code></td>
</tr>

<tr>
<td>Python</td>
<td><code>meta.decorator.python</code></td>
<td><code>meta.function.decorator.python</code></td>
</tr>

<tr>
<td>Markdown</td>
<td><code>heading.1.markdown</code> 等</td>
<td><code>markup.heading.markdown</code> 等</td>
</tr>
</tbody>
</table>
<p>更关键的是，<strong>验证工具可以防止回归</strong>——每次修改语言规则后运行一次，立刻知道哪些 scope 是「写了但永远不会生效」的。</p>

<h3 id="4-3-在-ci-中使用">4.3 在 CI 中使用</h3>

<p><code>verify-scopes.js</code> 在发现错误时以退出码 1 结束，因此可以无缝集成到 CI 流程中：</p>

<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;test:scopes&quot;: &quot;node scripts/verify-scopes.js&quot;
  }
}
</code></pre>

<p>CI 中运行 <code>pnpm test:scopes</code>，一旦有人提交了错误的 scope，构建立即失败。</p>

<p>验证工具越来越多，<strong>验证工具自身也需要被验证</strong>——这就是自动化测试的价值。</p>

<hr>

<h2 id="五-自动化测试-让构建系统可回归验证">🧪 五、自动化测试：让构建系统可回归验证</h2>

<p>当构建系统承担了「生成全部产物 + 质量验证 + 架构检查」的重任后，构建系统<strong>自身</strong>也需要测试来防止回归。</p>

<p>Moongate 使用 Node.js 内置的 <code>node --test</code> 测试运行器，不需要额外安装测试框架：</p>

<h3 id="5-1-测试覆盖范围">5.1 测试覆盖范围</h3>

<pre><code>test/
├── tokens.test.js        # 令牌解析、变量替换、循环检测
├── validators.test.js    # WCAG 对比度、结构验证、架构污染检测
├── optimizers.test.js    # token 合并、语义色精简
├── generators.test.js    # CSS/SCSS/TS/文档生成器
├── utils.test.js         # normalizeHex、duplicateColors 等工具
├── scope-validator.test.js # scope 验证逻辑
├── better-comments.test.js # Better Comments 生成器
├── theme-output.test.js  # 构建输出的主题 JSON 完整性
└── helpers.js            # 测试辅助（捕获 console、断言抛错）
</code></pre>

<p>85 个测试覆盖三大类：</p>

<ol>
<li><strong>纯函数逻辑</strong>：<code>normalizeHex</code> 对 3 位/4 位/8 位色值的处理、<code>resolveTokens</code> 的循环检测、<code>mergeTokenColors</code> 的稳定键等。</li>
<li><strong>边界条件</strong>：非法色值抛错、未定义变量警告、透明度后缀的多种边界情况。</li>
<li><strong>产物一致性</strong>：生成的 CSS 变量与语义层一一对应、Better Comments 配置与深色语义层色值同步。</li>
</ol>

<h3 id="5-2-测试辅助-捕获-console-输出">5.2 测试辅助：捕获 console 输出</h3>

<p>构建工具大量使用 <code>console.log</code> / <code>console.warn</code> 输出进度和警告。测试时，<code>test/helpers.js</code> 提供统一的辅助函数来捕获这些输出并断言：</p>

<pre><code class="language-javascript">// test/helpers.js（示意）
export function captureConsole(fn) {
  const logs = []
  const originalLog = console.log
  const originalWarn = console.warn
  console.log = (...args) =&gt; logs.push({ type: &quot;log&quot;, args })
  console.warn = (...args) =&gt; logs.push({ type: &quot;warn&quot;, args })
  try {
    const result = fn()
    return { logs, result }
  } finally {
    console.log = originalLog
    console.warn = originalWarn
  }
}
</code></pre>

<p>这个模式的优雅之处：<strong>不需要 mock 每个函数</strong>——只要断言 <code>console.warn</code> 被调用过，且传入了包含「警告」关键词的参数，就能验证「未定义变量时会发出警告」这类行为。</p>

<h3 id="5-3-运行测试">5.3 运行测试</h3>

<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;test&quot;: &quot;node --test \&quot;test/*.test.js\&quot;&quot;
  }
}
</code></pre>

<pre><code class="language-bash">pnpm test
</code></pre>

<p>输出示例：</p>

<pre><code>▶ tokens.test.js
  ✔ 解析令牌引用 {token}
  ✔ 替换变量 ${var} 支持透明度后缀
  ✔ 检测到循环引用时抛出错误
  ...
▶ validators.test.js
  ✔ 结构验证：缺少必需键时报错
  ✔ WCAG 对比度不足时抛出异常
  ...
✔ 85 tests passed
</code></pre>

<p>构建体系稳定之后，可以把这套能力伸向<strong>生态整合</strong>：让配色在插件生态里也不漂移。</p>

<hr>

<h2 id="六-better-comments-双源消除">🎨 六、Better Comments 双源消除</h2>

<p>主题的一个重要生态整合是 <strong>Better Comments</strong> 插件——它让注释中的 <code>TODO</code>、<code>FIXME</code>、<code>NOTE</code> 等标记拥有专属颜色。旧方案的问题在于：<strong>颜色存在两处</strong>（主题的语义层 + Better Comments 的 JSON 配置），修改一处后另一处容易忘改，导致配色漂移。</p>

<p>Moongate 的解法：<strong>Better Comments 配置由构建脚本自动生成</strong>，从深色语义层读取色值。</p>

<h3 id="6-1-单源真相">6.1 单源真相</h3>

<pre><code class="language-javascript">// scripts/generate-better-comments.js（核心逻辑）
import { resolveTokens } from &quot;./lib/tokens.js&quot;

// Better Comments 需要的语义变量 → tag 映射
const TAG_MAP = [
  { tag: &quot;TODO&quot;, semanticKey: &quot;warning&quot;, bold: true },
  { tag: &quot;FIXME&quot;, semanticKey: &quot;error&quot;, bold: true, italic: true },
  { tag: &quot;NOTE&quot;, semanticKey: &quot;highlight&quot;, italic: true },
  { tag: &quot;HACK&quot;, semanticKey: &quot;purple&quot;, bold: true },
  { tag: &quot;BUG&quot;, semanticKey: &quot;error&quot;, bold: true, underline: true },
  { tag: &quot;XXX&quot;, semanticKey: &quot;warning&quot;, bold: true },
]

// 从深色语义层解析每个 tag 对应的最终色值
const tags = TAG_MAP.map(({ tag, semanticKey, ...style }) =&gt; {
  const color = resolveTokens(darkSemantics[semanticKey], primitives)
  return { tag, color, ...style }
})

// 写入 extras/better-comments.json
</code></pre>

<p>当你在 <code>dark.yaml</code> 中调整 <code>warning</code> 的颜色时，运行 <code>pnpm run gen:better-comments</code>，<code>extras/better-comments.json</code> 自动同步。</p>

<h3 id="6-2-内置规则-零配置开箱即用">6.2 内置规则：零配置开箱即用</h3>

<p>除了生成独立预设，Moongate 还在 <code>src/special/better-comments.yaml</code> 中内置了 6 个特殊注释 scope 的规则（TODO、FIXME、NOTE、HACK、BUG、XXX）。<strong>安装 Moongate 主题后，Better Comments 插件自动使用官方配色，无需任何手动配置。</strong></p>

<p>这条「双通道」设计覆盖了两种用户：</p>

<ul>
<li><strong>主题用户</strong>：安装 Moongate 即获得完整配色（内置规则）。</li>
<li><strong>独立配置用户</strong>：不使用主题、只看注释配色的人，可以单独引入 <code>extras/better-comments.json</code>。</li>
</ul>

<p>两条通道都从语义层生成，<strong>不会产生双源漂移</strong>。</p>

<hr>

<h2 id="七-多格式产物生成-一套令牌-全平台复用">📦 七、多格式产物生成：一套令牌，全平台复用</h2>

<p>设计系统介绍了 CSS 变量导出，Moongate 进一步将语义层导出为<strong>四种格式</strong>，覆盖 Web、Sass 和 TypeScript 生态：</p>

<table>
<thead>
<tr>
<th>产物</th>
<th>格式</th>
<th>适用场景</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>themes/moongate-colors.css</code></td>
<td>CSS 变量</td>
<td>博客、组件库、任何 Web 项目</td>
</tr>

<tr>
<td><code>themes/moongate-layout.css</code></td>
<td>CSS 变量（布局）</td>
<td>间距、排版、断点、z-index</td>
</tr>

<tr>
<td><code>themes/_tokens.scss</code></td>
<td>SCSS</td>
<td>Sass 项目</td>
</tr>

<tr>
<td><code>themes/tokens.ts</code></td>
<td>TypeScript</td>
<td>前端框架项目</td>
</tr>
</tbody>
</table>

<h3 id="7-1-scss-令牌">7.1 SCSS 令牌</h3>

<pre><code class="language-scss">// themes/_tokens.scss（自动生成）
// 部分示意
$ui-colors-dark: (
  bg: #0f172a,
  primary: #3b82f6,
  surface-raised: #1a2538,
  // ...
);

$ui-colors-light: (
  bg: #f9fafb,
  primary: #0284c7,
  surface-raised: #ffffff,
  // ...
);

// 深色模式便捷变量
$ui-bg: #0f172a;
$ui-primary: #3b82f6;
</code></pre>

<h3 id="7-2-typescript-令牌">7.2 TypeScript 令牌</h3>

<pre><code class="language-typescript">// themes/tokens.ts（自动生成）
export interface MoongateTokens {
  dark: Record&lt;string, string&gt;
  light: Record&lt;string, string&gt;
}

export const tokens: MoongateTokens = {
  dark: {
    bg: &quot;#0f172a&quot;,
    primary: &quot;#3b82f6&quot;,
    // ...
  },
  light: {
    bg: &quot;#f9fafb&quot;,
    primary: &quot;#0284c7&quot;,
    // ...
  },
}

export default tokens
</code></pre>

<h3 id="7-3-设计系统文档自动生成">7.3 设计系统文档自动生成</h3>

<p>构建脚本还会自动生成 <code>docs/DESIGN_SYSTEM.md</code>——包含变量选择协议、原始色板预览、海拔系统表格、WCAG 对比度数据。这份文档<strong>不是手写的</strong>，而是每次构建时从真实数据生成，保证文档永远与代码一致。</p>

<hr>

<h2 id="八-ci-发布链路-错误在发布前被发现">🔧 八、CI/发布链路：错误在发布前被发现</h2>

<p>工业级工程的最后一块拼图：<strong>把验证融入发布流程</strong>。</p>

<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;build&quot;: &quot;node scripts/build.js&quot;,
    &quot;test&quot;: &quot;node --test \&quot;test/*.test.js\&quot;&quot;,
    &quot;test:scopes&quot;: &quot;node scripts/verify-scopes.js&quot;,
    &quot;gen:better-comments&quot;: &quot;node scripts/generate-better-comments.js&quot;,
    &quot;package&quot;: &quot;pnpm run build &amp;&amp; pnpm run gen:better-comments &amp;&amp; vsce package&quot;,
    &quot;publish&quot;: &quot;pnpm run build &amp;&amp; pnpm run gen:better-comments &amp;&amp; vsce publish&quot;,
    &quot;prepublishOnly&quot;: &quot;pnpm run build &amp;&amp; pnpm run gen:better-comments&quot;
  }
}
</code></pre>

<p>发布链路逐层把关：</p>

<pre><code>pnpm run package
  → pnpm run build              # 构建 + WCAG 校验 + 结构验证 + 架构检测
  → pnpm run gen:better-comments # 重新生成 Better Comments 配置
  → vsce package                # 打包 .vsix
</code></pre>

<ul>
<li>构建失败 → 不产生 <code>.vsix</code>。</li>
<li>对比度不足 → 构建中断，错误信息标明是哪个主题、哪个角色、差多少。</li>
<li>scope 错误 → <code>pnpm run test:scopes</code> 退出码 1，CI 拦截。</li>
</ul>

<p><strong>「发布前检查清单」从手动流程升级为自动化门禁</strong>——这正是工业级与手动的分水岭。</p>

<hr>

<h2 id="九-总结">📊 九、总结</h2>

<p>至此，你的主题构建系统已经完成了从「能用」到「工业级」的跃迁：</p>

<table>
<thead>
<tr>
<th>能力</th>
<th>主题工程化</th>
<th>构建体系</th>
</tr>
</thead>

<tbody>
<tr>
<td>模块化</td>
<td>单体 <code>build.js</code></td>
<td><code>scripts/lib/</code> 单职责模块</td>
</tr>

<tr>
<td>代码风格</td>
<td>CommonJS</td>
<td>ESM</td>
</tr>

<tr>
<td>WCAG 校验</td>
<td>❌</td>
<td>✅ 阶梯式对比度自动检查</td>
</tr>

<tr>
<td>结构验证</td>
<td>❌</td>
<td>✅ 未解析变量/令牌检测</td>
</tr>

<tr>
<td>循环引用</td>
<td>❌</td>
<td>✅ 深度上限 + 引用链输出</td>
</tr>

<tr>
<td>架构污染</td>
<td>❌</td>
<td>✅ 原始值直接引用警告</td>
</tr>

<tr>
<td>输出优化</td>
<td>❌</td>
<td>✅ token 合并 + 语义色精简</td>
</tr>

<tr>
<td>Scope 验证</td>
<td>❌</td>
<td>✅ <code>verify-scopes.js</code> 自动比对</td>
</tr>

<tr>
<td>自动化测试</td>
<td>❌</td>
<td>✅ 85 个测试（<code>node --test</code>）</td>
</tr>

<tr>
<td>多格式产物</td>
<td>CSS</td>
<td>CSS + SCSS + TypeScript + 设计文档</td>
</tr>

<tr>
<td>Better Comments</td>
<td>❌</td>
<td>✅ 自动生成 + 内置规则</td>
</tr>

<tr>
<td>发布门禁</td>
<td>手动检查</td>
<td>构建/测试/scope 验证自动拦截</td>
</tr>
</tbody>
</table>
<p>这套体系不仅是 Moongate 主题的生产工具，更是一个可以复用的<strong>设计系统工程模板</strong>。它验证了「零散的颜色 → 工程资产 → 全平台可复用」的完整路径。</p>

<p>但工程能力再强，也只是「怎么做」的问题。下一个问题同样重要：<strong>「为什么这样做」——设计哲学从何而来？如何让用户在不同硬件上获得一致的体验？如何让主题超越代码，成为品牌的组成部分？</strong></p>

<p>这正是系列的最后一篇——<strong>品牌生态</strong>要回答的。</p>

<p><a href="./create-vscode-theme-brand-ecosystem"><strong>品牌生态：设计哲学与视觉契约</strong></a></p>
]]></content:encoded>
      <description><![CDATA[从单体脚本到模块化工程体系——ESM 模块拆分、WCAG 对比度自动校验、scope 自动验证、自动化测试与多格式产物生成，让构建脚本自身成为一套可信赖的工程基础设施。]]></description>
      <category><![CDATA[VSCode]]></category>
      <category><![CDATA[Theme]]></category>
      <category><![CDATA[Design System]]></category>
      <category><![CDATA[Engineering]]></category>
      <category><![CDATA[CI/CD]]></category>
      <dc:subject>P4</dc:subject>
      <dc:relation><![CDATA[series:design-system]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[设计系统：DTCG 三层架构与昼夜双变体]]></title>
      <link>https://moongate.top/docs/create-vscode-theme-design-system</link>
      <guid isPermaLink="true">97e09fc8-13a0-4703-958d-44700fe20a62</guid>
      <pubDate>Thu, 06 Aug 2026 04:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共五篇，覆盖从零基础创建到工业级设计系统的 VS Code 主题开发全流程（对应 <strong>Moongate v2.6.0</strong>）：</p>

<ol>
<li><p><a href="./create-vscode-theme-basics"><strong>VS Code 主题：从手写 JSON 到可发布</strong></a>
—— 不依赖脚手架，手写最小主题 JSON，掌握 <code>colors</code> 与 <code>tokenColors</code> 的核心机制与发布流程。</p></li>

<li><p><a href="./create-vscode-theme-engineering"><strong>主题工程化：从单体 JSON 到模块化 YAML</strong></a>
—— 将单体 JSON 重构为模块化 YAML 项目，用构建脚本实现变量替换与自动生成。</p></li>

<li><p><a href="./create-vscode-theme-design-system"><strong>设计系统：DTCG 三层架构与昼夜双变体</strong></a>
—— 用 DTCG 设计令牌标准管理颜色，通过语义层与重力补偿构建深色/浅色双变体。</p></li>

<li><p><a href="./create-vscode-theme-build-system"><strong>构建体系：可测试、可验证的工程实践</strong></a>
—— 模块化构建体系、WCAG 对比度校验、scope 自动验证、自动化测试与多格式产物生成。</p></li>

<li><p><a href="./create-vscode-theme-brand-ecosystem"><strong>品牌生态：设计哲学与视觉契约</strong></a>
—— 为你的主题赋予设计哲学、视觉契约和品牌生态，打造完整的设计系统。</p></li>
</ol>

<hr>

<p>在<a href="./create-vscode-theme-engineering">主题工程化</a>的结尾，我们指出了工程化方案的三个痛点：</p>

<ul>
<li><code>colors.yaml</code> 是一个<strong>扁平的变量池</strong>，颜色之间的层级关系完全靠命名约定，没有结构性的约束。</li>
<li>深浅两套主题需要两套颜色变量文件，而「同一角色在深色和浅色下应该保持色相一致、明度不同」这件事完全靠手动维护。</li>
<li>构建脚本只能做变量替换，不能自动校验颜色是否符合对比度标准。</li>
</ul>

<p>本篇将解决这些问题。我们将引入 <strong>DTCG（Design Tokens Community Group）设计令牌标准</strong>的三层架构，让颜色从「散乱的变量」升级为「有结构的工程资产」，并基于它构建深色/浅色双变体——这是主题迈向设计系统的关键一步。</p>

<hr>

<h2 id="一-核心思路-从-多主题-到-设计系统">🧱 一、核心思路：从「多主题」到「设计系统」</h2>

<p>一个优秀的主题不应只有一副面孔。提供深色/浅色双主题，不仅能覆盖更广泛的用户需求，更是主题迈向设计系统的关键一步。</p>

<p>但<strong>多主题的真正价值不是多几套颜色</strong>，而是：<strong>所有主题共享同一套规则，唯一不同的是颜色变量的具体值。</strong></p>

<ul>
<li>语言规则（<code>languages/*.yaml</code>）在所有变体中完全复用。</li>
<li>UI 颜色（<code>workbench.yaml</code>）和语义规则（<code>semantic.yaml</code>）只引用变量名，不写具体色值。</li>
<li>每个变体只提供一个「语义层」，定义每个语义角色在该变体下的具体颜色。</li>
</ul>

<p>要做到这一点，需要一套能让「颜色」成为结构化工程资产的管理体系——这就是 DTCG 三层架构。</p>

<hr>

<h2 id="二-dtcg-三层架构">🏗️ 二、DTCG 三层架构</h2>

<p>DTCG（Design Tokens Community Group）是 W3C 下属的行业标准组织，旨在为设计令牌（Design Tokens）建立统一格式。它的核心思想可以用一个简单的问题概括：<strong>一个颜色值，应该由谁来定义、被谁引用、以什么名称存在？</strong></p>

<p>Moongate 采用 DTCG 推荐的三层架构：</p>

<pre><code>┌─────────────────────────────────────────────────┐
│  原始值层（Primitives）                           │
│  src/core/primitives/colors.yaml                 │
│  按色相-明度命名：blue-500、gray-900             │
└───────────────────────┬─────────────────────────┘
                        │ 语义层用 {token} 引用原始值
                        ▼
┌─────────────────────────────────────────────────┐
│  语义层（Semantics）                              │
│  src/core/semantics/dark.yaml + light.yaml       │
│  定义角色：primary、bg、surfaceGround            │
│  primary: &quot;{blue-500}&quot;                           │
└───────────────────────┬─────────────────────────┘
                        │ 组件层用 ${variable} 引用语义层
                        ▼
┌─────────────────────────────────────────────────┐
│  组件层（Components）                             │
│  src/workbench.yaml + semantic.yaml              │
│  映射 UI 元素                                    │
│  editor.background: &quot;${surfaceGround}&quot;           │
└─────────────────────────────────────────────────┘
</code></pre>

<h3 id="2-1-第一层-原始值-primitives">2.1 第一层：原始值（Primitives）</h3>

<p>原始值层是<strong>所有颜色的物理事实</strong>——不表达任何语义，只按色相和明度命名。</p>

<pre><code class="language-yaml"># src/core/primitives/colors.yaml
# ==================== 蓝色系 ====================
blue-500: &quot;#3b82f6&quot; # 深色主蓝
blue-600: &quot;#2563eb&quot; # 深色按钮悬停
blue-700: &quot;#0284c7&quot; # 浅色主蓝
blue-800: &quot;#0369a1&quot; # 浅色高亮/函数

# 发光蓝（特殊）
blue-glow: &quot;#7dd3fc&quot; # 深色高亮
blue-glow-dark: &quot;#87cefa&quot; # 深色函数

# ==================== 灰色阶（冷调基底）====================
gray-900: &quot;#0f172a&quot; # 深色编辑器背景
gray-850: &quot;#131c31&quot; # 深色卡片/浮层
gray-800: &quot;#1e293b&quot; # 深色侧边栏/代码块
gray-750: &quot;#252e40&quot; # 深色悬停背景
gray-700: &quot;#2d3748&quot; # 深色边框
# ... 完整的色相-明度阶梯
</code></pre>

<p><strong>命名规范</strong>：<code>色相-明度</code>，例如 <code>blue-500</code>、<code>green-400</code>、<code>gray-900</code>。这样命名不是为了好看，而是为了让「同一个色相在不同明度下如何变化」这件事变得可追溯。</p>

<p><strong>原始值的价值</strong>：当你需要「给所有主题换一个更蓝的主色」时，只需要调整 <code>blue-500</code> 和 <code>blue-700</code> 两个原始值，所有引用它的语义色自动同步。</p>

<h3 id="2-2-第二层-语义层-semantics">2.2 第二层：语义层（Semantics）</h3>

<p>语义层定义<strong>角色</strong>——<code>primary</code>（主色）、<code>bg</code>（背景）、<code>surfaceGround</code>（地面层）——并为每个角色指派一个原始值。<strong>语义层不与任何 UI 元素绑定</strong>，它只回答一个问题：「主色应该是什么颜色？」</p>

<p>每个变体都有一个独立的语义层文件：</p>

<pre><code class="language-yaml"># src/core/semantics/dark.yaml（深色语义层）
# ==================== 月语义主色 ====================
primary: &quot;{blue-500}&quot;
success: &quot;{green-400}&quot;
warning: &quot;{yellow-400}&quot;
error: &quot;{red-400}&quot;
highlight: &quot;{blue-glow}&quot;

# 功能色（语法）
function: &quot;{blue-glow-dark}&quot;
operator: &quot;{gray-600}&quot;
comment: &quot;{gray-525}&quot;
variable: &quot;{gray-200}&quot;

# 海拔系统
surfaceGround: &quot;{gray-900}&quot;
surfaceRaised: &quot;{gray-850}&quot;
surfaceFloating: &quot;{gray-800}&quot;
surfaceTooltip: &quot;{gray-750}&quot;
</code></pre>

<pre><code class="language-yaml"># src/core/semantics/light.yaml（浅色语义层）
# 变量名与深色版完全一致，仅色值不同
# ==================== 月语义主色 ====================
primary: &quot;{blue-700}&quot;
success: &quot;{green-600}&quot;
warning: &quot;{yellow-700}&quot;
error: &quot;{red-700}&quot;
highlight: &quot;{blue-800}&quot;

# 功能色（语法）
function: &quot;{blue-800}&quot;
operator: &quot;{gray-600}&quot;
comment: &quot;{gray-600}&quot;
variable: &quot;{gray-900}&quot;

# 海拔系统
surfaceGround: &quot;{gray-50}&quot;
surfaceRaised: &quot;{white}&quot;
surfaceFloating: &quot;{gray-100}&quot;
surfaceTooltip: &quot;{gray-200}&quot;
</code></pre>

<p><strong>关键原则</strong>：所有变体的语义层变量名<strong>必须完全一致</strong>。这是规则复用的基础——<code>workbench.yaml</code> 和 <code>languages/*.yaml</code> 不需要知道当前是深色还是浅色，它们只引用 <code>${primary}</code>、<code>${surfaceRaised}</code>，具体值交给语义层决定。</p>

<h3 id="2-3-第三层-组件层-components">2.3 第三层：组件层（Components）</h3>

<p>组件层<strong>直接映射 UI 元素</strong>和语法角色。它只引用语义层变量，不写任何具体色值：</p>

<pre><code class="language-yaml"># src/workbench.yaml（组件层：UI 颜色）
editor.background: &quot;${surfaceGround}&quot;
editor.foreground: &quot;${text}&quot;
titleBar.activeBackground: &quot;${surfaceRaised}&quot;
titleBar.activeForeground: &quot;${text}&quot;
statusBar.background: &quot;${surfaceGround}&quot;
statusBar.foreground: &quot;${textDim}&quot;
sideBar.background: &quot;${surfaceRaised}&quot;
# ... 所有 UI 键
</code></pre>

<pre><code class="language-yaml"># src/semantic.yaml（组件层：语义高亮）
variable: &quot;${variable}&quot;
variable.readonly:
  foreground: &quot;${variableDim}&quot;
  fontStyle: &quot;italic&quot;
function: &quot;${function}&quot;
function.declaration:
  foreground: &quot;${function}&quot;
  fontStyle: &quot;bold&quot;
class: &quot;${warning}&quot;
# ... 语义规则
</code></pre>

<h3 id="2-4-两种引用语法-token-与-variable">2.4 两种引用语法：<code>{token}</code> 与 <code>${variable}</code></h3>

<p>在 Moongate 的架构中，两种「引用」有着完全不同的语义：</p>

<table>
<thead>
<tr>
<th>语法</th>
<th>含义</th>
<th>使用位置</th>
<th>示例</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>{token}</code></td>
<td><strong>层间引用</strong>：引用另一个令牌</td>
<td>语义层引用原始值</td>
<td><code>primary: &quot;{blue-500}&quot;</code></td>
</tr>

<tr>
<td><code>${variable}</code></td>
<td><strong>变量替换</strong>：构建时替换为最终色值</td>
<td>组件层引用语义层</td>
<td><code>editor.background: &quot;${surfaceGround}&quot;</code></td>
</tr>
</tbody>
</table>

<ul>
<li>语义层用 <code>{token}</code> 引用原始值，构建脚本递归解析令牌引用（支持循环检测，见构建体系）。</li>
<li>组件层用 <code>${variable}</code> 引用语义层变量，构建脚本替换为最终色值（支持透明度后缀，如 <code>${primary}20</code>）。</li>
</ul>

<p>这个分工不是形式主义——它让每个文件都清楚自己「属于哪一层、可以引用谁」。构建体系中我们会看到，构建脚本甚至能检测「组件层直接引用原始值」这种架构污染并发出警告。</p>

<hr>

<h2 id="三-昼夜双变体与重力补偿">⚖️ 三、昼夜双变体与重力补偿</h2>

<p>有了三层架构，深色/浅色双主题的构建就变得非常自然：<strong>所有规则文件完全复用，只有语义层不同</strong>。但语义层的色值不是随手填的——深色和浅色之间需要一套科学的映射规则。</p>

<h3 id="3-1-为什么浅色不是深色的-反相">3.1 为什么浅色不是深色的「反相」？</h3>

<p>很多新手以为浅色主题就是把深色主题的颜色反相。但这是完全错误的：</p>

<ul>
<li>深色背景上的亮色是<strong>「发光体」</strong>——它们通过「亮于背景」来获得存在感。</li>
<li>浅色背景上的暗色是<strong>「吸光体」</strong>——它们通过「暗于背景」来获得存在感。</li>
</ul>

<p>同样是 <code>primary</code>，在深色下用亮蓝 <code>#3b82f6</code>（明度 60%）很好看，但如果直接搬到白底上，就会因为和白色背景的对比度不足而「消失」。要实现视觉重量对等，必须进行<strong>重力补偿</strong>——保持色相不变，科学调整明度和饱和度。</p>

<h3 id="3-2-moongate-的补偿实例">3.2 Moongate 的补偿实例</h3>

<table>
<thead>
<tr>
<th>语义角色</th>
<th>深色版</th>
<th>浅色版</th>
<th>调整方法</th>
</tr>
</thead>

<tbody>
<tr>
<td>主色（primary）</td>
<td><code>#3b82f6</code> (60% 明度)</td>
<td><code>#0284c7</code> (48% 明度)</td>
<td>蓝调不变，明度降低约 20%，适应白底</td>
</tr>

<tr>
<td>成功（success）</td>
<td><code>#34d399</code> (65%)</td>
<td><code>#059669</code> (40%)</td>
<td>绿色更沉稳，保证对比度</td>
</tr>

<tr>
<td>警告（warning）</td>
<td><code>#fbbf24</code> (75%)</td>
<td><code>#b45309</code> (35%)</td>
<td>从亮黄转为橙黄，避免在白底上「消失」</td>
</tr>

<tr>
<td>错误（error）</td>
<td><code>#f87171</code> (60%)</td>
<td><code>#b91c1c</code> (35%)</td>
<td>深红保持警示感</td>
</tr>
</tbody>
</table>
<p><strong>补偿规律</strong>：</p>

<ul>
<li><strong>色相（H）不变</strong>——这是语义一致性的核心。<code>primary</code> 永远是蓝色，用户在切换主题时不需要重新学习。</li>
<li><strong>明度（L）降低</strong>——深色版的亮色在白底上需要更暗才能达到同等对比度。通常降低 20-30%。</li>
<li><strong>饱和度（S）适当调整</strong>——深色背景上高饱和度产生舒适的「发光感」，但在浅色背景上可能刺眼，通常降低 10-20%。</li>
</ul>

<blockquote>
<p>💡 <strong>如何科学地确定补偿值</strong>：手动「凭感觉」调整明度效率低且不一致。推荐使用 <strong>HSL 颜色模型</strong>：把 HEX 转成 HSL，保持 H 不变，调整 L 和 S。可以用 <a href="https://gka.github.io/chroma.js/" target="_blank">chroma.js</a> 等工具程序化调整，例如：</p>

<pre><code class="language-javascript">const darkPrimary = chroma(&quot;#3b82f6&quot;)
const lightPrimary = darkPrimary.set(&quot;hsl.l&quot;, 0.48).set(&quot;hsl.s&quot;, 0.7)
</code></pre>
</blockquote>

<h3 id="3-3-海拔系统-为-ui-注入物理深度">3.3 海拔系统：为 UI 注入物理深度</h3>

<p>除了语法颜色，UI 界面也需要在深浅两套主题中保持一致的「空间感」。海拔系统通过定义多级背景明度阶梯，表达 UI 元素的物理深度：</p>

<ul>
<li>深色模式：海拔越高表面越亮（明度递增）。</li>
<li>浅色模式：海拔越高表面越亮（但用更高亮度的白色/浅色）。</li>
<li>每层阶梯的步长保持一致，形成平滑的层次感。</li>
</ul>

<p><strong>Moongate 的四层海拔</strong>（v2.6.0 最新值）：</p>

<table>
<thead>
<tr>
<th>海拔层级</th>
<th>用途</th>
<th>深色模式</th>
<th>浅色模式</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>surfaceGround</code></td>
<td>底层背景（编辑器）</td>
<td><code>#0f172a</code></td>
<td><code>#f9fafb</code></td>
<td>基准层</td>
</tr>

<tr>
<td><code>surfaceRaised</code></td>
<td>侧边栏、活动栏、选项卡栏</td>
<td><code>#1a2538</code></td>
<td><code>#ffffff</code></td>
<td>深色 +5%，浅色纯白</td>
</tr>

<tr>
<td><code>surfaceFloating</code></td>
<td>面板、悬浮卡片、菜单</td>
<td><code>#25364a</code></td>
<td><code>#f1f5f9</code></td>
<td>再 +5%，浅色浅灰蓝</td>
</tr>

<tr>
<td><code>surfaceTooltip</code></td>
<td>提示框、弹窗</td>
<td><code>#2e3b4d</code></td>
<td><code>#e2e8f0</code></td>
<td>最高层</td>
</tr>
</tbody>
</table>

<blockquote>
<p>📌 <strong>注意</strong>：浅色模式采用「越高越亮」还是「越高越暗」是一个设计选择。Moongate 选择浅色模式下浮层比背景<strong>更亮</strong>（更白），形成「纸张层叠」的通透感；深色模式则采用「越高越亮」，让浮层从背景中微微隆起。两种模式都遵循「海拔越高越醒目」的物理隐喻。</p>
</blockquote>

<p>这种设计让侧边栏微微隆起，弹窗轻盈浮现，代码区沉静深邃——编辑器从平面走向立体。</p>

<hr>

<h2 id="四-构建脚本自动生成双主题">🔨 四、构建脚本自动生成双主题</h2>

<p>有了三层架构，构建脚本只需要做一件事：<strong>扫描 <code>semantics/</code> 目录，为每个语义层文件生成一个主题 JSON</strong>。</p>

<p>以 <code>scripts/build.js</code> 的简化版为例：</p>

<pre><code class="language-javascript">// scripts/build.js（简化版，完整版见构建体系）
import fs from &quot;node:fs&quot;
import path from &quot;node:path&quot;
import yaml from &quot;js-yaml&quot;

const ROOT_DIR = process.cwd()

// 路径配置
const PATHS = {
  primitives: path.join(ROOT_DIR, &quot;src&quot;, &quot;core&quot;, &quot;primitives&quot;, &quot;colors.yaml&quot;),
  semanticsDir: path.join(ROOT_DIR, &quot;src&quot;, &quot;core&quot;, &quot;semantics&quot;),
  workbench: path.join(ROOT_DIR, &quot;src&quot;, &quot;workbench.yaml&quot;),
  semantic: path.join(ROOT_DIR, &quot;src&quot;, &quot;semantic.yaml&quot;),
  langDir: path.join(ROOT_DIR, &quot;src&quot;, &quot;languages&quot;),
  outputDir: path.join(ROOT_DIR, &quot;themes&quot;),
}

// 加载原始值
const primitives = yaml.load(fs.readFileSync(PATHS.primitives, &quot;utf8&quot;))

// 解析令牌引用 {token}：语义层引用原始值
function resolveTokens(obj, tokenMap, depth = 0) {
  const MAX_DEPTH = 20
  if (depth &gt; MAX_DEPTH) {
    throw new Error(
      `[ENGINEERING_FATAL] 令牌循环引用检测: ${JSON.stringify(obj)}`,
    )
  }
  if (typeof obj === &quot;string&quot;) {
    return obj.replace(/\{([a-zA-Z0-9_-]+)\}/g, (match, key) =&gt; {
      const value = tokenMap[key]
      if (value === undefined) {
        console.warn(`⚠️ 警告: 令牌 &quot;${key}&quot; 未定义，保留原样`)
        return match
      }
      return resolveTokens(value, tokenMap, depth + 1)
    })
  }
  if (Array.isArray(obj))
    return obj.map((item) =&gt; resolveTokens(item, tokenMap, depth + 1))
  if (obj &amp;&amp; typeof obj === &quot;object&quot;) {
    const result = {}
    for (const [k, v] of Object.entries(obj)) {
      result[k] = resolveTokens(v, tokenMap, depth + 1)
    }
    return result
  }
  return obj
}

// 替换变量 ${var}：组件层引用语义层（支持透明后缀）
function replaceVariables(obj, colors) {
  if (typeof obj === &quot;string&quot;) {
    return obj.replace(
      /\$\{([a-zA-Z0-9_-]+)\}([0-9a-fA-F]{2})?/g,
      (match, key, alpha) =&gt; {
        const value = colors[key]
        if (value === undefined) {
          console.warn(`⚠️ 警告: 变量 &quot;${key}&quot; 未定义，保留原样`)
          return match
        }
        return value + (alpha || &quot;&quot;)
      },
    )
  }
  if (Array.isArray(obj))
    return obj.map((item) =&gt; replaceVariables(item, colors))
  if (obj &amp;&amp; typeof obj === &quot;object&quot;) {
    const result = {}
    for (const [k, v] of Object.entries(obj)) {
      result[k] = replaceVariables(v, colors)
    }
    return result
  }
  return obj
}

// 加载公共规则
const workbenchRaw = yaml.load(fs.readFileSync(PATHS.workbench, &quot;utf8&quot;))
const semanticRaw = yaml.load(fs.readFileSync(PATHS.semantic, &quot;utf8&quot;))

// 扫描并合并语言规则
let tokenColorsRaw = []
fs.readdirSync(PATHS.langDir)
  .filter((f) =&gt; f.endsWith(&quot;.yaml&quot;))
  .sort()
  .forEach((file) =&gt; {
    const rules = yaml.load(
      fs.readFileSync(path.join(PATHS.langDir, file), &quot;utf8&quot;),
    )
    if (rules?.tokenColors) {
      tokenColorsRaw = tokenColorsRaw.concat(rules.tokenColors)
    }
  })

// 读取 package.json 获取主题基础名
const pkg = JSON.parse(
  fs.readFileSync(path.join(ROOT_DIR, &quot;package.json&quot;), &quot;utf8&quot;),
)
const baseName = pkg.name.replace(/[^a-z0-9-]/gi, &quot;-&quot;).toLowerCase()

// 为每个语义层文件构建一个主题
const semanticFiles = fs
  .readdirSync(PATHS.semanticsDir)
  .filter((f) =&gt; f.endsWith(&quot;.yaml&quot;))

semanticFiles.forEach((semanticFile) =&gt; {
  const themeType = path.basename(semanticFile, &quot;.yaml&quot;) // 'dark' 或 'light'
  const semantics = yaml.load(
    fs.readFileSync(path.join(PATHS.semanticsDir, semanticFile), &quot;utf8&quot;),
  )

  // 1. 解析语义层的令牌引用 {token} -&gt; 最终色值
  const resolved = resolveTokens(semantics, primitives)

  // 2. 组件层替换变量 ${var}
  const uiColors = replaceVariables(workbenchRaw, resolved)
  const semanticColors = replaceVariables(semanticRaw, resolved)
  const tokenColors = replaceVariables(tokenColorsRaw, resolved)

  // 3. 组装主题对象
  const type = themeType.includes(&quot;light&quot;) ? &quot;light&quot; : &quot;dark&quot;
  const theme = {
    name: `${pkg.displayName || &quot;My Theme&quot;} ${themeType === &quot;dark&quot; ? &quot;Dark&quot; : &quot;Light&quot;}`,
    type: type,
    colors: uiColors,
    tokenColors: tokenColors,
    semanticTokenColors: semanticColors,
  }

  // 4. 写入输出文件
  const outputFile = path.join(PATHS.outputDir, `${baseName}-${themeType}.json`)
  if (!fs.existsSync(PATHS.outputDir)) {
    fs.mkdirSync(PATHS.outputDir, { recursive: true })
  }
  fs.writeFileSync(outputFile, JSON.stringify(theme, null, 2))
  console.log(`   ✅ 构建完成: ${outputFile}`)
})
</code></pre>

<p><strong>核心逻辑</strong>：</p>

<ol>
<li>加载原始值。</li>
<li>扫描 <code>semantics/</code> 目录，每个语义层文件（<code>dark.yaml</code> / <code>light.yaml</code>）对应一个主题。</li>
<li>解析语义层的 <code>{token}</code> 引用，得到该变体的最终色值字典。</li>
<li>组件层用 <code>${var}</code> 替换为最终色值。</li>
<li>输出 <code>${baseName}-dark.json</code> 和 <code>${baseName}-light.json</code>。</li>
</ol>

<p><strong>新增主题的成本</strong>：只需在 <code>semantics/</code> 目录下添加一个新的 YAML 语义层文件（如 <code>sepia.yaml</code>），构建脚本自动扫描生成对应主题。<strong>无需修改任何规则文件</strong>。</p>

<blockquote>
<p>🔮 <strong>预告</strong>：构建逻辑越来越复杂——解析令牌、替换变量、合并规则、扫描语义层。你可能会问：<strong>怎么证明它写得对？</strong> 当颜色出错、scope 匹配不上时，如何自动发现？这就是下一篇引入<strong>自动化测试与质量验证</strong>的原因。</p>
</blockquote>

<hr>

<h2 id="五-注册多个主题">📦 五、注册多个主题</h2>

<p>在 <code>package.json</code> 的 <code>contributes.themes</code> 中为每个主题添加一个条目，注意 <code>uiTheme</code> 字段的正确设置：</p>

<pre><code class="language-json">&quot;contributes&quot;: {
  &quot;themes&quot;: [
    {
      &quot;label&quot;: &quot;Moongate Dark&quot;,
      &quot;uiTheme&quot;: &quot;vs-dark&quot;,
      &quot;path&quot;: &quot;./themes/moongate-theme-dark.json&quot;
    },
    {
      &quot;label&quot;: &quot;Moongate Light&quot;,
      &quot;uiTheme&quot;: &quot;vs&quot;,
      &quot;path&quot;: &quot;./themes/moongate-theme-light.json&quot;
    }
  ]
}
</code></pre>

<ul>
<li><strong><code>uiTheme</code> 字段决定主题的基础色系</strong>：

<ul>
<li>深色主题：<code>&quot;vs-dark&quot;</code></li>
<li>浅色主题：<code>&quot;vs&quot;</code></li>
</ul></li>
<li><strong><code>label</code></strong> 将显示在 VS Code 命令面板的「颜色主题」列表中。</li>
</ul>

<blockquote>
<p>📌 <strong>一个容易踩的坑</strong>：如果浅色主题的 <code>uiTheme</code> 误设为 <code>&quot;vs-dark&quot;</code>，VS Code 会按照深色基础色系渲染控件（滚动条、输入框等），导致浅色主题出现深色控件——看起来很别扭。务必根据主题类型设置正确的 <code>uiTheme</code>。</p>
</blockquote>

<hr>

<h2 id="六-跨平台资产-颜色不只是主题">🌐 六、跨平台资产：颜色不只是主题</h2>

<p>DTCG 三层架构还有一个额外的巨大收益：<strong>语义层的颜色字典可以直接导出为跨平台资产</strong>。</p>

<p>构建脚本可以在生成主题 JSON 的同时，自动生成一个 CSS 变量文件：</p>

<pre><code class="language-css">/* themes/moongate-colors.css（自动生成） */
:root,
.light {
  --ui-primary: #0284c7;
  --ui-bg: #f9fafb;
  --ui-surface-raised: #ffffff;
  /* ... 浅色模式所有语义色 */
}

.dark {
  --ui-primary: #3b82f6;
  --ui-bg: #0f172a;
  --ui-surface-raised: #1a2538;
  /* ... 深色模式所有语义色 */
}
</code></pre>

<p>这意味着你的博客、组件库、文档站可以<strong>直接引用主题的视觉语言</strong>：</p>

<pre><code class="language-html">&lt;link rel=&quot;stylesheet&quot; href=&quot;/themes/moongate-colors.css&quot; /&gt;
</code></pre>

<pre><code class="language-css">body {
  background: var(--ui-bg);
  color: var(--ui-text);
}
</code></pre>

<p>切换深浅模式只需要在根元素上添加/移除 <code>.dark</code> class：</p>

<pre><code class="language-javascript">document.documentElement.classList.toggle(&quot;dark&quot;)
</code></pre>

<p>Moongate 正是这样做的——博客、设计系统文档与 VS Code 主题共享同一套颜色，实现「一个颜色体系，贯穿所有产品」。除了 CSS，还可以自动生成 SCSS 和 TypeScript 令牌（见构建体系）。</p>

<hr>

<h2 id="七-常见问题与陷阱">⚠️ 七、常见问题与陷阱</h2>

<table>
<thead>
<tr>
<th>问题</th>
<th>可能原因</th>
<th>解决方法</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>主题未出现在颜色主题列表中</strong></td>
<td><code>package.json</code> 中未正确注册，或 JSON 文件路径错误</td>
<td>检查 <code>contributes.themes</code> 条目，确保 <code>path</code> 指向正确的文件</td>
</tr>

<tr>
<td><strong>浅色主题显示为深色</strong></td>
<td><code>uiTheme</code> 字段误设为 <code>&quot;vs-dark&quot;</code></td>
<td>浅色主题应使用 <code>&quot;vs&quot;</code></td>
</tr>

<tr>
<td><strong>颜色变量未替换，仍显示为 <code>${var}</code></strong></td>
<td>变量名拼写错误，或语义层未定义该变量</td>
<td>检查变量名是否一致，确保所有语义变量在 <code>dark.yaml</code> 和 <code>light.yaml</code> 中都有定义</td>
</tr>

<tr>
<td><strong>深色/浅色主题视觉差异过大</strong></td>
<td>重力补偿不合理，明度调整幅度不均</td>
<td>遵循「色相不变、明度有规律降低、饱和度适度调整」的补偿规则</td>
</tr>

<tr>
<td><strong>新增语义变量后，某个主题报错</strong></td>
<td><code>dark.yaml</code> 和 <code>light.yaml</code> 变量名不一致</td>
<td>所有变体的语义层变量名必须完全一致</td>
</tr>

<tr>
<td><strong>构建脚本报错「找不到文件」</strong></td>
<td>缺少必要的 YAML 文件</td>
<td>确保 <code>src/core/primitives/</code>、<code>src/core/semantics/</code>、<code>src/languages/</code> 等目录存在，且包含所需文件</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="八-总结">🚀 八、总结</h2>

<p>通过引入 DTCG 三层架构，你将「多主题」升级为「设计系统」：</p>

<ul>
<li>✅ <strong>原始值层</strong>：颜色按色相-明度命名，成为可追溯的物理事实。</li>
<li>✅ <strong>语义层</strong>：角色与变体解耦，每个变体只定义「角色该是什么颜色」。</li>
<li>✅ <strong>组件层</strong>：规则文件完全复用，不写任何具体色值。</li>
<li>✅ <strong>重力补偿</strong>：同一语义角色在不同背景下视觉重量对等。</li>
<li>✅ <strong>海拔系统</strong>：UI 拥有物理深度，深浅模式层次一致。</li>
<li>✅ <strong>一键扩展</strong>：新增主题只需添加一个语义层文件。</li>
<li>✅ <strong>跨平台资产</strong>：语义层直接导出 CSS 变量，一套颜色贯穿所有产品。</li>
</ul>

<p>但你可能已经注意到：本篇的构建脚本仍然比较简单——它只能做变量替换，<strong>还不能验证颜色是否符合对比度标准、是否引用了未定义的变量、是否产生了架构污染</strong>。而且随着语言数量增加（当前 Moongate 支持 15 种语言），「语言规则写了对不上」的问题也会浮现。</p>

<p>下一篇将解决这些问题——<strong>如何让构建脚本自身成为一套可测试、可验证的工程体系</strong>。</p>

<p><a href="./create-vscode-theme-build-system"><strong>构建体系：可测试、可验证的工程实践</strong></a></p>
]]></content:encoded>
      <description><![CDATA[用量业界标准的 DTCG 设计令牌标准管理颜色，通过语义层与重力补偿构建深色/浅色双变体，让「同一语义角色在不同背景下视觉重量对等」从理念变为可执行的工程架构。]]></description>
      <category><![CDATA[Design System]]></category>
      <category><![CDATA[Theme]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:design-system]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[主题工程化：从单体 JSON 到模块化 YAML]]></title>
      <link>https://moongate.top/docs/create-vscode-theme-engineering</link>
      <guid isPermaLink="true">7199f437-f5ae-40e9-b08b-fba6968205b5</guid>
      <pubDate>Thu, 06 Aug 2026 02:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共五篇，覆盖从零基础创建到工业级设计系统的 VS Code 主题开发全流程（对应 <strong>Moongate v2.6.0</strong>）：</p>

<ol>
<li><p><a href="./create-vscode-theme-basics"><strong>VS Code 主题：从手写 JSON 到可发布</strong></a>
—— 不依赖脚手架，手写最小主题 JSON，掌握 <code>colors</code> 与 <code>tokenColors</code> 的核心机制与发布流程。</p></li>

<li><p><a href="./create-vscode-theme-engineering"><strong>主题工程化：从单体 JSON 到模块化 YAML</strong></a>
—— 将单体 JSON 重构为模块化 YAML 项目，用构建脚本实现变量替换与自动生成。</p></li>

<li><p><a href="./create-vscode-theme-design-system"><strong>设计系统：DTCG 三层架构与昼夜双变体</strong></a>
—— 用 DTCG 设计令牌标准管理颜色，通过语义层与重力补偿构建深色/浅色双变体。</p></li>

<li><p><a href="./create-vscode-theme-build-system"><strong>构建体系：可测试、可验证的工程实践</strong></a>
—— 模块化构建体系、WCAG 对比度校验、scope 自动验证、自动化测试与多格式产物生成。</p></li>

<li><p><a href="./create-vscode-theme-brand-ecosystem"><strong>品牌生态：设计哲学与视觉契约</strong></a>
—— 为你的主题赋予设计哲学、视觉契约和品牌生态，打造完整的设计系统。</p></li>
</ol>

<hr>

<p>在<a href="./create-vscode-theme-basics">VS Code 主题</a>中，你已经学会了如何手写一个可发布的 VS Code 主题。但随着主题功能越来越丰富，你可能遇到了以下痛点：</p>

<ul>
<li>一个 JSON 文件动辄上千行，修改一个颜色需要全局搜索，容易误改。</li>
<li>想为不同语言定制高亮，却要在同一个 <code>tokenColors</code> 数组里堆砌规则，难以维护。</li>
<li>想尝试浅色版本，不得不复制整个文件，然后手动修改几百个颜色值。</li>
</ul>

<p>是时候引入工程化了！本篇将带你<strong>将一个单体的 JSON 主题重构为模块化、可自动构建的工程化项目</strong>：用 YAML 拆分源文件，用构建脚本自动合并与变量替换。</p>

<blockquote>
<p>💡 <strong>本篇的定位</strong>：本文采用的「单一颜色变量文件 + 构建脚本」方案是工程化的第一步。在<a href="./create-vscode-theme-design-system">设计系统</a>中，这个方案会进一步升级为 DTCG 三层架构（原始值 → 语义层 → 组件层）。建议按顺序阅读，理解每一步的动机。</p>
</blockquote>

<hr>

<h2 id="准备工作">📦 准备工作</h2>

<p>首先确保你已经安装了 Node.js 和 npm（或 pnpm）。然后安装构建依赖：</p>

<pre><code class="language-bash">pnpm add -D js-yaml
# 或
npm install --save-dev js-yaml
</code></pre>

<blockquote>
<p>⚠️ <strong>注意</strong>：<code>js-yaml</code> 必须安装在 <code>devDependencies</code> 中，因为它只是构建工具，不应作为生产依赖随主题发布。</p>
</blockquote>

<hr>

<h2 id="设计目录结构">📁 设计目录结构</h2>

<p>我们将源码放在 <code>src/</code> 目录下，构建脚本放在 <code>scripts/</code>，最终生成的 JSON 放在 <code>themes/</code>：</p>

<pre><code class="language-text">your-theme/
├── src/
│   ├── core/
│   │   └── colors.yaml          # 颜色变量（主色、背景、文本等）
│   ├── languages/                # 各语言的语法规则
│   │   ├── base.yaml             # 跨语言通用规则
│   │   ├── python.yaml
│   │   ├── go.yaml
│   │   └── ... (其他语言)
│   ├── workbench.yaml             # UI 颜色（colors 对象）
│   └── semantic.yaml              # 语义高亮（semanticTokenColors）
├── scripts/
│   └── build.js                   # 构建脚本
├── themes/
│   └── your-theme.json            # 构建生成的最终文件
├── package.json
└── .vscodeignore
</code></pre>

<blockquote>
<p>📌 <strong>注</strong>：上图中的 <code>languages/</code> 目录中并没有 <code>javascript.yaml</code>、<code>typescript.yaml</code>——因为 JS/TS 的语法规则完全被 <code>base.yaml</code> 的通用规则覆盖，无需单独文件。这也是「通用规则 + 语言独有规则」架构的核心思路：<strong>只在语言文件里放真正独有的规则</strong>。</p>
</blockquote>

<hr>

<h2 id="第一步-提取颜色变量">🎨 第一步：提取颜色变量</h2>

<p>打开你原有的主题 JSON，找出所有 <code>colors</code> 对象中的颜色值以及 <code>tokenColors</code> 中反复出现的颜色，将它们定义为变量。创建 <code>src/core/colors.yaml</code>：</p>

<pre><code class="language-yaml"># 核心颜色变量
primary: &quot;#3b82f6&quot; # 主蓝
success: &quot;#34d399&quot; # 成功绿
warning: &quot;#fbbf24&quot; # 警告黄
error: &quot;#f87171&quot; # 错误红
highlight: &quot;#7dd3fc&quot; # 发光蓝

bg: &quot;#0f172a&quot; # 背景
bgMuted: &quot;#1e293b&quot; # 次级背景
text: &quot;#e2e8f0&quot; # 前景色
textMuted: &quot;#94a3b8&quot; # 辅助文字
border: &quot;#2d3748&quot; # 边框

# ... 其他变量
</code></pre>

<p><strong>⚠️ 重要规则</strong>：</p>

<ul>
<li>变量名使用驼峰或小写连字符，并且<strong>只使用字母、数字和下划线</strong>。</li>
<li><strong>变量中不要包含透明度</strong>（如 <code>#3b82f620</code> 中的 <code>20</code>），透明度应通过后缀 <code>${primary}20</code> 在引用时添加，构建脚本会自动拼接。</li>
<li>确保 <code>colors.yaml</code> 覆盖了所有将在其他文件中引用的变量，否则构建时会警告并保留原样。</li>
</ul>

<hr>

<h2 id="第二步-拆分语法规则">✂️ 第二步：拆分语法规则</h2>

<p>将 <code>tokenColors</code> 数组按语言拆分为多个 YAML 文件。以 <code>src/languages/base.yaml</code> 为例，存放所有语言共用的规则：</p>

<pre><code class="language-yaml"># 通用规则（base.yaml）
tokenColors:
  - name: Comment
    scope: [&quot;comment&quot;, &quot;punctuation.definition.comment&quot;]
    settings:
      fontStyle: &quot;italic&quot;
      foreground: &quot;${comment}&quot;

  - name: Keyword
    scope: [&quot;keyword&quot;, &quot;storage.type&quot;, &quot;storage.modifier&quot;]
    settings:
      foreground: &quot;${primary}&quot;
      fontStyle: &quot;bold&quot;

  - name: String
    scope: [&quot;string&quot;, &quot;string.quoted.single&quot;, &quot;string.quoted.double&quot;]
    settings:
      foreground: &quot;${success}&quot;
  # ... 其他通用规则
</code></pre>

<p><strong>核心原则</strong>：<code>base.yaml</code> 已有的通用规则（关键字、字符串、注释、操作符、变量等），语言文件<strong>不再重复定义</strong>。语言文件只放真正属于该语言的独有规则。</p>

<p>以 <code>src/languages/go.yaml</code> 为例，只包含 Go 的特有语法元素：</p>

<pre><code class="language-yaml"># Go 专用规则（go.yaml）
tokenColors:
  - name: Go Package Clause
    scope: [&quot;source.go keyword.package.go&quot;]
    settings:
      foreground: &quot;${primary}&quot;
      fontStyle: &quot;bold&quot;

  - name: Go Struct
    scope: [&quot;keyword.struct.go&quot;]
    settings:
      foreground: &quot;${warning}&quot;
      fontStyle: &quot;bold&quot;

  - name: Go Error Variable
    scope: [&quot;variable.other.object.err.go&quot;]
    settings:
      foreground: &quot;${error}&quot;
      fontStyle: &quot;italic&quot;
  # ... 其他 Go 独有规则
</code></pre>

<p>这种「通用 + 独有」的架构让每个文件的职责都非常清晰：</p>

<ul>
<li>修改通用配色 → 只改 <code>base.yaml</code> 一处，全局生效。</li>
<li>为 Go 添加独有规则 → 只动 <code>go.yaml</code>，不影响其他语言。</li>
<li>想了解某语言的完整配色 → 看 <code>base.yaml</code> + 对应语言文件即可。</li>
</ul>

<hr>

<h2 id="第三步-拆分-ui-颜色和语义规则">🧩 第三步：拆分 UI 颜色和语义规则</h2>

<p>将 <code>colors</code> 对象移到 <code>src/workbench.yaml</code>，并将所有颜色值替换为变量引用：</p>

<pre><code class="language-yaml"># 编辑器 UI 颜色（workbench.yaml）
editor.background: &quot;${bg}&quot;
editor.foreground: &quot;${text}&quot;
titleBar.activeBackground: &quot;${bg}&quot;
titleBar.activeForeground: &quot;${text}&quot;
statusBar.background: &quot;${bg}&quot;
statusBar.foreground: &quot;${textMuted}&quot;
# ... 所有 UI 键
</code></pre>

<p>将 <code>semanticTokenColors</code> 移到 <code>src/semantic.yaml</code>，同样使用变量：</p>

<pre><code class="language-yaml"># 语义高亮规则（semantic.yaml）
variable: &quot;${variable}&quot;
function: &quot;${function}&quot;
class: &quot;${warning}&quot;
&quot;*.decorator&quot;:
  foreground: &quot;${purple}&quot;
  fontStyle: &quot;italic&quot;
# ... 其他语义规则
</code></pre>

<p><strong>🔍 注意</strong>：在 YAML 中，键名如果包含特殊字符（如 <code>*.decorator</code>）必须用双引号括起来，否则 YAML 解析会失败。</p>

<hr>

<h2 id="第四步-编写构建脚本">🔨 第四步：编写构建脚本</h2>

<p>创建 <code>scripts/build.js</code>，使用 <strong>ESM（ES Module）</strong> 语法。它的任务是：</p>

<ol>
<li>加载 <code>colors.yaml</code> 获得变量字典。</li>
<li>加载 <code>workbench.yaml</code>、<code>semantic.yaml</code> 以及 <code>languages/</code> 下的所有语言规则。</li>
<li>递归替换所有 <code>${变量名}</code> 为实际颜色值（支持透明度后缀）。</li>
<li>合并 <code>tokenColors</code>（<code>base.yaml</code> 先合并、语言规则后合并，后面的规则拥有更高优先级）。</li>
<li>输出最终的 JSON 到 <code>themes/</code>。</li>
</ol>

<pre><code class="language-javascript">// scripts/build.js
import fs from &quot;node:fs&quot;
import path from &quot;node:path&quot;
import yaml from &quot;js-yaml&quot;
import { fileURLToPath } from &quot;node:url&quot;

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const ROOT_DIR = path.resolve(__dirname, &quot;..&quot;)

// 路径配置（根据你的项目结构调整）
const PATHS = {
  colors: path.join(ROOT_DIR, &quot;src&quot;, &quot;core&quot;, &quot;colors.yaml&quot;),
  workbench: path.join(ROOT_DIR, &quot;src&quot;, &quot;workbench.yaml&quot;),
  semantic: path.join(ROOT_DIR, &quot;src&quot;, &quot;semantic.yaml&quot;),
  langDir: path.join(ROOT_DIR, &quot;src&quot;, &quot;languages&quot;),
  outputDir: path.join(ROOT_DIR, &quot;themes&quot;),
}

// 加载颜色变量
const colors = yaml.load(fs.readFileSync(PATHS.colors, &quot;utf8&quot;))

// 递归替换 `${var}`，支持两位十六进制透明度后缀（如 ${primary}20）
function replaceVariables(obj) {
  if (typeof obj === &quot;string&quot;) {
    return obj.replace(/\$\{([a-zA-Z0-9_-]+)\}([0-9a-fA-F]{2})?/g, (match, key, alpha) =&gt; {
      const value = colors[key]
      if (value === undefined) {
        console.warn(`⚠️ 警告: 变量 &quot;${key}&quot; 未定义，保留原样`)
        return match
      }
      return value + (alpha || &quot;&quot;)
    })
  }
  if (Array.isArray(obj)) {
    return obj.map(replaceVariables)
  }
  if (obj &amp;&amp; typeof obj === &quot;object&quot;) {
    const result = {}
    for (const [k, v] of Object.entries(obj)) {
      result[k] = replaceVariables(v)
    }
    return result
  }
  return obj
}

// 读取并解析 YAML
function loadYaml(filePath, description) {
  try {
    return yaml.load(fs.readFileSync(filePath, &quot;utf8&quot;))
  } catch (err) {
    console.error(`❌ 解析 ${description} 失败:`, err.message)
    return null
  }
}

// 加载 UI 颜色与语义规则
const workbench = replaceVariables(loadYaml(PATHS.workbench, &quot;workbench.yaml&quot;))
const semantic = replaceVariables(loadYaml(PATHS.semantic, &quot;semantic.yaml&quot;))

// 自动扫描 languages/ 目录下所有 YAML 文件，按文件名排序合并
// base.yaml 会被优先加载（作为通用规则），
// 语言专属规则随后合并（覆盖通用规则中相同 scope 的规则）
let tokenColors = []

if (fs.existsSync(PATHS.langDir)) {
  const langFiles = fs
    .readdirSync(PATHS.langDir)
    .filter((f) =&gt; f.endsWith(&quot;.yaml&quot;))
    .sort() // base.yaml 按字母序排在最前

  for (const file of langFiles) {
    const rules = loadYaml(path.join(PATHS.langDir, file), `语言规则 ${file}`)
    if (rules?.tokenColors) {
      tokenColors = tokenColors.concat(rules.tokenColors)
      console.log(`   ✅ 已加载: ${file}`)
    }
  }
}

// 替换 tokenColors 中的变量
const processedTokenColors = replaceVariables(tokenColors)

// 确保输出目录存在
if (!fs.existsSync(PATHS.outputDir)) {
  fs.mkdirSync(PATHS.outputDir, { recursive: true })
}

// 构建最终主题对象
const theme = {
  name: &quot;Your Theme Name&quot;,
  type: &quot;dark&quot;,
  colors: workbench,
  tokenColors: processedTokenColors,
  semanticTokenColors: semantic,
}

// 写入文件
const outputFile = path.join(PATHS.outputDir, &quot;your-theme.json&quot;)
fs.writeFileSync(outputFile, JSON.stringify(theme, null, 2))
console.log(&quot;✅ 主题构建完成！&quot;)
</code></pre>

<p><strong>⚠️ 注意事项</strong>：</p>

<ul>
<li><strong>自动扫描而非手动排列</strong>：与过去手动维护 <code>order</code> 数组不同，这里直接扫描 <code>languages/</code> 目录并按文件名排序。<code>base.yaml</code> 按字母序天然排在最前，其余语言文件按字母序排在其后——后合并的规则覆盖前面相同 scope 的规则，语言专属规则天然拥有更高优先级。新增语言时只需添加 YAML 文件，无需修改构建脚本。</li>
<li>如果颜色变量未定义，脚本会给出警告，生成的 JSON 中会保留 <code>${var}</code> 占位符，导致主题无效。务必确保所有变量均已定义。</li>
<li><strong>透明度后缀格式</strong>：透明度后缀使用两位十六进制数（00–FF），其中 <code>20</code> 对应约 12.5% 透明度，<code>80</code> 对应 50%，<code>FF</code> 对应完全不透明。这种表示法直接对应 CSS 的 <code>#RRGGBBAA</code> 格式，便于构建脚本直接拼接。</li>
<li>变量名仅使用字母、数字和下划线（如 <code>primary</code>、<code>primaryColor</code>），避免使用连字符或其他符号——因为正则表达式 <code>\$\{([a-zA-Z0-9_-]+)\}</code> 只匹配这些字符。</li>
</ul>

<hr>

<h2 id="第五步-集成到-package-json">⚙️ 第五步：集成到 package.json</h2>

<p>在 <code>package.json</code> 的 <code>scripts</code> 中添加构建命令，并设置 <code>prepublishOnly</code> 自动构建：</p>

<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;build&quot;: &quot;node scripts/build.js&quot;,
    &quot;prepublishOnly&quot;: &quot;npm run build&quot;
  }
}
</code></pre>

<p>🔍 检查：确保 <code>js-yaml</code> 在 <code>devDependencies</code> 中，而不是 <code>dependencies</code>。因为它是构建工具，不应随主题发布。</p>

<hr>

<h2 id="开发体验优化-实时预览与自动构建">✨ 开发体验优化：实时预览与自动构建</h2>

<p>手动运行 <code>npm run build</code> 每次修改后都很繁琐。我们可以添加一个 <strong>watch 模式</strong>，让构建脚本在源码文件变化时自动执行，实现「修改即预览」的高效工作流。</p>

<h3 id="1-安装-nodemon">1. 安装 nodemon</h3>

<p><code>nodemon</code> 是一个常用的工具，可以监视文件变化并自动重启命令。将它安装为开发依赖：</p>

<pre><code class="language-bash">pnpm add -D nodemon
# 或
npm install --save-dev nodemon
</code></pre>

<h3 id="2-添加-watch-脚本">2. 添加 watch 脚本</h3>

<p>在 <code>package.json</code> 的 <code>scripts</code> 中添加以下两个命令：</p>

<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;build&quot;: &quot;node scripts/build.js&quot;,
    &quot;watch&quot;: &quot;nodemon --watch src -e yaml --exec \&quot;npm run build\&quot;&quot;,
    &quot;dev&quot;: &quot;npm run watch&quot;,
    &quot;prepublishOnly&quot;: &quot;npm run build&quot;
  }
}
</code></pre>

<ul>
<li><code>--watch src</code>：监视 <code>src</code> 目录下的所有文件变化。</li>
<li><code>-e yaml</code>：只监视扩展名为 <code>.yaml</code> 的文件。</li>
<li><code>--exec &quot;npm run build&quot;</code>：文件变化时执行构建命令。</li>
</ul>

<p><code>dev</code> 脚本是 <code>watch</code> 的别名，方便记忆。</p>

<h3 id="3-使用-watch-模式">3. 使用 watch 模式</h3>

<p>在开发过程中，打开终端运行：</p>

<pre><code class="language-bash">npm run watch
# 或
npm run dev
</code></pre>

<p>终端会保持运行状态，每当你在 <code>src/</code> 下修改并保存任何 YAML 文件时，构建脚本会自动执行，重新生成 <code>themes/</code> 下的 JSON 文件。</p>

<p>配合 VS Code 的调试功能，你只需按 <code>F5</code> 启动扩展开发宿主，然后保持 watch 运行。修改源码后，在开发宿主中执行 <code>Developer: Reload Window</code> 即可立即看到效果，无需手动重新构建。</p>

<h3 id="4-注意事项">4. 注意事项</h3>

<ul>
<li><code>nodemon</code> 只是开发时的辅助工具，不需要随主题发布，因此务必安装在 <code>devDependencies</code> 中。</li>
<li>如果你的项目结构复杂，可以自定义 <code>--watch</code> 参数监视更多目录。</li>
<li>如果不想安装额外依赖，也可以使用 Node.js 自带的 <code>fs.watch</code> 编写简单的监视脚本，但 <code>nodemon</code> 更成熟易用。</li>
</ul>

<hr>

<h2 id="第六步-更新-vscodeignore">📦 第六步：更新 .vscodeignore</h2>

<p>确保发布时只包含最终产物，不包含源码和依赖。一个典型的 <code>.vscodeignore</code> 内容如下：</p>

<pre><code class="language-bash">.vscode/**
.gitignore
vsc-extension-quickstart.md
node_modules
pnpm-lock.yaml
src/**
scripts/**
!themes/*.json
</code></pre>

<p>💡 说明：<code>!themes/*.json</code> 表示保留 <code>themes</code> 目录下的所有 JSON 文件，这些是构建产物。</p>

<hr>

<h2 id="第七步-测试构建">✅ 第七步：测试构建</h2>

<p>运行以下命令，检查生成的 JSON 是否与原文件一致：</p>

<pre><code class="language-bash">npm run build
</code></pre>

<p>然后用 diff 工具对比新生成的 <code>themes/your-theme.json</code> 与原始 JSON，确保没有意外差异。如果有差异，请检查：</p>

<ul>
<li>变量定义是否完整。</li>
<li>透明度后缀是否正确。</li>
<li>语言规则合并顺序是否符合预期。</li>
</ul>

<hr>

<h2 id="第八步-享受工程化带来的便利">🚀 第八步：享受工程化带来的便利</h2>

<p>现在你的主题源码已经模块化，维护变得轻而易举：</p>

<ul>
<li>想修改主色？只需改 <code>colors.yaml</code> 一处。</li>
<li>想为 Python 添加新规则？直接在 <code>python.yaml</code> 中增加条目。</li>
<li>想创建浅色版本？新建 <code>colors-light.yaml</code>，并调整构建脚本输出两个主题。</li>
</ul>

<hr>

<h2 id="常见问题排查">🔍 常见问题排查</h2>

<table>
<thead>
<tr>
<th>问题</th>
<th>可能原因</th>
<th>解决方法</th>
</tr>
</thead>

<tbody>
<tr>
<td>构建后颜色值仍为 <code>${var}</code></td>
<td>变量未在 <code>colors.yaml</code> 中定义</td>
<td>检查变量名拼写，确保变量已定义</td>
</tr>

<tr>
<td>透明度不正确</td>
<td>变量本身已包含 alpha，或透明度后缀格式错误</td>
<td>变量中不应包含透明度，使用 <code>${var}20</code> 形式</td>
</tr>

<tr>
<td>某语言高亮缺失</td>
<td>语言规则文件不存在，或规则不在 <code>base.yaml</code> 通用范围内</td>
<td>添加对应语言文件；注意语言文件只放独有规则，通用规则放 <code>base.yaml</code></td>
</tr>

<tr>
<td>规则被意外覆盖</td>
<td>合并顺序不符合预期</td>
<td>文件名排序控制顺序：<code>base.yaml</code> 排最前，语言文件按字母序在后，后合并的规则覆盖前面的相同 scope 规则</td>
</tr>

<tr>
<td><code>vsce package</code> 报错「missing dependencies」</td>
<td><code>js-yaml</code> 被放在了 <code>dependencies</code> 中</td>
<td>将其移到 <code>devDependencies</code></td>
</tr>

<tr>
<td>透明度后缀格式不支持三位（如 <code>200</code>）</td>
<td>脚本仅支持两位十六进制透明度后缀（如 <code>20</code>）</td>
<td>确保透明度后缀始终为两位十六进制，并在引用时使用 <code>${var}20</code> 形式</td>
</tr>

<tr>
<td>变量名包含连字符或非单词字符导致无法替换</td>
<td>变量命名不规范，正则表达式 <code>\$\{(\w+)\}</code> 只能匹配字母、数字和下划线</td>
<td>变量名仅使用字母、数字和下划线（如 <code>primary</code>、<code>primaryColor</code>），避免使用连字符或其他符号</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="总结">📝 总结</h2>

<p>通过工程化重构，你从一个难以维护的 JSON 单体进化到了一个清晰、可扩展的模块化项目。你已经拥有了：</p>

<ul>
<li>✅ 模块化的 YAML 源文件（颜色、语言规则、UI、语义高亮分离）</li>
<li>✅ 自动合并与变量替换的构建脚本</li>
<li>✅ watch 模式实时预览开发</li>
<li>✅ 发布时自动构建的正确配置</li>
</ul>

<p>但你可能已经注意到，上一篇文章的方案还有一些问题需要解决：</p>

<ul>
<li><code>colors.yaml</code> 是一个<strong>扁平的变量池</strong>，颜色之间的层级关系（哪些是原始色、哪些是语义角色）完全靠命名约定，没有结构性的约束。</li>
<li>深浅两套主题需要<strong>两套颜色变量文件</strong>，而「同一角色在深色和浅色下应该保持色相一致、明度不同」这件事完全靠手动维护。</li>
<li>构建脚本只能做变量替换，还<strong>不能自动校验</strong>颜色是否符合对比度标准、是否引用了不存在的变量。</li>
</ul>

<p>这些问题，正是我们下一篇要解决的——<strong>设计系统：DTCG 三层架构与昼夜双变体</strong>。</p>

<p><a href="./create-vscode-theme-design-system"><strong>设计系统：DTCG 三层架构与昼夜双变体</strong></a></p>
]]></content:encoded>
      <description><![CDATA[将单体 JSON 重构为模块化 YAML 项目，用构建脚本实现变量替换与自动生成。让颜色变量可复用、语言规则可维护，为设计系统升级打下坚实基础。]]></description>
      <category><![CDATA[VSCode]]></category>
      <category><![CDATA[Theme]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:design-system]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[VS Code 主题：从手写 JSON 到可发布]]></title>
      <link>https://moongate.top/docs/create-vscode-theme-basics</link>
      <guid isPermaLink="true">7a7bec85-c90e-4770-92d3-4ef537ba2960</guid>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共五篇，覆盖从零基础创建到工业级设计系统的 VS Code 主题开发全流程（对应 <strong>Moongate v2.6.0</strong>）：</p>

<ol>
<li><p><a href="./create-vscode-theme-basics"><strong>VS Code 主题：从手写 JSON 到可发布</strong></a>
—— 不依赖脚手架，手写最小主题 JSON，掌握 <code>colors</code> 与 <code>tokenColors</code> 的核心机制与发布流程。</p></li>

<li><p><a href="./create-vscode-theme-engineering"><strong>主题工程化：从单体 JSON 到模块化 YAML</strong></a>
—— 将单体 JSON 重构为模块化 YAML 项目，用构建脚本实现变量替换与自动生成。</p></li>

<li><p><a href="./create-vscode-theme-design-system"><strong>设计系统：DTCG 三层架构与昼夜双变体</strong></a>
—— 用 DTCG 设计令牌标准管理颜色，通过语义层与重力补偿构建深色/浅色双变体。</p></li>

<li><p><a href="./create-vscode-theme-build-system"><strong>构建体系：可测试、可验证的工程实践</strong></a>
—— 模块化构建体系、WCAG 对比度校验、scope 自动验证、自动化测试与多格式产物生成。</p></li>

<li><p><a href="./create-vscode-theme-brand-ecosystem"><strong>品牌生态：设计哲学与视觉契约</strong></a>
—— 为你的主题赋予设计哲学、视觉契约和品牌生态，打造完整的设计系统。</p></li>
</ol>

<hr>

<p>如果你有编程基础（熟悉 JavaScript、JSON、命令行），想把一套配色方案变成 VS Code 主题，但完全不知道从何下手——这篇文章正是为你准备的。</p>

<p>Moongate 主题最初就是从个人博客的配色衍生而来。在这篇文章中，我们不使用任何脚手架，而是<strong>从一个最小的 JSON 文件开始</strong>，逐步理解主题的运作机制。等机制清楚了，你自然会发现哪些环节需要工程化——那就是整个系列接下来要做的事。</p>

<hr>

<h2 id="一-vs-code-主题的本质">一、VS Code 主题的本质</h2>

<p>一个主题说到底就是一份 JSON 文件，它告诉 VS Code 两件事：</p>

<ul>
<li><strong>界面长什么样</strong>：标题栏、状态栏、侧边栏、编辑器背景……这些 UI 区域的颜色。</li>
<li><strong>代码怎么着色</strong>：不同的语法元素（关键字、字符串、注释、函数……）分别用什么颜色和样式。</li>
</ul>

<p>理解文件结构本身并不难，真正难的是<strong>知道该配置哪些键名、每个键名是什么意思</strong>。所以这篇文章的核心目标是：带你亲手创建一个最小可用的主题，然后告诉你如何用 VS Code 自带的工具，找到任何你想调整的颜色所对应的正确键名。</p>

<hr>

<h2 id="二-自定义主题的最小路径">二、自定义主题的最小路径</h2>

<h3 id="1-创建项目结构">1. 创建项目结构</h3>

<p>首先准备一个空目录，并创建 <code>themes/</code> 文件夹：</p>

<pre><code class="language-bash">mkdir my-theme
cd my-theme
mkdir themes
</code></pre>

<p>虽然 VS Code 的扩展项目通常还需要 <code>package.json</code>、<code>README.md</code> 等文件，但为了让读者先专注理解结构，这里从主题 JSON 本身开始。</p>

<h3 id="2-编写最小主题-json">2. 编写最小主题 JSON</h3>

<p>在 <code>themes/</code> 下创建一个 JSON 文件，例如 <code>my-theme.json</code>：</p>

<pre><code class="language-json">{
  &quot;name&quot;: &quot;My Theme&quot;,
  &quot;type&quot;: &quot;dark&quot;,
  &quot;colors&quot;: {
    &quot;editor.background&quot;: &quot;#0f172a&quot;,
    &quot;editor.foreground&quot;: &quot;#e2e8f0&quot;
  },
  &quot;tokenColors&quot;: [
    {
      &quot;name&quot;: &quot;Comment&quot;,
      &quot;scope&quot;: [&quot;comment&quot;, &quot;punctuation.definition.comment&quot;],
      &quot;settings&quot;: {
        &quot;fontStyle&quot;: &quot;italic&quot;,
        &quot;foreground&quot;: &quot;#a5b4cb&quot;
      }
    },
    {
      &quot;name&quot;: &quot;Keyword&quot;,
      &quot;scope&quot;: [&quot;keyword&quot;, &quot;storage.type&quot;, &quot;storage.modifier&quot;],
      &quot;settings&quot;: {
        &quot;foreground&quot;: &quot;#3b82f6&quot;,
        &quot;fontStyle&quot;: &quot;bold&quot;
      }
    },
    {
      &quot;name&quot;: &quot;String&quot;,
      &quot;scope&quot;: [&quot;string&quot;, &quot;string.quoted.single&quot;, &quot;string.quoted.double&quot;],
      &quot;settings&quot;: {
        &quot;foreground&quot;: &quot;#34d399&quot;
      }
    }
  ]
}
</code></pre>

<p>这个文件虽然很小，但它已经包含了主题的全部核心结构。</p>

<hr>

<h2 id="三-核心概念-colors-与-tokencolors">三、核心概念：<code>colors</code> 与 <code>tokenColors</code></h2>

<h3 id="colors-编辑器界面"><code>colors</code>：编辑器界面</h3>

<p><code>colors</code> 对象定义编辑器 <strong>UI 的配色</strong>——背景、标题栏、状态栏、侧边栏、选区高亮等。它是一个扁平的对象，键名是 VS Code 预定义的接口，例如：</p>

<table>
<thead>
<tr>
<th>键名</th>
<th>作用</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>editor.background</code></td>
<td>编辑器背景</td>
</tr>

<tr>
<td><code>editor.foreground</code></td>
<td>编辑器默认前景色</td>
</tr>

<tr>
<td><code>titleBar.activeBackground</code></td>
<td>标题栏背景</td>
</tr>

<tr>
<td><code>statusBar.background</code></td>
<td>状态栏背景</td>
</tr>

<tr>
<td><code>sideBar.background</code></td>
<td>侧边栏背景</td>
</tr>
</tbody>
</table>

<blockquote>
<p>⚠️ <strong><code>type</code> 字段</strong>：顶级 <code>type</code> 字段决定主题是深色还是浅色，取值为 <code>&quot;dark&quot;</code> 或 <code>&quot;light&quot;</code>。它会影响 VS Code 默认控件的渲染方式（如滚动条、输入框的自适应）。</p>
</blockquote>

<h3 id="tokencolors-代码语法高亮"><code>tokenColors</code>：代码语法高亮</h3>

<p><code>tokenColors</code> 是一个<strong>规则数组</strong>，每条规则由 <code>scope</code> 和 <code>settings</code> 组成：</p>

<ul>
<li><code>scope</code>：匹配的 TextMate 作用域（scope），可以是字符串或字符串数组。</li>
<li><code>settings</code>：该作用域应用的颜色与样式（<code>foreground</code>、<code>fontStyle</code>、<code>background</code>）。</li>
</ul>

<pre><code class="language-json">{
  &quot;name&quot;: &quot;Comment&quot;,
  &quot;scope&quot;: [&quot;comment&quot;, &quot;punctuation.definition.comment&quot;],
  &quot;settings&quot;: {
    &quot;fontStyle&quot;: &quot;italic&quot;,
    &quot;foreground&quot;: &quot;#a5b4cb&quot;
  }
}
</code></pre>

<p><strong>⚠️ 重要</strong>：<code>tokenColors</code> 数组的<strong>顺序很重要</strong>——后面的规则会覆盖前面相同 <code>scope</code> 的规则。这也是为什么工程化之后我们会用一个独立的 <code>base.yaml</code> 管理通用规则，再在语言文件中叠加专属规则。</p>

<hr>

<h2 id="四-如何找到正确的键名">四、如何找到正确的键名？</h2>

<p>这是所有主题开发者最常遇到的问题：「我想把状态栏文字改成蓝色，该用什么键名？」</p>

<p>VS Code 提供了两个非常强大的内置工具，彻底解决了这个问题。</p>

<h3 id="1-ui-颜色-提取当前主题">1. UI 颜色：提取当前主题</h3>

<p>按下 <code>Ctrl+Shift+P</code>，运行命令 <strong><code>Developer: Generate Color Theme From Current Settings</code></strong>。</p>

<p>这会在输出面板中生成一个 JSON，包含<strong>当前主题所用到的所有 UI 颜色键名</strong>以及它们的值。你只需要：</p>

<ol>
<li>在输出中找到你关心的区域（比如 <code>statusBar.foreground</code>）。</li>
<li>复制这个键名，粘贴到你自己的 <code>colors</code> 对象中。</li>
<li>改成你自己的颜色值。</li>
</ol>

<p>这个命令生成的是「当前生效值」，即使某个键名你从未配置过（VS Code 在使用默认值），它也会出现在输出中——相当于一份完整的键名清单。</p>

<h3 id="2-语法-scope-inspect-工具">2. 语法 scope：Inspect 工具</h3>

<p>这是定位语法高亮问题的<strong>神器</strong>。打开任意代码文件，把光标放在你想着色的元素上，运行 <strong><code>Developer: Inspect Editor Tokens and Scopes</code></strong>。</p>

<p>弹出的窗口会显示：</p>

<ul>
<li><strong>该元素当前的 TextMate scope 链</strong>，从最具体到最通用。</li>
<li><strong>当前颜色来自哪条规则</strong>（<code>foreground</code> 来源），以及是哪个文件定义的。</li>
</ul>

<p>使用技巧：</p>

<ul>
<li><strong>选择最具体的 scope</strong> 来精确命中目标元素，避免误伤同一族元素。</li>
<li>例如注释既有 <code>comment.line</code> 也有 <code>comment.block</code>，如果你只想给行注释着色，就选 <code>comment.line</code>；如果想统一处理所有注释，就选 <code>comment</code>。</li>
<li>当你发现某个元素颜色「不对」时，Inspect 窗口会直接告诉你当前颜色来自哪个规则——这排查思路清晰得多。</li>
</ul>

<hr>

<h2 id="五-本地调试">五、本地调试</h2>

<p>在确认主题文件书写正确后，下一步就是把它加载进 VS Code 实时查看。</p>

<h3 id="1-创建-package-json">1. 创建 package.json</h3>

<p>要运行主题扩展，需要一个最小的 <code>package.json</code> 将它声明为扩展：</p>

<pre><code class="language-json">{
  &quot;name&quot;: &quot;my-theme&quot;,
  &quot;displayName&quot;: &quot;My Theme&quot;,
  &quot;version&quot;: &quot;0.0.1&quot;,
  &quot;publisher&quot;: &quot;your-name&quot;,
  &quot;engines&quot;: {
    &quot;vscode&quot;: &quot;^1.109.0&quot;
  },
  &quot;categories&quot;: [&quot;Themes&quot;],
  &quot;contributes&quot;: {
    &quot;themes&quot;: [
      {
        &quot;label&quot;: &quot;My Theme&quot;,
        &quot;uiTheme&quot;: &quot;vs-dark&quot;,
        &quot;path&quot;: &quot;./themes/my-theme.json&quot;
      }
    ]
  }
}
</code></pre>

<p>关键字段：</p>

<ul>
<li><strong><code>contributes.themes</code></strong>：声明主题列表，每项包含 <code>label</code>（在颜色主题列表中显示的名称）、<code>uiTheme</code>（基础色系：<code>vs-dark</code> 深色 / <code>vs</code> 浅色）、<code>path</code>（主题 JSON 的相对路径）。</li>
<li><strong><code>publisher</code></strong>：你的发布者 ID。可以先填占位符，发布前注册后再回来修改。</li>
<li><strong><code>engines</code></strong>：最低 VS Code 版本要求。</li>
</ul>

<h3 id="2-f5-启动扩展开发主机">2. F5 启动扩展开发主机</h3>

<ol>
<li>在 VS Code 中打开项目文件夹。</li>
<li>按 <code>F5</code>，启动「扩展开发主机」窗口。</li>
<li>在开发主机中按 <code>Ctrl+K Ctrl+T</code>，选择你的主题。</li>
<li>打开测试代码文件，实时查看效果。</li>
</ol>

<p>修改主题 JSON 后，在开发主机中按 <code>Ctrl+R</code> 重新加载，修改立即生效。<strong>注意</strong>：修改 <code>package.json</code> 中的 <code>contributes.themes</code> 后，需要重启开发主机会话才能生效。</p>

<hr>

<h2 id="六-准备发布">六、准备发布</h2>

<h3 id="1-完善-package-json">1. 完善 package.json</h3>

<p>发布之前，需要补全关键字段：</p>

<pre><code class="language-json">{
  &quot;name&quot;: &quot;my-theme&quot;,
  &quot;displayName&quot;: &quot;My Theme&quot;,
  &quot;description&quot;: &quot;简短介绍你的主题&quot;,
  &quot;version&quot;: &quot;1.0.0&quot;,
  &quot;publisher&quot;: &quot;你的发布者ID&quot;,
  &quot;engines&quot;: { &quot;vscode&quot;: &quot;^1.109.0&quot; },
  &quot;categories&quot;: [&quot;Themes&quot;],
  &quot;icon&quot;: &quot;images/icon.png&quot;,
  &quot;repository&quot;: {
    &quot;type&quot;: &quot;git&quot;,
    &quot;url&quot;: &quot;https://github.com/yourname/my-theme&quot;
  }
}
</code></pre>

<h3 id="2-准备截图">2. 准备截图</h3>

<p>在根目录创建 <code>images/</code> 文件夹，放入至少 3-5 张不同语言的代码截图（建议 1280×640），用于 README 和商店展示。</p>

<h3 id="3-编写-readme-md">3. 编写 README.md</h3>

<p>包含主题名称、预览截图、设计理念、安装方法、配色表（可选）等。</p>

<h3 id="4-添加-license">4. 添加 LICENSE</h3>

<p>建议使用 MIT 许可证，创建 <code>LICENSE</code> 文件。</p>

<h3 id="5-创建-vscodeignore">5. 创建 <code>.vscodeignore</code></h3>

<p>排除不需要打包的文件，<strong>但务必保留 <code>images/</code> 文件夹</strong>：</p>

<pre><code class="language-bash">.vscode/**
.gitignore
node_modules
</code></pre>

<hr>

<h2 id="七-发布前检查清单">七、发布前检查清单</h2>

<p>在运行 <code>vsce package</code> 之前，花两分钟核对以下事项，可以避免大部分常见的发布错误：</p>

<ul>
<li><strong><code>package.json</code> 信息</strong>：确保 <code>publisher</code>、<code>name</code>、<code>version</code> 字段正确无误（<code>publisher</code> 必须与你在市场注册的 ID 完全一致）。</li>
<li><strong>图标文件</strong>：确认 <code>icon</code> 路径指向一个 <strong>128×128 像素的 PNG 图片</strong>，且文件确实存在于该位置。</li>
<li><strong>预览图</strong>：检查 <code>README.md</code> 中是否包含了至少一张主题预览图（建议使用 <code>images/</code> 文件夹内的截图），没有预览图的主题很难吸引用户。</li>
<li><strong><code>.vscodeignore</code> 配置</strong>：确认已排除不必要的文件，但<strong>务必保留 <code>images/</code> 文件夹</strong>，否则截图无法随扩展一起发布。</li>
<li><strong>本地打包测试</strong>：运行 <code>vsce package</code> 命令，若能成功生成 <code>.vsix</code> 文件，说明配置基本正确。如果失败，仔细阅读错误提示——最常见的原因是 <code>icon</code> 路径错误或 <code>publisher</code> 未设置。</li>
</ul>

<hr>

<h2 id="八-打包与发布">八、打包与发布</h2>

<h3 id="1-安装发布工具">1. 安装发布工具</h3>

<pre><code class="language-bash">npm install -g @vscode/vsce
</code></pre>

<h3 id="2-打包测试">2. 打包测试</h3>

<pre><code class="language-bash">vsce package
</code></pre>

<p>如果成功，会生成 <code>.vsix</code> 文件。可以拖进 VS Code 手动安装测试。若失败，仔细阅读错误提示，常见原因是 <code>icon</code> 路径错误或 <code>.vscodeignore</code> 误排除了必要文件。</p>

<h3 id="3-获取-personal-access-token">3. 获取 Personal Access Token</h3>

<ul>
<li>登录 <a href="https://dev.azure.com" target="_blank">Azure DevOps</a>（用你注册市场的微软账号）。</li>
<li>右上角头像 → Personal access tokens → New Token。</li>
<li>名称随意，组织选 <strong>All accessible organizations</strong>，有效期建议 1 年。</li>
<li><strong>权限</strong>：只勾选 <strong>Marketplace → Manage</strong>。</li>
<li>创建后<strong>立即复制 Token</strong>（只显示一次）。</li>
</ul>

<h3 id="4-登录并发布">4. 登录并发布</h3>

<pre><code class="language-bash">vsce login 你的发布者ID
# 粘贴刚才的 Token（不会显示，直接回车）

vsce publish
</code></pre>

<p>几秒后主题就会上传。约 5-10 分钟即可在 VS Code 中搜索到。</p>

<p><strong>常见发布错误</strong>：</p>

<ul>
<li><code>Token verification failed</code>：权限未正确设置或 Token 过期，重新生成。</li>
<li><code>Version already exists</code>：版本号重复，更新 <code>package.json</code> 中的 <code>version</code>。</li>
<li>网络问题：尝试更换网络或使用代理。</li>
</ul>

<h3 id="替代方案-手动上传-vsix-文件">🔁 替代方案：手动上传 .vsix 文件</h3>

<p>如果你在命令行方式中遇到困难，完全可以通过浏览器手动上传：</p>

<ol>
<li>首先确保你已经运行 <code>vsce package</code> 成功生成了 <code>.vsix</code> 文件。</li>
<li>访问 <a href="https://marketplace.visualstudio.com/manage" target="_blank">VS Code 市场管理页</a>，用你的微软账号登录。</li>
<li>在页面中点击你的发布者名称，进入发布者管理界面。</li>
<li>点击右上角的 <strong>Publish extension</strong> 按钮。</li>
<li>选择你的 <code>.vsix</code> 文件上传。</li>
<li>几分钟后主题就会出现在市场中。</li>
</ol>

<p><strong>优点</strong>：完全绕过命令行 token 验证，过程可视化。</p>

<hr>

<h2 id="九-发布后">九、发布后</h2>

<ul>
<li>查看市场页面：<code>https://marketplace.visualstudio.com/items?itemName=你的发布者ID.你的主题名</code></li>
<li>登录 <a href="https://marketplace.visualstudio.com/manage" target="_blank">市场管理后台</a> 查看报表（页面浏览、安装量、转化率）。</li>
<li>在 GitHub 仓库添加 README 徽章（如版本、下载量）。</li>
<li>收集反馈，准备后续更新。</li>
</ul>

<hr>

<h2 id="十-设计哲学-从-好看-到-好用">十、设计哲学：从「好看」到「好用」</h2>

<p>很多新手以为主题就是配几个好看的颜色。但真正优秀的主题，能让代码的结构自己「浮现」出来。这就是 <strong>「视觉远近法」</strong> 的理念：</p>

<ul>
<li><strong>操作符、标点应该退后</strong>（亮度低一些），不干扰阅读；</li>
<li><strong>函数名应该发光</strong>（亮度高一些），成为视觉锚点；</li>
<li><strong>只读变量应该用斜体</strong>暗示「不可变」；</li>
<li><strong>废弃代码应该加上删除线</strong>，一眼识别。</li>
</ul>

<p>这套原则在 Moongate 中落地为「语义分层」：前景（核心逻辑）、中景（普通代码）、背景（辅助信息）三个视觉层级。你可以在后续的系列文章中看到它如何演变为完整的设计体系。</p>

<hr>

<h2 id="为什么手写-json-不可持续">为什么手写 JSON 不可持续？</h2>

<p>现在你已经拥有一个可以手动编辑、可以发布的 VS Code 主题了。但当你认真用起来，很快就会遇到这些痛点：</p>

<ul>
<li>一个 JSON 文件动辄上千行，修改一个颜色需要全局搜索，容易误改。</li>
<li>想为不同语言定制高亮，却要在同一个 <code>tokenColors</code> 数组里堆砌规则，难以维护。</li>
<li>想尝试浅色版本，不得不复制整个文件，然后手动修改几百个颜色值。</li>
<li>一个不小心，就会用错 scope，某个语言的高亮「规则写了对不上」。</li>
</ul>

<p>这些问题不是你的失误，而是<strong>手写 JSON 这种工作方式的极限</strong>。这正是我们接下来的系列要解决的问题——从工程化到设计系统，让主题维护变得轻松而优雅。</p>

<p><a href="./create-vscode-theme-engineering"><strong>主题工程化：从单体 JSON 到模块化 YAML</strong></a></p>
]]></content:encoded>
      <description><![CDATA[不依赖脚手架，从零手写最小主题 JSON，理解 colors 与 tokenColors 的核心机制，掌握调试、打包与发布的完整流程，构建属于你的第一个 VS Code 主题。]]></description>
      <category><![CDATA[VSCode]]></category>
      <category><![CDATA[Theme]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P1</dc:subject>
      <dc:relation><![CDATA[series:design-system]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Vue 3 Teleport 组件单元测试指南：5 个 jsdom 陷阱与顺手抓到的 2 个 Bug]]></title>
      <link>https://moongate.top/docs/vue-teleport-unit-testing-jsdom-pitfalls</link>
      <guid isPermaLink="true">59513f20-2b16-4652-89b3-1d9ba7cfac05</guid>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>本文记录了为组件库 <a href="https://github.com/yuelinghuashu/moongate-vue" target="_blank">Moongate Vue</a> 编写单元测试时，在 jsdom 环境中测试 Teleport 组件的完整踩坑与收获。</p>
</blockquote>

<h2 id="背景">背景</h2>

<p>Moongate Vue 是一个包含 25 个组件的 Vue 3 组件库，其中 <code>Modal</code>（模态框）、<code>Drawer</code>（抽屉）、<code>Message</code>（消息）、<code>Toast</code>（通知）等都使用了 <code>&lt;Teleport to=&quot;body&quot;&gt;</code> 将内容渲染到 <code>document.body</code>。</p>

<p>为了让测试环境尽量接近真实浏览器，我们选了 Vitest + jsdom 作为测试基础设施。原以为只是写几个断言的事，结果在 Teleport 组件上反复栽跟头——<strong>24 个测试失败，其中一大半都指向同一个诡异报错</strong>：</p>

<pre><code class="language-text">TypeError: Cannot read properties of null (reading 'insertBefore')
</code></pre>

<p>排查到最后发现，这不是巧合，而是 <strong>Teleport 机制 + jsdom 环境 + Vue 异步更新</strong> 三者在特定时序下的必然结果。下面按&rdquo;坑 → 表现 → 根因 → 解法&rdquo;拆解。</p>

<hr>

<h2 id="第一部分-teleport-测试的-5-个陷阱">第一部分：Teleport 测试的 5 个陷阱</h2>

<h3 id="陷阱-1-teleport-内容在-body-顶层-wrapper-查不到">陷阱 1：Teleport 内容在 body 顶层，wrapper 查不到</h3>

<p><strong>表现</strong>：<code>wrapper.find('.mg-modal-overlay')</code> 返回空，即使组件已经通过 <code>attachTo: document.body</code> 挂载。</p>

<p><strong>根因</strong>：Teleport 的目标元素是 <code>body</code>，其渲染内容作为 <code>body</code> 的<strong>直接子节点</strong>，<strong>不在 <code>wrapper.element</code> 子树内</strong>。即使能通过 <code>wrapper.vm</code> 访问到组件实例，Teleport 渲染的 <code>v-if</code> 内容也在 wrapper 管理的 DOM 树之外。</p>

<pre><code class="language-ts">// ❌ 错误：wrapper 找不到 Teleport 内容
const wrapper = mount(Modal, { props: { modelValue: true } })
expect(wrapper.find(&quot;.mg-modal-overlay&quot;).exists()).toBe(true)

// ✅ 正确：从 body 顶层查询
const wrapper = mount(Modal, {
  props: { modelValue: true },
  attachTo: document.body, // ① 确保挂载到 body
})
expect(document.body.querySelector(&quot;.mg-modal-overlay&quot;)).not.toBeNull()
</code></pre>

<p><strong>解法</strong>：</p>

<ol>
<li><code>attachTo: document.body</code> 确保组件渲染在 body 中</li>
<li>断言 Teleport 内容永远用 <code>document.body.querySelector</code>，而不是 <code>wrapper.find</code></li>
</ol>

<hr>

<h3 id="陷阱-2-依赖自动卸载-触发-insertbefore-on-null">陷阱 2：依赖自动卸载，触发 <code>insertBefore on null</code></h3>

<p><strong>表现</strong>：测试断言全通过，但 Vitest 结束后抛出一个异步错误：</p>

<pre><code class="language-bash">TypeError: Cannot read properties of null (reading 'insertBefore')
  at insert (runtime-dom.cjs.js:31)
  at processCommentNode ...
</code></pre>

<p><strong>根因</strong>：Vue 的响应式更新是<strong>异步批量 patch</strong> 的。当测试结束时 wrapper 被自动卸载（或 <code>document.body.innerHTML = ''</code> 被清空），但 Vue 内部 scheduler 队列里还挂着上一轮渲染的 DOM patch 任务。这个 patch 试图操作已经脱离 DOM 的旧节点 → 崩溃。</p>

<p>Teleport 组件尤其容易触发，因为它的插入点（body）是测试环境里<strong>最容易被清空</strong>的目标。</p>

<p><strong>解法</strong>：测试结束时显式卸载 wrapper，让 Teleport 有完整机会摘除自己：</p>

<pre><code class="language-ts">import { mount } from &quot;@vue/test-utils&quot;

// 跟踪所有 wrapper，统一显式卸载
const wrappers: ReturnType&lt;typeof mount&gt;[] = []
const mountDrawer = (options = {}) =&gt; {
  const wrapper = mount(Drawer, { attachTo: document.body, ...options })
  wrappers.push(wrapper)
  return wrapper
}

afterEach(async () =&gt; {
  while (wrappers.length &gt; 0) {
    const wrapper = wrappers.pop()!
    await wrapper.unmount() // 显式卸载，而非依赖自动清理
  }
})
</code></pre>

<hr>

<h3 id="陷阱-3-测试间-dom-污染">陷阱 3：测试间 DOM 污染</h3>

<p><strong>表现</strong>：第一个测试用例渲染了一个 Modal，第二个用例查询 <code>.mg-modal-overlay</code> 莫名找到了<strong>上一轮残留</strong>的元素；或者反过来，第二个用例找不到预期元素。</p>

<p><strong>根因</strong>：Teleport 把内容加到 <code>document.body</code>，但测试框架的自动清理不一定销毁它们。尤其当组件内部持有<strong>模块级缓存</strong>（比如我们的 <code>createOverlay</code> 共享容器 Map）时，残留引用会让 DOM 越积越多。</p>

<pre><code class="language-ts">// ❌ 无法清理 Teleport 残留
afterEach(() =&gt; {
  document.body.innerHTML = &quot;&quot; // 直接把 body 清空
})
</code></pre>

<p>直接清空 body 是<strong>有问题的</strong>，因为 Vue 内部仍引用着这些节点，下一个 tick 的 patch 会对已移除节点操作而爆炸（这正是陷阱 2 的错误来源）。</p>

<p><strong>解法</strong>：先 flush 再清空：</p>

<pre><code class="language-ts">afterEach(async () =&gt; {
  await flushPromises() // ① 等待 Vue 异步作业（Teleport 移除、nextTick patch）完成
  document.body.innerHTML = &quot;&quot; // ② 再清空
})
</code></pre>

<blockquote>
<p>⚠️ <strong>注意</strong>：如果测试中使用了 <code>vi.useFakeTimers()</code>，<code>flushPromises()</code> 只能清空微任务队列，<strong>清不掉宏任务</strong>（如 <code>setTimeout</code> 触发的 DOM 操作）。此时应还原真实定时器，再清空 body：</p>

<pre><code class="language-ts">afterEach(async () =&gt; {
  await flushPromises()
  // 若用了假定时器，直接还原真实定时器（会自动清空假定时器队列），比跑完所有宏任务更安全
  if (vi.isFakeTimers()) {
    vi.useRealTimers()
  }
  document.body.innerHTML = &quot;&quot;
})
</code></pre>

<p>⚠️ <strong>避免 <code>vi.runAllTimersAsync()</code> 的陷阱</strong>：它会把所有宏任务（包括 <code>setInterval</code>、<code>requestAnimationFrame</code> 这类<strong>永久性定时器</strong>）循环跑完。如果被测组件内部有无限轮询或动画循环，这个调用会导致测试<strong>卡死/超时</strong>。<strong>还原真实定时器是更稳妥的清理方式</strong>。</p>
</blockquote>

<hr>

<h3 id="陷阱-4-清理顺序错误-先清空-body-导致-vue-找不到父节点">陷阱 4：清理顺序错误，先清空 body 导致 Vue 找不到父节点</h3>

<p><strong>表现</strong>：诡异的是，把 <code>document.body.innerHTML = ''</code> 放在 <code>afterEach</code> 的开头反而报错，放在结尾就正常。</p>

<p><strong>根因</strong>：Vue 的调度器不知道测试环境的存在。如果在 Vue 还没完成 last tick 的 DOM patch 时清空 body，等它去 patch 时父节点已经是 null。</p>

<p>正确的清理顺序必须是：</p>

<pre><code class="language-text">卸载所有 wrapper / destroyAllOverlays  →  flushPromises（若用了 fake timers 则 useRealTimers 还原）→  清空 body  →  restoreAllMocks
</code></pre>

<p><strong>这是最容易忽略的一条</strong>。很多人（包括我）第一反应是&rdquo;清空 body 嘛，什么时候不行&rdquo;，结果 Vue 用崩溃告诉你不可以。</p>

<hr>

<h3 id="陷阱-5-v-model-关闭后立刻断言事件-但-dom-patch-还没发生">陷阱 5：<code>v-model</code> 关闭后立刻断言事件，但 DOM patch 还没发生</h3>

<p><strong>表现</strong>：点击关闭按钮后，<code>wrapper.emitted('update:modelValue')</code> 有值，但紧接着查询 <code>document.body.querySelector('.mg-modal')</code> 仍能查到（元素还在）。</p>

<p><strong>根因</strong>：<code>emit('update:modelValue', false)</code> 是同步的，但 Teleport 移除 DOM 是<strong>异步 patch</strong>。事件已派发、DOM 还没更新。</p>

<p><strong>解法</strong>：</p>

<ol>
<li><strong>先操作 UI 再断言事件</strong>：<code>wrapper.emitted(...)</code> 是同步的，点击后立即可用</li>
<li><strong>DOM 断言必须等待异步 patch</strong>：<code>await flushPromises()</code> 或 <code>await wrapper.vm.$nextTick()</code></li>
<li>先断言事件（同步派发），再断言 DOM（异步更新），在时间线上分离二者：</li>
</ol>

<pre><code class="language-ts">// ✅ 推荐写法：先触发 UI，事件同步断言；再等 DOM 异步更新
closeBtn.click()
expect(wrapper.emitted(&quot;update:modelValue&quot;)).toBeTruthy() // 事件是同步派发的
await flushPromises() // 等待 DOM 移除
expect(document.body.querySelector(&quot;.mg-modal&quot;)).toBeNull()
</code></pre>

<hr>

<h3 id="小结-teleport-测试的黄金法则">小结：Teleport 测试的黄金法则</h3>

<table>
<thead>
<tr>
<th>法则</th>
<th>一句话</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>挂载</strong></td>
<td>一律 <code>attachTo: document.body</code></td>
</tr>

<tr>
<td><strong>断言</strong></td>
<td>Teleport 内容用 <code>document.body.querySelector</code></td>
</tr>

<tr>
<td><strong>卸载</strong></td>
<td>显式 <code>await wrapper.unmount()</code>，不依赖自动清理</td>
</tr>

<tr>
<td><strong>清理</strong></td>
<td>卸载 → <code>flushPromises()</code>（用了 fake timers 则 <code>useRealTimers()</code> 还原，勿用 <code>runAllTimersAsync()</code> 以免死循环）→ 清空 body，顺序不可颠倒</td>
</tr>

<tr>
<td><strong>时序</strong></td>
<td>事件同步，DOM 异步，断言前先 <code>flushPromises()</code></td>
</tr>
</tbody>
</table>

<hr>

<h2 id="第二部分-测试驱动顺手抓到的-2-个真实-bug">第二部分：测试驱动顺手抓到的 2 个真实 Bug</h2>

<p>写测试的过程中，失败的断言意外暴露了组件库本身的两个隐藏缺陷。这才是测试真正的价值——<strong>它不只是验证代码，而是替用户提前踩坑</strong>。</p>

<h3 id="bug-1-input-组件的-change-事件完全丢失">Bug 1：Input 组件的 <code>change</code> 事件完全丢失</h3>

<p><strong>现象</strong>：组件声明了 <code>change</code> 事件，但无论如何测试都收不到：</p>

<pre><code class="language-ts">// Input.vue 中声明了
const emit = defineEmits&lt;{
  /** 值变化时触发（原生事件透传） */
  change: [event: Event]
}&gt;()

// 但测试始终收不到
wrapper.trigger(&quot;change&quot;)
expect(wrapper.emitted(&quot;change&quot;)).toHaveLength(1) // ❌ 失败
</code></pre>

<p><strong>根因</strong>：模板里只绑定了 <code>@input</code>、<code>@blur</code>、<code>@focus</code>，<strong>漏掉了 <code>@change</code></strong>。要理解为什么事件会&rdquo;彻底消失&rdquo;，需要先弄清 Vue 3 的事件透传机制：</p>

<blockquote>
<p>Vue 3 中，组件模板上监听的<strong>未在 <code>emits</code> 中声明</strong>的事件，会被当作原生事件透传给根元素（进入 <code>$attrs</code>）；<strong>一旦在 <code>emits</code> 中声明</strong>，Vue 就认为该事件已由组件内部显式处理，不再透传。</p>
</blockquote>

<p>因此，<code>change</code> 被 <code>defineEmits</code> 声明后，Vue 不会再把它透传给根 <code>&lt;input&gt;</code>。此时如果模板中又没有绑定 <code>@change</code> 处理器，这个事件就<strong>凭空消失</strong>了——既不会触发组件事件，也不会透传到原生元素。</p>

<pre><code class="language-vue">&lt;!-- 修复前：漏了 @change --&gt;
&lt;input @input=&quot;handleInput&quot; @blur=&quot;handleBlur&quot; @focus=&quot;handleFocus&quot; /&gt;

&lt;!-- 修复后 --&gt;
&lt;input
  @input=&quot;handleInput&quot;
  @blur=&quot;handleBlur&quot;
  @focus=&quot;handleFocus&quot;
  @change=&quot;handleChange&quot;
/&gt;
</code></pre>

<p><strong>教训</strong>：<code>defineEmits</code> 声明的事件如果没在模板绑定对应处理器，会被&rdquo;吞掉&rdquo;（既不触发、也不透传）。<strong>这条例外适用于所有组件库</strong>——尤其是表格、表单这类依赖原生事件透传的组件。</p>

<hr>

<h3 id="bug-2-createoverlay-共享容器的孤儿引用">Bug 2：<code>createOverlay</code> 共享容器的孤儿引用</h3>

<p><strong>现象</strong>：测试清空 body 后，下一个用例创建 Message/Toast，查询容器时莫名失败。</p>

<p><strong>根因</strong>：我们的 <code>createOverlay</code> 用模块级 <code>Map</code> 缓存共享容器（用于消息堆叠）：</p>

<pre><code class="language-ts">// createOverlay.ts
const sharedContainers = new Map&lt;string, HTMLDivElement&gt;()

function getSharedContainer(containerClass: string) {
  let container = sharedContainers.get(containerClass)
  if (!container) {
    container = document.createElement(&quot;div&quot;)
    document.body.appendChild(container)
    sharedContainers.set(containerClass, container)
  }
  return container // ❌ 如果容器已被外部 remove，这里返回的是&quot;孤儿节点&quot;
}
</code></pre>

<p>当测试执行 <code>document.body.innerHTML = ''</code> 后，Map 里的容器引用<strong>已脱离 DOM</strong>（<code>isConnected === false</code>），但缓存没清空。下一个用例调用 <code>createOverlay</code> 时拿到孤儿节点，内容挂进去后从 <code>document.body</code> 查不到 → 失败。</p>

<p><strong>修复</strong>：销毁逻辑增加 <code>isConnected</code> 检测，并提供同步清理 API：</p>

<pre><code class="language-ts">// ① 容器可能已被外部移除时，同步从 DOM 摘除
if (container.childElementCount === 0 || !container.isConnected) {
  container.remove()
  sharedContainers.delete(containerClass)
}

// ② 新增同步清理 API，供测试/应用卸载使用
export function destroyAllOverlays(): void {
  activeInstances.forEach((instance) =&gt; {
    instance.element.remove()
    instance.app.unmount()
  })
  sharedContainers.clear()
  activeInstances.clear()
}
</code></pre>

<p><strong>教训</strong>：动态挂载（<code>createApp</code> 手动管理生命周期）的工具，<strong>绝不能只依赖&rdquo;元素存在&rdquo;来判断是否复用</strong>——必须检测元素是否仍连接在文档树中（<code>isConnected</code>）。</p>

<hr>

<h2 id="附-可复现的最小示例">附：可复现的最小示例</h2>

<p>如果你想在本地复现这 5 个陷阱，下面是最小化的模板（完整可跑代码见 <a href="https://github.com/yuelinghuashu/moongate-vue/tree/main/src/__tests__" target="_blank">Moongate Vue 仓库 <code>src/__tests__</code></a>）：</p>

<pre><code class="language-vue">&lt;!-- MyTeleport.vue --&gt;
&lt;template&gt;
  &lt;div&gt;
    &lt;Teleport to=&quot;body&quot;&gt;
      &lt;div v-if=&quot;visible&quot; class=&quot;my-overlay&quot;&gt;overlay content&lt;/div&gt;
    &lt;/Teleport&gt;
    &lt;button @click=&quot;visible = !visible&quot;&gt;toggle&lt;/button&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script setup lang=&quot;ts&quot;&gt;
import { ref } from &quot;vue&quot;
const visible = ref(true)
&lt;/script&gt;
</code></pre>

<pre><code class="language-ts">// my-teleport.test.ts —— 复现&quot;不敢在 afterEach 清空 body&quot;之坑
import { describe, it, expect, afterEach } from &quot;vitest&quot;
import { mount, flushPromises } from &quot;@vue/test-utils&quot;
import MyTeleport from &quot;./MyTeleport.vue&quot;

describe(&quot;MyTeleport&quot;, () =&gt; {
  afterEach(async () =&gt; {
    // ❌ 错误：先清空 body → Vue patch 崩溃
    // document.body.innerHTML = ''
    // ✅ 正确：先 flush 再清空
    await flushPromises()
    document.body.innerHTML = &quot;&quot;
  })

  it(&quot;Teleport 内容在 body 而非 wrapper&quot;, () =&gt; {
    const wrapper = mount(MyTeleport, { attachTo: document.body })
    expect(wrapper.find(&quot;.my-overlay&quot;).exists()).toBe(false) // wrapper 查不到
    expect(document.body.querySelector(&quot;.my-overlay&quot;)).not.toBeNull() // body 才能查到
  })
})
</code></pre>

<hr>

<h2 id="结语">结语</h2>

<p>这次为组件库补测试的经历，最大收获不是&rdquo;写出了 204 个通过的断言&rdquo;，而是验证了一个观点：<strong>测试环境（jsdom）不是浏览器，但它会以更严格的方式逼你直面组件实现和生命周期管理的每一处模糊地带。</strong></p>

<p>Teleport 在 jsdom 中的这些坑，本质都是同一个问题：<strong>手动挂载（Teleport / createApp / 动态组件）产生的 DOM，其生命周期超出了组件 vnode 树的管理范围。这种&rdquo;失控&rdquo;在 jsdom 中会被放大——就像 Bug 2 中的孤儿引用，你以为清理干净了，实则残留的缓存和游离的 DOM 节点还在暗中作祟</strong>。理解了这一点，就掌握了应对所有类似场景（Portal、Dialog、Notification、ContextMenu）的思路。</p>
]]></content:encoded>
      <description><![CDATA[为我们的组件库 Moongate Vue 编写 204 个单元测试时，Teleport 组件在 jsdom 环境中踩了 5 个坑，还顺手揪出了 2 个隐藏 bug。本文复盘完整过程，附可复现的最小示例。]]></description>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[Engineering]]></category>
      <category><![CDATA[TypeScript]]></category>
      <dc:subject>P4</dc:subject>
      
    </item>

    <item>
      <title><![CDATA[VS Code CompletionProvider 中的 filterText 陷阱]]></title>
      <link>https://moongate.top/docs/vscode-completion-provider-filtertext-trap</link>
      <guid isPermaLink="true">2b1d7a31-26e2-4a81-8fef-e071fff8265b</guid>
      <pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="1-现象">1. 现象</h2>

<p>最近在开发一个 VS Code 扩展，为自定义的 <code>.meph</code> 文件提供语法支持。其中有一个很自然的需求：用户输入 <code>【</code> 时，自动弹出标准区块名的补全列表。</p>

<p>注册方式按官方文档来：</p>

<pre><code class="language-typescript">vscode.languages.registerCompletionItemProvider(
  { language: &quot;mephisto&quot; },
  new MephistoCompletionProvider(),
  &quot;【&quot;, // trigger character
  &quot;.&quot;,
  &quot;[&quot;,
)
</code></pre>

<p>预期行为是：输入 <code>【</code> → 弹出补全 → 选择&rdquo;角色名&rdquo; → 自动变成 <code>【角色名】</code>。</p>

<p>实际表现：输入 <code>【</code> 后，auto-closing 自动补了 <code>】</code>，行变成 <code>【】</code>，但<strong>补全列表什么都没有</strong>。</p>

<h2 id="2-第一次排查-triggercharacter-的问题">2. 第一次排查：triggerCharacter 的问题</h2>

<p>起初怀疑是 triggerCharacter 没生效。在 <code>provideCompletionItems</code> 开头加了一行测试代码：</p>

<pre><code class="language-typescript">provideCompletionItems() {
    return [new vscode.CompletionItem('测试-如果能看到我', CompletionItemKind.Text)];
    // ... 原有逻辑
}
</code></pre>

<p>重新加载扩展后，按 <code>Ctrl+Space</code> 手动触发补全，&rdquo;测试-如果能看到我&rdquo; <strong>正常显示</strong>。</p>

<p>这说明 completion provider 本身注册成功了，也能正常返回数据。问题出在<strong>触发环节</strong>。</p>

<p>进一步测试发现，在中文输入法下 <code>'【'</code> 作为 trigger character 并不可靠。不是 <code>【</code> 本身不能作为 trigger——在英文输入法下直接输入 <code>【</code> 是可以触发补全的。问题在于中文输入法（如拼音）中，<code>【</code> 通常通过候选窗口选择输入，这个输入路径绕过了 VS Code 的 trigger character 检测机制。</p>

<p>改用 <code>onDidChangeTextDocument</code> 事件配合 <code>triggerSuggest</code> 命令，不依赖 trigger character，而是监听文档内容变化后强制弹出补全：</p>

<pre><code class="language-typescript">vscode.workspace.onDidChangeTextDocument((e) =&gt; {
  if (e.document.languageId === &quot;mephisto&quot;) {
    for (const change of e.contentChanges) {
      if (change.text === &quot;【&quot;) {
        vscode.commands.executeCommand(&quot;editor.action.triggerSuggest&quot;)
        break
      }
    }
  }
})
</code></pre>

<p>注意这里<strong>不需要</strong> <code>setTimeout(0)</code>。根据 VS Code 的事件模型，<code>onDidChangeTextDocument</code> 在文档内容修改完成后才触发，此时 auto-closing 已经同步插入了 <code>】</code>，当前行已经是 <code>【】</code> 了。不过在极慢的扩展宿主环境下，<code>setTimeout(0)</code> 可以作为保险措施，但通常不需要。</p>

<h2 id="3-第二次排查-补全数据去哪了">3. 第二次排查：补全数据去哪了</h2>

<p>用 <code>onDidChangeTextDocument</code> 替换 triggerCharacter 后，provider 确实被调用了，也返回了 9 个标准区块名。但<strong>补全列表依然是空的</strong>。</p>

<p>没有报错，没有警告，只有空荡荡的补全弹窗。</p>

<p>这就奇怪了：provider 返回了数据，VS Code 没有报错，为什么用户看不到？</p>

<h2 id="4-真正的原因-filtertext">4. 真正的原因：filterText</h2>

<p>问题出在 VS Code 的补全过滤机制上。</p>

<p>当用户输入 <code>【</code> 后，auto-closing 立即插入 <code>】</code>，当前行变成了：</p>

<pre><code class="language-text">【】
</code></pre>

<p>光标在索引 1 的位置（<code>【</code> 和 <code>】</code> 之间）。此时 <code>triggerSuggest</code> 弹出补全，VS Code <strong>自动提取光标位置的&rdquo;当前词&rdquo;</strong>作为过滤前缀。在 <code>【】</code> 中间，当前词就是 <code>【</code>。</p>

<p>然后 VS Code 拿 <code>【</code> 去匹配 provider 返回的每个补全项：</p>

<table>
<thead>
<tr>
<th>补全项 label</th>
<th>默认用于匹配的文本（filterText = label）</th>
<th>匹配 <code>【</code>？</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>角色名</code></td>
<td><code>角色名</code></td>
<td>❌</td>
</tr>

<tr>
<td><code>锚点</code></td>
<td><code>锚点</code></td>
<td>❌</td>
</tr>

<tr>
<td><code>世界观</code></td>
<td><code>世界观</code></td>
<td>❌</td>
</tr>

<tr>
<td>&hellip;</td>
<td>&hellip;</td>
<td>❌</td>
</tr>
</tbody>
</table>
<p>全部不匹配，所以全部被过滤掉。</p>

<p>这就是 <code>filterText</code> 的作用：它告诉 VS Code <strong>用什么文本来做匹配</strong>，而不是用 label 作为匹配依据。</p>

<p>修复方式是在 <code>getBlockCompletions</code> 中设置 <code>filterText</code>：</p>

<pre><code class="language-typescript">private getBlockCompletions(): CompletionItem[] {
    const items: CompletionItem[] = [];
    for (const name of STANDARD_BLOCKS) {
        const item = new CompletionItem(name, CompletionItemKind.Module);
        item.insertText = name + '】\n';
        item.detail = '标准区块';
        item.filterText = '【' + name;  // ← 关键
        items.push(item);
    }
    return items;
}
</code></pre>

<ul>
<li><code>label</code>（显示文本）仍然是&rdquo;角色名&rdquo;</li>
<li><code>insertText</code>（插入内容）仍然是 <code>角色名】\n</code></li>
<li>但 <code>filterText</code> 设为 <code>'【角色名'</code>，VS Code 拿到当前词 <code>【</code> 去匹配 <code>'【角色名'</code> → <strong>匹配成功</strong> → 显示在列表中</li>
</ul>

<h2 id="5-completionitem-的文本相关属性">5. CompletionItem 的文本相关属性</h2>

<p><code>CompletionItem</code> 有三个与文本相关的属性：</p>

<table>
<thead>
<tr>
<th>属性</th>
<th>作用</th>
<th>在这个场景中的值</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>label</code></td>
<td>用户看到的补全项文本</td>
<td><code>角色名</code></td>
</tr>

<tr>
<td><code>filterText</code></td>
<td>VS Code 用于做模糊匹配的文本</td>
<td><code>【角色名</code></td>
</tr>

<tr>
<td><code>insertText</code></td>
<td>用户选择后实际插入的文本</td>
<td><code>角色名】\n</code></td>
</tr>
</tbody>
</table>
<p>默认情况下，<code>filterText</code> 等于 <code>label</code>。当补全项 label 与触发字符（当前词）不匹配时，需要显式设置 <code>filterText</code> 来提供正确的匹配文本。</p>

<p>值得注意的是，VS Code 的过滤是 <strong>fuzzy matching（模糊匹配）</strong>，不是严格的前缀匹配。<code>filterText</code> 的值会参与评分算法，当前词不需要严格作为 <code>filterText</code> 的前缀也能匹配。但在这个场景中，设 <code>filterText</code> 为 <code>'【角色名'</code> 已经足够让 <code>【</code> 匹配上了。</p>

<p>此外，<code>sortText</code> 控制补全列表的排序顺序：</p>

<pre><code class="language-typescript">item.sortText = &quot;0&quot; + name // 标准区块排在前面
item.sortText = &quot;1&quot; + name // 其他项排在后面
</code></pre>

<p><code>CompletionItemKind</code> 则影响显示的图标：</p>

<pre><code class="language-typescript">item.kind = CompletionItemKind.Module // 方块图标
item.kind = CompletionItemKind.Class // 菱形图标
item.kind = CompletionItemKind.Struct // 结构体图标
</code></pre>

<p>挑一个视觉上顺眼的即可。</p>

<h2 id="6-最终代码">6. 最终代码</h2>

<p>将上述改动整合在一起，最终实现如下：</p>

<pre><code class="language-typescript">// extension.ts — 监听中文输入法下的 【 输入
vscode.workspace.onDidChangeTextDocument(e =&gt; {
    if (e.document.languageId !== 'mephisto') return;
    for (const change of e.contentChanges) {
        if (change.text === '【') {
            vscode.commands.executeCommand('editor.action.triggerSuggest');
            break;
        }
    }
});

// completion.ts — CompletionProvider 中设置 filterText 和 range
private getBlockCompletions(): CompletionItem[] {
    return STANDARD_BLOCKS.map(name =&gt; {
        const item = new CompletionItem(name, CompletionItemKind.Module);
        item.insertText = name + '】\n';
        item.filterText = '【' + name;
        item.detail = '标准区块';
        return item;
    });
}
</code></pre>

<h2 id="7-总结">7. 总结</h2>

<h3 id="核心要点回顾">核心要点回顾</h3>

<table>
<thead>
<tr>
<th>问题</th>
<th>原因</th>
<th>解决方案</th>
</tr>
</thead>

<tbody>
<tr>
<td>输入 <code>【</code> 不触发补全</td>
<td>中文输入法下 triggerCharacter 不可靠</td>
<td>改用 <code>onDidChangeTextDocument</code> + <code>triggerSuggest</code></td>
</tr>

<tr>
<td>补全弹出了但列表为空</td>
<td><code>filterText</code> 默认与 label 相同，无法匹配当前词 <code>【</code></td>
<td>设置 <code>filterText</code> 为 <code>'【' + name</code></td>
</tr>

<tr>
<td>VS Code 提取的当前词不对</td>
<td><code>wordPattern</code> 不包含 <code>【</code></td>
<td>设置 <code>range</code> 手动指定当前词范围</td>
</tr>
</tbody>
</table>

<h3 id="一句话总结">一句话总结</h3>

<p><code>filterText</code> 解决的是&rdquo;<strong>拿什么去匹配</strong>&ldquo;的问题，<code>range</code> 解决的是&rdquo;<strong>当前要匹配的词是哪个</strong>&ldquo;的问题。两者结合，能应对大多数补全过滤异常的场景。</p>

<h3 id="最终的完整解决方案">最终的完整解决方案</h3>

<pre><code class="language-typescript">// 1. 监听输入触发补全（不依赖 triggerCharacter）
vscode.workspace.onDidChangeTextDocument(e =&gt; {
    if (e.document.languageId !== 'mephisto') return;
    for (const change of e.contentChanges) {
        if (change.text === '【') {
            vscode.commands.executeCommand('editor.action.triggerSuggest');
            break;
        }
    }
});

// 2. 在 CompletionProvider 中设置 filterText 和 range
provideCompletionItems(document: vscode.TextDocument, position: vscode.Position) {
    const line = document.lineAt(position.line).text;
    const openIdx = line.indexOf('【');
    const closeIdx = line.indexOf('】');

    if (openIdx !== -1 &amp;&amp; closeIdx !== -1 &amp;&amp; position.character &gt; openIdx &amp;&amp; position.character &lt;= closeIdx) {
        const items = STANDARD_BLOCKS.map(name =&gt; {
            const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Module);
            item.insertText = name + '】\n';
            item.filterText = '【' + name;
            item.range = new vscode.Range(
                new vscode.Position(position.line, openIdx),
                new vscode.Position(position.line, openIdx + 1)
            );
            item.detail = '标准区块';
            return item;
        });
        return items;
    }
    // 其他补全逻辑...
}
</code></pre>

<h3 id="排查清单">排查清单</h3>

<p>当你在 VS Code 扩展中发现补全不显示时：</p>

<ol>
<li>补全列表完全没出现 → 检查 triggerCharacter / triggerSuggest 是否生效</li>
<li>补全列表弹出了但什么都没有 → 检查 provider 是否返回了数据（加一个测试项验证）</li>
<li>确认返回了数据但用户看不到 → <strong>检查 filterText 是否匹配当前词</strong></li>
<li>以上都无效 → 检查 wordPattern 或设置 range</li>
</ol>

<p>其中第 3 步是最容易忽略的，因为 VS Code 不会告诉你它过滤掉了什么。</p>
]]></content:encoded>
      <description><![CDATA[VS Code 扩展开发中 CompletionProvider 返回数据但补全列表为空的完整解析，涵盖 filterText 过滤机制、中文输入法下 triggerCharacter 失效问题，以及系统的排查与解决方案。]]></description>
      <category><![CDATA[VSCode]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P2</dc:subject>
      
    </item>

    <item>
      <title><![CDATA[构建大模型叙事引擎：运行时闭环与多分支存档]]></title>
      <link>https://moongate.top/docs/narrative-engine-runtime-loop-and-branching</link>
      <guid isPermaLink="true">e5f9b2c3-4d6e-5f7a-8b9c-2c3d4e5f6a7b</guid>
      <pubDate>Mon, 20 Jul 2026 23:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p><strong>前置阅读</strong>：这是本系列的最后一篇，建议先读前五篇——第一篇了解 <code>.meph</code> 格式，第二篇上手实操，第三、四篇理解解析器如何输出 <code>domain.Contract</code>，第五篇理解测试如何保障行为稳定。本篇假设你已经知道引擎拿到了 <code>contract</code> 结构体，要解决的核心问题是：<strong>怎么驱动它运转起来？</strong></p>
</blockquote>

<h2 id="一-叙事引擎的第五个问题-怎么让规则活起来">一、叙事引擎的第五个问题：怎么让规则活起来？</h2>

<p>解析器把契约变成了结构体，但结构体不会叙事。一个叙事引擎还需要<strong>运行时</strong>——输入 → 匹配 → 执行 → 输出的完整闭环。</p>

<p>这里要解决的工程问题有三个：</p>

<ol>
<li><strong>规则怎么实时匹配？</strong> 用户输入到达时，引擎需要遍历所有规则、评估条件、决定触发哪些动作——这个过程必须在毫秒级完成，不能影响用户体验。</li>
<li><strong>LLM 怎么遵守规则？</strong> 规则不能直接约束 LLM 的行为，它们必须通过 Prompt 间接生效。怎么把规则&rdquo;翻译&rdquo;成 LLM 能理解的格式，是这个环节的核心难点。</li>
<li><strong>状态和记忆怎么持续？</strong> 每一轮对话都会改变角色的状态和记忆池，但下一轮对话开始时，引擎必须恢复上一轮的完整状态。没有持久化，就没有长线叙事。</li>
</ol>

<p>这一篇解决的就是这三个问题。第四、五篇解决的规则匹配细节（两阶段匹配、骰子判定）此处不再展开，聚焦运行时如何把整条链路串起来。</p>

<hr>

<p>引擎拿到 <code>contract</code> 后进入一个循环：接收用户输入 → 匹配规则 → 执行动作 → 调用 LLM → 返回响应。这个循环构成了引擎的运行时。</p>

<p>但在实现这个循环之前，有几个工程问题必须先解决——它们决定了引擎是&rdquo;能跑&rdquo;还是&rdquo;能用&rdquo;。</p>

<h2 id="二-小说级终端流-全角缩进与流式拦截">二、小说级终端流：全角缩进与流式拦截</h2>

<p>LLM 的流式输出是逐块返回的。引擎通过 <code>onChunk</code> 回调拦截每一块，然后直接写到终端。</p>

<p>这里有一个设计细节：<strong>全角缩进（<code>　　</code>）</strong>。</p>

<pre><code class="language-go">onChunk := func(chunk string) {
    for _, ch := range chunk {
        if ch == '\n' {
            fmt.Println()
            needIndent = true
            inParagraph = false
        } else {
            if !inParagraph &amp;&amp; needIndent {
                fmt.Print(&quot;　　&quot;)  // 每个段落开头空两格
                needIndent = false
            }
            fmt.Print(string(ch))
            inParagraph = true
        }
    }
}
</code></pre>

<p>效果：终端输出的文本，每个自然段开头都有两个全角空格，看起来像一本实体书。这个细节对创作者的体验提升是巨大的——它把&rdquo;终端输出&rdquo;变成了&rdquo;小说页面&rdquo;。</p>

<p>但流式输出背后有一个更关键的工程决策：<strong>动作执行器统一处理流式回调</strong>。</p>

<ul>
<li>状态修改动作：立即返回结果，然后通过回调模拟逐字符输出</li>
<li>LLM 动作：真正的流式输出，逐块回调</li>
<li>静态文本：立即返回，然后模拟流式输出</li>
</ul>

<p>所有动作最终都通过 <code>onChunk</code> 输出，不管来源是什么。这保证了终端的输出体验是一致的。</p>

<h2 id="三-五层三明治-prompt-把约束焊死在上下两端">三、五层三明治 Prompt：把约束焊死在上下两端</h2>

<p>LLM 叙事最大的问题是格式跑偏——它经常输出括号剧本流：</p>

<pre><code>（冷笑一声）【贝利亚】：你们太弱了。
</code></pre>

<p>这破坏了沉浸感。解决方案是<strong>把格式约束放在 Prompt 的顶部和底部</strong>，形成三明治结构。v1.1.0 的实际 Prompt 渲染（<code>internal/core/llm/prompt.go</code> 的 <code>RenderPrompt</code>）是五层结构：</p>

<pre><code>【格式硬性要求】
（NarrativeConstraints——禁止括号、禁止剧本标记、禁止独角戏）

【世界观】
（context）

【角色名】
你是 {角色名}，一个 {锚点风格} 的存在。你的背景：{角色背景}

【当前状态】
（从运行时 `map[string]any` 渲染而来）

【命运的推动】
（对话历史——命运与角色的交替记录）

【你记得的过往】
（运行时动态累积的记忆）

【此刻】
（user_input）

【要求】
（NarrativeConstraints，再次强调）
</code></pre>

<p>约束在两端出现两次，中间夹着上下文。这样做有两个原因：</p>

<ol>
<li><strong>首因效应</strong>：LLM 最先看到约束，优先级最高</li>
<li><strong>近因效应</strong>：LLM 最后看到的约束会影响最终输出</li>
</ol>

<p>两次强调，确保格式约束不会被中间的上下文稀释。</p>

<h3 id="3-1-确定性渲染-排序保证-cache-命中">3.1 确定性渲染：排序保证 Cache 命中</h3>

<p>这里有一个微妙的工程问题：Go 的 <code>map</code> 遍历顺序是随机的。</p>

<p>在引擎运行时，契约中的 <code>【状态】</code> 会被转换为 <code>map[string]any</code> 以便快速读写。当 Prompt 渲染 <code>【当前状态】</code> 时，如果每次遍历顺序不同，生成的 Prompt 文本就会变化——哪怕状态内容完全一样。这会导致 <strong>LLM 的 KV Cache 完全失效</strong>，每次都要重新计算。</p>

<p>解决方案：对 <code>state</code> 的键做排序（<code>sort.Strings</code>），确保渲染顺序稳定。这样在状态不变时，生成的 Prompt 文本字节级一致，LLM 服务的 KV Cache 能够命中，降低延迟和 Token 消耗。</p>

<h3 id="3-2-反独角戏约束">3.2 反独角戏约束</h3>

<p><code>NarrativeConstraints</code> 中有一条被刻意强调：</p>

<blockquote>
<p>每段回复必须包含至少一名其他角色（非玩家）的对话和动作反应。如果场景中没有其他角色，请引入或创造至少一个互动对象。禁止只有玩家独角戏。</p>
</blockquote>

<p>这是为了解决叙事中的&rdquo;空旷感&rdquo;。如果 LLM 只回应玩家输入，不引入其他角色互动，故事会变成单人独白，失去戏剧张力。这条约束强制 LLM 在每轮回复中至少引入一个互动对象，让世界活起来。</p>

<h2 id="四-mother-child-存档机制">四、Mother-Child 存档机制</h2>

<p>每一轮对话结束后，引擎会自动保存当前状态到子版文件。</p>

<p><strong>命名规则</strong>（v1.1.0 起与 Flutter 版对齐，<strong>点分隔</strong>）：</p>

<ul>
<li>母版 <code>story.meph</code> → 默认子版 <code>story.child.meph</code></li>
<li>分支 <code>--branch dark</code> → <code>story.dark.meph</code></li>
</ul>

<pre><code class="language-go">func BuildChildPath(filename string, branch string) string {
    dir := filepath.Dir(filename)
    base := filepath.Base(filename)
    ext := filepath.Ext(base)
    name := strings.TrimSuffix(base, ext)

    // 已是子版：直接覆盖（避免嵌套生成）
    if isChildFileName(name) {
        return filename
    }

    if branch != &quot;&quot; {
        return filepath.Join(dir, name+&quot;.&quot;+branch+ext)
    }
    return filepath.Join(dir, name+childSuffix+ext) // childSuffix = &quot;.child&quot;
}
</code></pre>

<p><code>isChildFileName</code> 精准识别已存在的子版：<code>xxx.child</code>（默认子版）或 <code>xxx.分支名</code>（分支名以字母开头）。这样 <code>my_story_1.meph</code>（数字序号）不会被误判为子版。</p>

<p>子版文件是完整的 <code>.meph</code> 契约，包含：</p>

<ul>
<li>母版的所有静态区块（角色名、世界观、角色背景、开局场景、锚点、规则）</li>
<li>更新后的 <code>【状态】</code></li>
<li>累积的 <code>【记忆】</code></li>
<li>最近的 <code>【历史】</code></li>
</ul>

<p>这意味着一份静态契约可以演化出无数个动态子版：</p>

<pre><code>story.meph (母版，只读)
    ├── story.child.meph (主线存档)
    ├── story.dark.meph (黑暗分支)
    ├── story.light.meph (光明分支)
    └── story.experimental.meph (实验分支)
</code></pre>

<p>每个分支独立演化，互不影响。项目自带的 <code>data/dantes.meph</code> 就伴随一个已运行的 <code>data/dantes.child.meph</code> 存档。</p>

<p><strong>保存时机</strong>：不是每轮都写磁盘的低效模式，而是分两层：</p>

<ol>
<li><strong>每轮对话后</strong>：Session 层（<code>cmd/mephisto/session.go</code>）调用 <code>engine.Save()</code>，实时持久化进度</li>
<li><strong>退出时</strong>：<code>defer</code> 再保存一次，确保退出前状态落盘</li>
</ol>

<p><strong>保存时的规则保鲜</strong>：<code>Save()</code> 有一个精妙设计——保存前先读取磁盘上的子版文件（若存在），以磁盘上的 <code>【规则】</code> 区块为最新规则。这样用户在编辑器中对规则区块的实时修改不会被自动保存覆盖。这同时支撑了 v1.0.3 引入的<strong>规则热重载</strong>：<code>session.go</code> 通过 <code>fsnotify</code> 监听子版文件变更，500ms 防抖后调用 <code>ReloadContract</code> 重新解析，只替换规则、保留状态和历史，让&rdquo;编辑规则 → 保存 → 立即生效&rdquo;成为可能。</p>

<p><strong>加载时</strong>：</p>

<ul>
<li>默认加载子版（如果存在）</li>
<li><code>--reset</code> 忽略子版，从母版重新开始</li>
<li><code>--branch dark</code> 加载对应的分支文件</li>
</ul>

<p><strong>注意</strong>：直接运行子版文件会覆盖原文件——引擎会将任何 <code>.meph</code> 文件视为母版，并生成对应的子版。如果不想丢失进度，请避免对子版文件直接运行 <code>run</code> 命令。</p>

<p><strong>这个设计的价值</strong>：创作者可以在关键时刻分叉故事线，探索不同走向，而不丢失任何进度。</p>

<h2 id="五-记忆提取-流式输出后的同步编织">五、记忆提取：流式输出后的同步编织</h2>

<p>记忆提取是长线叙事的关键——它把关键事件从对话历史中提取出来，压缩后长期保存，在每一轮中注入 LLM 上下文。</p>

<p>但提取需要调用 LLM，会耗时数秒。如果放在流式输出<strong>之前</strong>执行，用户每 5 轮就要等几秒才能看到第一个字。</p>

<p>解决方案很简单：<strong>先输出，后提取。</strong></p>

<p>每一轮对话的流程是这样的：</p>

<pre><code>用户输入
    │
    ▼
规则匹配 + 动作执行 + LLM 流式输出（用户看到文字逐字出现）
    │
    ▼
流式输出完成，用户读完回复
    │
    ▼
引擎 Run 同步执行记忆提取（此时代码在 Run 内部，用户已读完响应）
    │
    ▼
返回给 Session，Session 调用 Save 自动保存子版
    │
    ▼
显示输入提示，等待下一轮
</code></pre>

<p>注意<strong>和旧版本的区别</strong>：记忆提取在 <code>engine.Run()</code> 内部同步执行，子版保存由 Session 层在每轮 Run 返回后调用。两者解耦——引擎负责叙事与记忆，Session 负责持久化。</p>

<pre><code class="language-go">// internal/core/engine/engine.go

func (e *Engine) Run(input string, onChunk func(string)) (string, error) {
    // ... 规则匹配、LLM 调用、流式输出 ...

    // 4. 记录角色响应
    runtime.AddHistory(&quot;assistant&quot;, response)

    // 5. 记忆提取（每 N 轮，同步调用）
    e.processMemories()   // ← 用户此时已读完响应

    return response, nil
}
</code></pre>

<p><code>processMemories()</code> 内部流程：</p>

<ol>
<li><strong>提取间隔判断</strong>：<code>ShouldExtract</code>——轮数 % 5 == 0 时触发（<code>ExtractInterval = 5</code>）</li>
<li><strong>调用 LLM 提取</strong>：取最近 10 轮对话（<code>ExtractWindow = 10</code>），生成关键事件摘要（每条不超过 20 字）</li>
<li><strong>语义去重</strong>：<code>shared.DeduplicateMemories</code> 基于关键词 Jaccard 相似度去重，语义相近但表述不同的记忆自动合并。例如&rdquo;浮士德在书斋中遇到了梅菲斯特&rdquo;与&rdquo;梅菲斯特在深夜来访浮士德的书斋&rdquo;——两条记忆共享浮士德、梅菲斯特、书斋等多个关键词，被判定为描述同一事件而合并成一条</li>
<li><strong>追加 + 压缩</strong>：超过上限（<code>MaxLimit = 30</code>）时自动压缩，保留最近 5 条 + 3-5 条摘要</li>
</ol>

<p><strong>为什么这样设计？</strong></p>

<ol>
<li><strong>用户无感知</strong>：流式输出已经完成，用户正在阅读或思考回复内容。记忆提取在后台悄悄进行，用户不需要&rdquo;等待&rdquo;。</li>
<li><strong>逻辑简单</strong>：同步调用比异步 goroutine 更容易控制——没有竞态条件，没有&rdquo;保存时记忆还没写完&rdquo;的问题。</li>
</ol>

<p>提取失败只会静默记录一条日志（提取函数返回错误则直接跳过），对话可以继续——只是本轮的记忆没有被保存。</p>

<h2 id="六-完整闭环">六、完整闭环</h2>

<p>把所有部分串起来，引擎的每一轮对话是这样运转的：</p>

<pre><code>用户输入
    │
    ▼
规则匹配
    │   ├── 被动规则（状态修改 + 注入记忆）批量执行，多条同时触发
    │   └── 主动规则（LLM 指令 / 静态文本）互斥匹配，只取第一条
    │
    ▼
执行动作 → LLM 调用（60 秒超时保护，失败时 ⚠️ 降级为静态响应）
    │                        ↑——超时只保护 LLM 调用阶段
    ▼
流式输出（全角缩进 + 逐块回调）
    │
    ▼
记录 assistant 历史 → （同步）记忆提取（每 5 轮触发一次，无流式等待）
    │
    ▼
Session 层调用 Save → 自动保存到子版文件（story.child.meph）
    │
    ▼
规则热重载监听（后台异步 fsnotify，不阻塞主循环）
    │
    ▼
等待下一轮输入
</code></pre>

<p>几个运行时的健壮性细节值得说明：</p>

<ul>
<li><strong>LLM 超时降级</strong>：整个 LLM 调用包裹在 60 秒超时上下文（<code>context.WithTimeout</code>）中。超时或失败时，引擎通过 <code>onChunk</code> 输出 <code>（⚠️ LLM 调用失败：请求超时，已降级为静态响应）</code>，再返回默认静态文本。<strong>告诉用户&rdquo;LLM 挂了&rdquo;比让用户猜&rdquo;角色沉默了&rdquo;更好</strong>——前者是可诊断的工程问题，后者可能被误解为叙事设计。</li>
<li><strong>调试与静默</strong>：调试信息（<code>--debug</code>）写入 <code>os.Stderr</code>，普通输出（<code>--quiet</code>）不干扰调试信息。两者可同时启用。</li>
</ul>

<p>这就是 Mephisto 的运行时。</p>

<h2 id="七-代价与局限">七、代价与局限</h2>

<p>这套机制不是没有代价的：</p>

<p><strong>1. 记忆提取依赖 LLM 质量</strong></p>

<p>如果主模型状态不佳，提取的摘要可能跑偏——甚至篡改关键事实（如将&rdquo;击败&rdquo;误写为&rdquo;放逐&rdquo;）。这是一种&rdquo;幻觉&rdquo;风险。</p>

<p>当前应对策略有两个层面：</p>

<ul>
<li><strong>提示词保护</strong>：提取和压缩的提示词中明令禁止修改角色的核心设定（角色名、锚点内容、状态值等），在 DeepSeek 和 GPT-4 上效果可靠。</li>
<li><strong>模型选择建议</strong>：对于 7B 本地模型，摘要质量下降明显。如果必须使用轻量模型，建议关闭自动记忆提取（设置 <code>ExtractInterval = 0</code>），改为手动管理记忆。</li>
</ul>

<p>未来可以进一步加强：实现<strong>记忆后验验证</strong>——提取结果返回后，由引擎检查是否与核心设定冲突，发现有矛盾直接丢弃该条记忆。</p>

<p><strong>2. 分支切换需要手动管理</strong></p>

<p>子版文件是独立存储的，切换分支需要用户主动指定 <code>--branch</code>。不像真正的版本控制有 diff 和 merge，分支之间的内容不会自动同步。</p>

<p><strong>3. 流式输出占用终端</strong></p>

<p>全角缩进和流式输出在终端看起来很好，但如果用户想复制粘贴文本，缩进和换行符会一起被复制——有时会产生干扰。</p>

<h2 id="八-小结">八、小结</h2>

<p>六篇走完了一条完整的路径：</p>

<table>
<thead>
<tr>
<th>篇目</th>
<th>解决的问题</th>
<th>核心产出</th>
</tr>
</thead>

<tbody>
<tr>
<td>第一篇</td>
<td>用什么格式写规则？</td>
<td><code>.meph</code> 格式设计</td>
</tr>

<tr>
<td>第二篇</td>
<td>第一步跑什么？</td>
<td>从零写浮士德契约并运行</td>
</tr>

<tr>
<td>第三篇</td>
<td>怎么精确解析并报错？</td>
<td>区块扫描器 + Parser</td>
</tr>

<tr>
<td>第四篇</td>
<td>规则和变量怎么解析？</td>
<td>规则表达式 + 插值语法</td>
</tr>

<tr>
<td>第五篇</td>
<td>怎么保证不改坏？</td>
<td>Golden File 测试</td>
</tr>

<tr>
<td>第六篇</td>
<td>怎么让契约活起来？</td>
<td>五层 Prompt + 分支存档 + 记忆提取</td>
</tr>
</tbody>
</table>
<p>合在一起，就是一个完整的长线叙事引擎：</p>

<pre><code>契约（.meph）
    │
    ▼
解析器（第三、四篇）──→ Contract
    │
    ▼
引擎（第六篇）──→ 规则匹配 + LLM 流式叙事 + 记忆提取
    │
    ▼
子版存档（story.child.meph）──→ 长期连续性 + 多分支
</code></pre>

<hr>

<h2 id="速查卡-完整流程">速查卡：完整流程</h2>

<pre><code>.meph 契约文件
    │
    ▼ 扫描器（行号绑定）
    │
    ▼ 区块列表 []Block
    │
    ▼ Parser（按区块标题路由）
    │
    ▼ domain.Contract
    │   ├─ RoleName
    │   ├─ Anchor
    │   ├─ State
    │   ├─ Worldview
    │   └─ Rules（条件原样存储，运行时求值）
    │
    ▼ 引擎
    │   ├─ 五层三明治 Prompt（顶部约束 → 上下文 → 角色 → 状态/历史/记忆 → 底部约束）
    │   ├─ 规则匹配（被动批量 + 主动互斥）
    │   ├─ 动作执行（注入 / 状态修改 / LLM 调用 / 静态文本）
    │   ├─ 流式输出（全角缩进）
    │   ├─ 记忆提取（每 5 轮，同步调用，语义去重）
    │   ├─ LLM 超时降级（60 秒，⚠️ 提示 + 静态响应）
    │   └─ 子版存档（story.child.meph，点分隔命名）
    │
    ▼ 引擎循环
        用户输入 → 规则匹配 → 执行动作 → LLM 流式叙事
        → 记忆提取 → Session 自动保存 → 热重载监听 → 等待下一轮
</code></pre>

<p>如果你想快速定位某篇的具体内容，对照上面的流程节点查找：</p>

<table>
<thead>
<tr>
<th>流程节点</th>
<th>对应文章</th>
<th>关键概念</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>.meph</code> 格式</td>
<td>第一篇</td>
<td>区块标题、规则语法</td>
</tr>

<tr>
<td>上手操作</td>
<td>第二篇</td>
<td>从零写契约、无 LLM 模式</td>
</tr>

<tr>
<td>扫描器</td>
<td>第三篇</td>
<td>行号绑定、白名单前置</td>
</tr>

<tr>
<td>Parser</td>
<td>第四篇</td>
<td>规则拆解、插值语法</td>
</tr>

<tr>
<td>测试体系</td>
<td>第五篇</td>
<td>Golden File、错误场景测试</td>
</tr>

<tr>
<td>引擎运行时</td>
<td>第六篇</td>
<td>五层 Prompt、分支存档、记忆提取</td>
</tr>
</tbody>
</table>

<blockquote>
<p>项目地址：<a href="https://github.com/yuelinghuashu/mephisto" target="_blank">https://github.com/yuelinghuashu/mephisto</a></p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[契约在手，怎么让它活起来？五层三明治 Prompt 结构、流式全角缩进、Mother-Child 分支存档、记忆提取——构建完整的运行时闭环。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[LLM]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:narrative-engine]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[构建大模型叙事引擎：集成测试与行为冻结]]></title>
      <link>https://moongate.top/docs/narrative-engine-integration-testing-and-behavior-freezing</link>
      <guid isPermaLink="true">f1937ff0-8254-47a9-8b24-61418346fbea</guid>
      <pubDate>Mon, 20 Jul 2026 21:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p><strong>前置阅读</strong>：建议先读第三篇的“四、Parser”和第四篇的“四、小结”，理解解析器输出 <code>domain.Contract</code> 的完整流程。本篇假设你已经知道解析器能把 <code>.meph</code> 变成结构体。</p>
</blockquote>

<h2 id="一-叙事引擎的第四个问题-怎么保证行为稳定">一、叙事引擎的第四个问题：怎么保证行为稳定？</h2>

<p>解析器写完了。但它是一个<strong>长期维护的代码</strong>——需求会变、格式会扩、bug 会修。每次改动都可能破坏已有的行为。</p>

<p>这里的关键矛盾是：<strong>创作者依赖的是稳定的行为，开发者依赖的是自由的修改权</strong>。如果改一行代码就要手动测试所有已知场景，开发者会畏惧重构；如果不测试，改坏了创作者会直接发现——但创作者不会关心&rdquo;你重构了解析器&rdquo;。</p>

<p>解决方案是把解析行为&rdquo;冻结&rdquo;下来：用一组固定的契约作为看门狗，每次变更后自动对比解析结果是否与预期一致。</p>

<p>这就是集成测试的作用：把一组固定的 <code>.meph</code> 契约作为&rdquo;看门狗&rdquo;，每次代码变更后跑一遍，确保行为没有被意外改变。</p>

<hr>

<h2 id="二-golden-file-测试-把解析结果固化下来">二、Golden File 测试：把解析结果固化下来</h2>

<p>最直接的测试方式：准备一个标准契约文件，解析它，然后把解析结果序列化为 JSON 保存起来。以后每次跑测试，都把当前的解析结果和这个 JSON 文件做对比。</p>

<p>项目里的 <code>testdata/sample.meph</code> 就是这份标准契约。测试流程如下：</p>

<pre><code class="language-go">func TestParseSample(t *testing.T) {
    got, err := ParseFile(&quot;testdata/sample.meph&quot;)
    if err != nil {
        t.Fatalf(&quot;解析失败: %v&quot;, err)
    }

    goldenPath := &quot;testdata/sample.golden&quot;
    var want domain.Contract

    if err := loadGolden(goldenPath, &amp;want); err != nil {
        // Golden 文件不存在，自动生成
        saveGolden(goldenPath, got)
        t.Log(&quot;Golden 文件已生成，请检查后重新运行测试&quot;)
        t.FailNow()
    }

    // 对比 got 和 want
    if diff := cmp.Diff(want, got); diff != &quot;&quot; {
        t.Errorf(&quot;解析结果与预期不符:\n%s&quot;, diff)
        t.Log(&quot;💡 如果更改是预期的，请运行: go test -update&quot;)
    }
}
</code></pre>

<p>首次运行会自动生成 <code>sample.golden</code>。之后每次运行都会对比，发现差异就报错。如果改动是预期的（比如新增了一个字段），运行 <code>go test -update</code> 即可刷新 Golden 文件。</p>

<p><strong>这个机制的核心价值是：</strong> 让解析器的行为被“冻结”下来。任何改动都必须经过测试验证，不能偷偷改变解析结果。</p>

<h2 id="三-解析即验证">三、解析即验证</h2>

<p>解析不只是“把文本读进来”——它会在解析过程中直接验证必填项。</p>

<p>如果角色名为空，<code>parseRoleName</code> 直接报错：</p>

<pre><code>第 X 行：角色名不能为空
</code></pre>

<p>如果规则名/条件/动作为空，<code>parseRuleLine</code> 直接报错并携带行号。</p>

<p><strong>解析不通过，结构体就不存在。</strong> 不存在“解析成功但内容无效”的状态——这是手写解析器相比 JSON/YAML 的另一个优势。JSON 解析器不管语义完整性，它只管结构正确。</p>

<h2 id="四-滑窗老化测试-历史记录的正确截断">四、滑窗老化测试：历史记录的正确截断</h2>

<p>引擎有一个关键行为：历史记录不能无限增长。它需要自动截断，只保留最近 N 轮对话。</p>

<p>测试用例验证这个行为：</p>

<pre><code class="language-go">func TestIntegrationHistoryLimit(t *testing.T) {
    contract, err := parser.ParseFile(&quot;../parser/testdata/sample.meph&quot;)
    if err != nil {
        t.Fatalf(&quot;解析失败: %v&quot;, err)
    }
    // 设置最大历史保留 2 轮
    eng := engine.New(contract, engine.WithMaxHistory(2))

    // 执行 5 轮对话
    for range 5 {
        eng.Run(&quot;你好&quot;, nil)
    }

    history := eng.History()
    // 5 轮对话产生 10 条记录，但容量只有 4 条（2 轮 * 2 条/轮）
    if len(history) != 4 {
        t.Errorf(&quot;历史记录长度 = %d, want 4&quot;, len(history))
    }
}
</code></pre>

<p>这个测试确保历史截断是“整轮丢弃”而不是“逐条丢弃”。如果逐条丢弃，可能出现“只剩下命运的输入、没有角色的响应”这种半轮数据，会导致 Prompt 中 <code>【命运的推动】</code> 区块出现不完整的上下文。</p>

<p><strong>整轮截断</strong>的策略保证了历史的完整性——要么保留一整轮（fate + assistant），要么全丢。</p>

<h2 id="五-错误场景测试-确保报错信息精确">五、错误场景测试：确保报错信息精确</h2>

<p>除了“正常路径”，集成测试还覆盖“错误路径”——确保各类格式错误能正确报错，并且报错信息包含行号和区块名：</p>

<pre><code class="language-go">func TestParseErrors(t *testing.T) {
    tests := []struct {
        name    string
        input   string
        wantErr string // 错误信息应包含的子串
    }{
        {
            name:    &quot;区块外有内容&quot;,
            input:   &quot;这是区块外的内容\n【角色名】\n贝利亚&quot;,
            wantErr: &quot;内容出现在任何区块之外&quot;,
        },
        {
            name:    &quot;列表项缺少 - 前缀&quot;,
            input:   &quot;【锚点】\n核心信念：力量&quot;,
            wantErr: &quot;列表项必须以 '-' 开头&quot;,
        },
        {
            name:    &quot;列表项缺少冒号&quot;,
            input:   &quot;【锚点】\n- 核心信念 \&quot;力量\&quot;&quot;,
            wantErr: &quot;缺少 ':' 或 '：'&quot;,
        },
        // ...
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            _, err := ParseString(tt.input)
            if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
                t.Errorf(&quot;期望错误包含 '%s'，实际: %v&quot;, tt.wantErr, err)
            }
        })
    }
}
</code></pre>

<p>这些测试确保报错信息不会退化为“unexpected token at position 42”——那是我们在第一篇就决定要消灭的东西。</p>

<h2 id="六-代价">六、代价</h2>

<ul>
<li>维护 Golden 文件需要手动确认（首次生成或更新时要检查内容是否正确）</li>
<li>错误场景测试需要覆盖尽可能多的边界情况</li>
<li>每次新增区块类型，需要同步更新测试用例</li>
</ul>

<p>但收益是：<strong>重构时可以放心改代码，只要测试全绿，行为就没变。</strong></p>

<h2 id="七-小结">七、小结</h2>

<p>集成测试是工程的“看门狗”。它把解析器的行为冻结下来，任何改动都必须经过验证。</p>

<p>四件事构成了这套测试体系：</p>

<ol>
<li><strong>Golden File 测试</strong>：固化标准契约的解析结果</li>
<li><strong>解析即验证</strong>：解析过程中直接检查必填项和完整性</li>
<li><strong>滑窗老化测试</strong>：确保历史记录按整轮截断</li>
<li><strong>错误场景测试</strong>：确保报错信息精确到行号</li>
</ol>

<p>有了这套体系，后续的引擎开发可以放心迭代——不怕改坏东西，测试会告诉你。</p>

<p>下一篇，我们将进入引擎的运行时。要解决的核心问题是：<strong>拿到了 <code>domain.Contract</code> 之后，怎么驱动大模型生成符合规则的叙事？</strong></p>

<p>答案是<strong>三明治 Prompt 结构</strong>——把格式约束放在上下两端，把上下文放在中间，彻底根除括号剧本流。</p>

<blockquote>
<p>项目地址：<a href="https://github.com/yuelinghuashu/mephisto" target="_blank">https://github.com/yuelinghuashu/mephisto</a></p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[解析器写完了，但怎么保证以后改代码不会改坏它？Golden File 测试、验证器、滑窗老化测试——用测试将解析行为彻底冻结。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[Engineering]]></category>
      <category><![CDATA[CI/CD]]></category>
      <dc:subject>P4</dc:subject>
      <dc:relation><![CDATA[series:narrative-engine]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[构建大模型叙事引擎：规则表达式与插值语法的解析]]></title>
      <link>https://moongate.top/docs/narrative-engine-parsing-rules-and-interpolation</link>
      <guid isPermaLink="true">c7f60196-3aa2-4191-9796-544aa5ba7a3e</guid>
      <pubDate>Mon, 20 Jul 2026 19:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p><strong>前置阅读</strong>：建议先读第三篇的&rdquo;二、两阶段设计&rdquo;和&rdquo;三、区块扫描器&rdquo;，理解扫描器如何输出 <code>[]Block</code>。本篇假设你已经知道 Parser 如何根据 <code>Title</code> 路由到不同解析函数。</p>
</blockquote>

<h2 id="一-叙事引擎的第三个问题-条件-动作怎么拆解">一、叙事引擎的第三个问题：条件-动作怎么拆解？</h2>

<p>区块切分完以后，最复杂的部分是<strong>规则表达式</strong>。一个叙事引擎需要回答：条件怎么写、动作怎么写、变量怎么引用。</p>

<p>这里有一条重要的设计边界：<strong>解析器只负责拆解，不负责求值</strong>。条件字符串（如 <code>包含 &quot;攻击&quot; &amp;&amp; 状态.堕落指数 &gt; 80</code>）原样存入结构体，引擎运行时再计算 true/false。为什么这样做？因为求值所需的运行状态在解析时还不存在——状态值是在对话过程中动态变化的，无法在加载阶段就确定。</p>

<p>这一篇要解决的就是拆解逻辑本身。</p>

<hr>

<h2 id="二-规则解析-拆解条件-动作">二、规则解析：拆解条件-动作</h2>

<p>规则格式固定：<code>[规则名] if 条件 -&gt; 动作</code></p>

<pre><code class="language-meph">[攻击] if 包含 &quot;攻击&quot; -&gt; 注入 &quot;贝利亚发动了猛烈的攻击&quot;
[光之国] if 包含 &quot;光之国&quot; &amp;&amp; 状态.情绪 == &quot;暴怒&quot; -&gt; 注入 &quot;光之国的记忆让贝利亚更加愤怒&quot;
[高堕落] if 状态.堕落指数 &gt; 80 -&gt; 状态.情绪 = &quot;癫狂&quot;
</code></pre>

<p>插值语法出现在规则动作和文本区块中：</p>

<pre><code class="language-meph">注入 &quot;{角色名}的故乡是光之国&quot;
</code></pre>

<p>这一篇要解决两个问题：</p>

<ol>
<li><strong>规则的条件和动作怎么拆解成可存储的结构？</strong></li>
<li><strong>插值语法怎么在解析层被识别和处理？</strong></li>
</ol>

<h3 id="2-1-规则名提取">2.1 规则名提取</h3>

<p>找到第一个 <code>[</code> 和第一个 <code>]</code>，取中间内容：</p>

<pre><code class="language-go">func parseRuleLine(line string, lineNumber int) (*domain.Rule, error) {
    trimmed := strings.TrimSpace(line)
    if !strings.HasPrefix(trimmed, &quot;[&quot;) {
        return nil, fmt.Errorf(&quot;规则必须以 '[' 开头&quot;)
    }
    idx := strings.Index(trimmed, &quot;]&quot;)
    if idx == -1 {
        return nil, fmt.Errorf(&quot;缺少闭合的 ']'&quot;)
    }
    name := strings.TrimSpace(trimmed[1:idx])
    if name == &quot;&quot; {
        return nil, fmt.Errorf(&quot;规则名不能为空&quot;)
    }

    rest := strings.TrimSpace(trimmed[idx+1:])
    // 接下来提取条件和动作...
}
</code></pre>

<h3 id="2-2-条件与动作的拆分">2.2 条件与动作的拆分</h3>

<p>用 <code>if</code> 和 <code>-&gt;</code> 作为分隔符：</p>

<pre><code class="language-go">// 去掉 &quot;if &quot; 前缀
if !strings.HasPrefix(rest, &quot;if &quot;) {
    return nil, fmt.Errorf(&quot;规则条件必须以 'if ' 开头&quot;)
}
rest = strings.TrimPrefix(rest, &quot;if&quot;)
rest = strings.TrimSpace(rest)

// 取第一个 &quot;-&gt;&quot; 分割条件和动作
cond, action, ok := strings.Cut(rest, &quot;-&gt;&quot;)
if !ok {
    return nil, fmt.Errorf(&quot;规则缺少 '-&gt;'&quot;)
}
cond = strings.TrimSpace(cond)
action = strings.TrimSpace(action)
</code></pre>

<h3 id="2-3-互斥组">2.3 互斥组</h3>

<p>动作中可能带有 <code>[group:xxx]</code> 标记：</p>

<pre><code class="language-text">[攻击] if 包含 &quot;攻击&quot; -&gt; [group:combat] 注入 &quot;贝利亚发动了猛烈的攻击&quot;
</code></pre>

<p>解析时提取组名，存入 <code>domain.Rule.Group</code>：</p>

<pre><code class="language-go">group := &quot;&quot;
if strings.HasPrefix(action, &quot;[group:&quot;) {
    endIdx := strings.Index(action, &quot;]&quot;)
    if endIdx != -1 {
        group = action[7:endIdx]
        action = strings.TrimSpace(action[endIdx+1:])
    }
}
</code></pre>

<p>互斥组的作用是：同一组内多条规则，只有第一条匹配的会被触发。这个逻辑在引擎运行时生效，解析层只需存好组名。</p>

<p>互斥组可以搭配任何动作类型，不仅限于 <code>注入</code>：</p>

<pre><code class="language-text">[高堕落] if 状态.堕落指数 &gt; 80 -&gt; [group:escalate] 状态.情绪 = &quot;癫狂&quot;
[失控] if 状态.情绪 == &quot;癫狂&quot; &amp;&amp; 状态.堕落指数 &gt; 90 -&gt; [group:escalate] 注入 &quot;{角色名}已完全失控&quot;
</code></pre>

<h3 id="2-4-引号处理">2.4 引号处理</h3>

<p>条件和动作中的字符串用 <code>&quot;</code> 包裹。解析时我调用 <code>unquote</code> 剥离外层引号：</p>

<pre><code class="language-go">func unquote(s string) (string, error) {
    s = strings.TrimSpace(s)
    if len(s) &gt;= 2 {
        if (strings.HasPrefix(s, &quot;\&quot;&quot;) &amp;&amp; strings.HasSuffix(s, &quot;\&quot;&quot;)) ||
           (strings.HasPrefix(s, &quot;“&quot;) &amp;&amp; strings.HasSuffix(s, &quot;”&quot;)) {
            return s[1:len(s)-1], nil
        }
    }
    return s, nil
}
</code></pre>

<p>支持中文引号是因为创作者可能使用中文输入法，<code>“</code> 和 <code>”</code> 比 <code>&quot;</code> 更容易自然输入。</p>

<h2 id="三-插值语法-变量名-的识别与替换">三、插值语法：{变量名} 的识别与替换</h2>

<p>插值的核心需求：<strong>在任意文本中识别 <code>{角色名}</code> 并替换为对应的角色名称。</strong> 当前支持的插值变量只有一个——<code>{角色名}</code>，它来自 <code>【角色名】</code> 区块，静态不变。</p>

<h3 id="3-1-为什么只有-角色名">3.1 为什么只有 <code>{角色名}</code>？</h3>

<p>你可能好奇：为什么状态变量（如堕落指数、情绪）不能直接用 <code>{堕落指数}</code> 插值？</p>

<p>因为状态变量在引擎中是<strong>运行时动态读写</strong>的——创作者通过 <code>状态.键 = 值</code> 来修改，通过 <code>状态.键 &gt; 值</code> 来比较。如果允许 <code>{堕落指数}</code> 插值，就引入了一套平行机制：既可以在规则中通过 <code>状态.堕落指数</code> 引用它，又可以通过 <code>{堕落指数}</code> 引用它。两套语法做同一件事，徒增学习成本。</p>

<p>所以状态变量的引用统一使用 <code>状态.键</code> 语法，不设插值。引擎运行时的模板替换也只做一件事：把 <code>{角色名}</code> 替换为实际的角色名称。</p>

<h3 id="3-2-替换时机-三个场景">3.2 替换时机：三个场景</h3>

<p>插值替换在引擎中发生在<strong>三个不同场景</strong>：</p>

<ol>
<li><p><strong>CLI 欢迎界面显示时</strong>：世界观、开局场景在显示给人类看时替换一次。创作者看到的是&rdquo;贝利亚奥特曼&rdquo;而非 <code>{角色名}</code>。</p></li>

<li><p><strong>规则动作执行时</strong>：<code>注入 &quot;{角色名}的故乡是光之国&quot;</code> 在每次规则触发时执行替换。这是插值最核心的使用场景。</p></li>

<li><p><strong>Prompt 构建中</strong>：世界观和背景文本<strong>原样传给 LLM</strong>，不依赖引擎主动替换。LLM 从上下文中（&rdquo;你是贝利亚奥特曼&rdquo;）自然理解 <code>{角色名}</code> 的含义。</p></li>
</ol>

<p><strong>为什么第三种场景不主动替换？</strong></p>

<p>因为替换会改变文本的原始形态。如果将 <code>{角色名}的故乡是光之国</code> 替换为 <code>贝利亚奥特曼的故乡是光之国</code>，LLM 收到的是一段已经&rdquo;固化&rdquo;的叙述。而保持 <code>{角色名}</code> 原样，让 LLM 在生成时根据上下文动态决定如何使用这个名字——在某些叙事分支中，角色名可能发生变化（如角色改名、被遗忘等），这时保留占位符反而更灵活。</p>

<h2 id="四-交汇点-当规则遇上插值">四、交汇点：当规则遇上插值</h2>

<p>以这条规则为例：</p>

<pre><code class="language-meph">[光之国] if 包含 &quot;光之国&quot; -&gt; 注入 &quot;{角色名}的故乡是光之国&quot;
</code></pre>

<p>完整执行流程：</p>

<pre><code class="language-text">用户输入 &quot;我要去光之国！&quot;
    │
    ▼
规则匹配：包含 &quot;光之国&quot; → true
    │
    ▼
动作识别：提取动作类型为 &quot;注入&quot;
    │
    ▼
运行时替换：{角色名} → &quot;贝利亚奥特曼&quot;
    │
    ▼
追加记忆：&quot;贝利亚奥特曼的故乡是光之国&quot; 写入记忆库
    │
    ▼
LLM 叙事：带着新记忆生成响应
</code></pre>

<p><strong>为什么坚持把替换放在运行时？</strong></p>

<p>因为引擎支持多分支故事线。如果加载时就替换成静态文本，所有分支共享同一个值，无法独立演化。运行时替换意味着每个分支读取自己的状态——状态变了，插值结果就变了。</p>

<h3 id="骰子表达式的容错">骰子表达式的容错</h3>

<p>骰子表达式（如 <code>roll(1d100) &gt;= 80</code>）在解析时原样存储，运行时求值。如果表达式格式错误（如 <code>roll(1d10</code> 缺少括号），引擎不会报错，而是视为条件不满足（返回 <code>false</code>）。这是因为骰子表达式本身就是条件的一部分，解析层不负责验证运行时的正确性——格式错误就当作&rdquo;不匹配&rdquo;处理。</p>

<h2 id="五-小结-解析层完整了">五、小结：解析层完整了</h2>

<p>到这一篇为止，解析层覆盖了所有语法单元：</p>

<table>
<thead>
<tr>
<th>区块类型</th>
<th>解析函数</th>
<th>复杂度</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td>文本区块</td>
<td><code>parseTextBlock</code></td>
<td>低</td>
<td>拼接内容，保留换行</td>
</tr>

<tr>
<td>键值对列表</td>
<td><code>parseKeyValuePairs</code></td>
<td>中</td>
<td>支持中英文冒号，精确报报错</td>
</tr>

<tr>
<td>规则列表</td>
<td><code>parseRules</code></td>
<td><strong>高</strong></td>
<td>条件表达式、互斥组、骰子表达式</td>
</tr>

<tr>
<td>纯文本列表</td>
<td><code>parsePlainList</code></td>
<td>低</td>
<td>逐行提取</td>
</tr>

<tr>
<td><strong>插值语法</strong></td>
<td><code>ReplacePlaceholders</code>（运行时）</td>
<td>中</td>
<td>解析时保留，运行时替换</td>
</tr>
</tbody>
</table>
<p><strong>最关键的一条边界：</strong></p>

<blockquote>
<p>解析器只负责&rdquo;读出来&rdquo;——把文本转化为结构化的数据。<br>
引擎负责&rdquo;算出来&rdquo;——条件求值、变量替换、动作执行。</p>
</blockquote>

<p>下一篇，我们将跨过这条边界，进入引擎的运行时。而引擎面对的第一个工程问题是：<strong>如何保证代码改动不破坏现有的解析行为？</strong></p>

<p>答案是<strong>集成测试</strong>——用一组固定的 <code>.meph</code> 契约作为&rdquo;看门狗&rdquo;，每次改动后对比解析结果是否与预期一致。</p>

<blockquote>
<p>项目地址：<a href="https://github.com/yuelinghuashu/mephisto" target="_blank">https://github.com/yuelinghuashu/mephisto</a></p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[规则的条件-动作表达式怎么拆解？{变量} 插值语法怎么处理？本文覆盖解析层最后两块拼图——让规则从文本变为可执行结构。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[DSL]]></category>
      <category><![CDATA[LLM]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:narrative-engine]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[构建大模型叙事引擎：区块扫描与行号绑定]]></title>
      <link>https://moongate.top/docs/narrative-engine-block-scanner-and-line-numbers</link>
      <guid isPermaLink="true">a6a93745-4f18-4778-98bb-e1405b4e5770</guid>
      <pubDate>Mon, 20 Jul 2026 17:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p><strong>前置阅读</strong>：如果你是从搜索引擎直接跳到这一篇，建议先读第一篇的“一、先看目标”和“六、.meph 的设计原则”，了解 <code>.meph</code> 的长相和设计初衷。本篇假设你已经知道 <code>【角色名】</code> 和 <code>【规则】</code> 是什么。</p>
</blockquote>

<p><code>.json</code> 解析器报 <code>position 246</code>，创作者需要的是<strong>&ldquo;第 12 行缺冒号&rdquo;</strong>。这一篇实现的就是这种精确报错。</p>

<h2 id="一-叙事引擎的第二个问题-规则怎么变成结构">一、叙事引擎的第二个问题：规则怎么变成结构？</h2>

<p>格式定义好之后，下一个问题是<strong>解析</strong>——怎么把文本变成程序能操作的数据。</p>

<p>这里有两个关键指标：<strong>解析正确性</strong>和<strong>报错可读性</strong>。通用格式（JSON/YAML）在第一个指标上很好，但在第二个指标上几乎是灾难——<code>position 246</code> 对创作者毫无意义。而对于自定义格式，解析器必须从头实现。</p>

<p>一篇好的解析器设计，应该做到三件事：</p>

<ol>
<li><strong>精确识别</strong>：每个区块、每行内容都能被正确归类</li>
<li><strong>行号绑定</strong>：每一条错误都能追溯到具体行，而不是字符串偏移量</li>
<li><strong>白名单校验</strong>：错误的写法（如 <code>【脚色名】</code>）不会被当作有效内容</li>
</ol>

<p>这一篇实现的就是这三件事。</p>

<hr>

<h2 id="二-先看问题-如果没有行号绑定">二、先看问题：如果没有行号绑定</h2>

<p>在开始写解析器之前，先看一个真实的场景。</p>

<p>创作者写了一份契约，其中一行是：</p>

<pre><code class="language-meph">【锚点】
- 核心信念 &quot;力量就是一切&quot;
</code></pre>

<p>注意，<code>- 核心信念 &quot;力量就是一切&quot;</code> 缺少了冒号（正确写法是 <code>- 核心信念: &quot;力量就是一切&quot;</code>）。</p>

<p>如果我用通用解析器，报错大概是：</p>

<pre><code>unexpected token at position 42
</code></pre>

<p>创作者需要复制粘贴去数 position 42 是哪个字符。这个过程极其折磨。</p>

<p>而我的目标是让解析器报出这样的错误：</p>

<pre><code>第 2 行（区块「锚点」）：缺少 ':' 或 '：'
</code></pre>

<p>不需要数 position，不需要理解“token”是什么，直接告诉创作者：“第二行缺了一个冒号。”</p>

<p><strong>这个差异，就是手写解析器的全部理由。</strong></p>

<h2 id="三-两阶段设计">三、两阶段设计</h2>

<p>我把解析拆成两个阶段：</p>

<table>
<thead>
<tr>
<th>阶段</th>
<th>职责</th>
<th>输入</th>
<th>输出</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>区块扫描器</strong></td>
<td>切分区块，记录行号</td>
<td>原始文本</td>
<td><code>[]Block</code></td>
</tr>

<tr>
<td><strong>结构化解析器</strong>（Parser）</td>
<td>结构化解析</td>
<td><code>[]Block</code></td>
<td><code>*domain.Contract</code></td>
</tr>
</tbody>
</table>
<p><strong>关键设计</strong>：行号在扫描阶段就绑定到每一行，Parser 直接使用，无需计算偏移量。这样报错时永远是精确的绝对行号。</p>

<p>数据结构就两个：</p>

<pre><code class="language-go">type Line struct {
    Text   string
    Number int  // 绝对行号，从 1 开始
}

type Block struct {
    Title   string  // 如 &quot;角色名&quot;
    Content []Line  // 内容行，自带行号
    Line    int     // 标题行号
}
</code></pre>

<p>有了这个结构，报错可以这样写：</p>

<pre><code class="language-go">fmt.Errorf(&quot;第 %d 行（区块「%s」）：列表项必须以 '-' 开头&quot;,
    line.Number, blockName)
</code></pre>

<h2 id="四-区块扫描器-一个简单的状态机">四、区块扫描器：一个简单的状态机</h2>

<p>核心是一个逐行扫描的状态机。它只有两个状态：</p>

<ul>
<li><code>inBlock == false</code>：当前不在任何区块内</li>
<li><code>inBlock == true</code>：当前在区块内，正在收集内容</li>
</ul>

<pre><code class="language-go">func Lex(text string) ([]Block, error) {
    lines := strings.Split(text, &quot;\n&quot;)
    var blocks []Block
    var currentTitle string
    var currentContent []Line
    var currentLine int
    inBlock := false

    for i, rawLine := range lines {
        lineNumber := i + 1

        // 不在区块内时，空行跳过
        if !inBlock &amp;&amp; strings.TrimSpace(rawLine) == &quot;&quot; {
            continue
        }

        // 检查是否为区块标题
        if title, ok := isBlockTitle(rawLine); ok {
            if inBlock {
                // 保存当前区块
                blocks = append(blocks, Block{
                    Title:   currentTitle,
                    Content: currentContent,
                    Line:    currentLine,
                })
            }
            // 开始新区块
            currentTitle = title
            currentContent = []Line{}
            currentLine = lineNumber
            inBlock = true
            continue
        }

        // 非标题行：必须在区块内
        if !inBlock {
            return nil, fmt.Errorf(&quot;第 %d 行：内容出现在任何区块之外&quot;, lineNumber)
        }

        currentContent = append(currentContent, Line{
            Text:   rawLine,
            Number: lineNumber,
        })
    }

    if inBlock {
        blocks = append(blocks, Block{...})
    }

    if len(blocks) == 0 {
        return nil, fmt.Errorf(&quot;没有有效区块&quot;)
    }
    return blocks, nil
}
</code></pre>

<p><strong>两个关键设计：</strong></p>

<p><strong>1. 白名单前置</strong></p>

<p><code>isBlockTitle</code> 只认预定义的标题列表：</p>

<pre><code class="language-go">var knownBlocks = map[string]bool{
    &quot;角色名&quot;: true,
    &quot;锚点&quot;:  true,
    &quot;规则&quot;:  true,
    &quot;状态&quot;:  true,
    // ...
}
</code></pre>

<p>如果创作者写了 <code>【脚色名】</code>（错别字），扫描器不会把它当作区块开始——而是会得到一个指向该行的错误。具体报错信息取决于上下文：如果该行出现在任何区块之外，报错“内容出现在任何区块之外”；如果出现在某个区块内部，则会在该区块的解析中报错。无论哪种情况，行号都是精确的。</p>

<p><strong>2. 行号绑定</strong></p>

<p>每一行在存入时直接携带 <code>lineNumber</code>，永不偏移。这是实现精确报错的根基。</p>

<h2 id="五-parser-路由到不同解析函数">五、Parser：路由到不同解析函数</h2>

<p>扫描器输出 <code>[]Block</code> 后，Parser 根据 <code>Title</code> 路由到对应的解析函数：</p>

<pre><code class="language-go">func parseBlocks(blocks []Block) (*domain.Contract, error) {
    contract := &amp;domain.Contract{}
    for _, block := range blocks {
        switch block.Title {
        case &quot;角色名&quot;:
            contract.RoleName, err = parseRoleName(block.Content, block.Line)
        case &quot;锚点&quot;:
            contract.Anchor, err = parseKeyValuePairs(block.Content, block.Title)
        case &quot;规则&quot;:
            contract.Rules, err = parseRules(block.Content, block.Title)
        // ... 其他区块
        }
        if err != nil {
            return nil, err
        }
    }
    return contract, nil
}
</code></pre>

<p>每种区块的解析逻辑是独立的。以键值对列表为例——所有错误都带行号和区块名：</p>

<pre><code class="language-go">func parseKeyValuePairs(lines []Line, blockName string) ([]KeyValue, error) {
    for _, line := range lines {
        trimmed := strings.TrimSpace(line.Text)
        if trimmed == &quot;&quot; || strings.HasPrefix(trimmed, &quot;#&quot;) {
            continue
        }
        if !strings.HasPrefix(trimmed, &quot;-&quot;) {
            return nil, fmt.Errorf(&quot;第 %d 行（区块「%s」）：列表项必须以 '-' 开头&quot;,
                line.Number, blockName)
        }
        // ... 提取键值对
    }
    return result, nil
}
</code></pre>

<h2 id="六-错误信息对比">六、错误信息对比</h2>

<p>同一个错误，两种体验：</p>

<table>
<thead>
<tr>
<th>用户写错的内容</th>
<th>通用解析器报错</th>
<th><code>.meph</code> 报错</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>- 核心信念 &quot;力量&quot;</code>（缺冒号）</td>
<td><code>Unexpected token at position 42</code></td>
<td><code>第 2 行（区块「锚点」）：缺少 ':' 或 '：'</code></td>
</tr>

<tr>
<td><code>情绪: 暴怒</code>（缺 <code>-</code>）</td>
<td><code>invalid character looking for value</code></td>
<td><code>第 2 行（区块「状态」）：列表项必须以 '-' 开头</code></td>
</tr>

<tr>
<td><code>【脚色名】</code>（错别字）</td>
<td>不适用</td>
<td>指向该行的精确错误</td>
</tr>
</tbody>
</table>

<h2 id="七-代价">七、代价</h2>

<ul>
<li>写了约 400 行 Go 代码</li>
<li>需要为每个区块类型写独立的解析逻辑</li>
<li>新增区块要同步更新白名单</li>
</ul>

<p>但没有外部依赖，<code>go build</code> 一步完成。解析逻辑完全可控，可以随时调整。</p>

<h2 id="八-小结">八、小结</h2>

<p>区块扫描器和 Parser 完成了“文本到结构”的转化。现在 <code>【角色名】</code> 变成了 <code>contract.RoleName</code>，<code>【规则】</code> 变成了 <code>contract.Rules</code>。</p>

<p>但 <code>contract.Rules</code> 里的条件（如 <code>包含 &quot;攻击&quot;</code>）和动作（如 <code>注入 &quot;{角色名}的故乡是光之国&quot;</code>）仍然是字符串。下一篇我们要处理的是：<strong>规则的条件-动作表达式怎么拆解？插值语法 <code>{变量}</code> 怎么处理？</strong></p>

<p>这是解析层最后两块拼图。</p>

<blockquote>
<p>项目地址：<a href="https://github.com/yuelinghuashu/mephisto" target="_blank">https://github.com/yuelinghuashu/mephisto</a></p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[从格式设计到解析器实现，手写区块扫描器让错误报出"第 12 行"而非"position 246"——精确的行号绑定是手写解析器的核心价值。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[DSL]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:narrative-engine]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[构建大模型叙事引擎：快速上手——从零写出你的第一个契约]]></title>
      <link>https://moongate.top/docs/narrative-engine-your-first-contract</link>
      <guid isPermaLink="true">60b6f1fd-145e-4d31-b5b3-9ef1164170ce</guid>
      <pubDate>Mon, 20 Jul 2026 15:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p><strong>前置阅读</strong>：建议先读完第一篇<a href="./narrative-engine-from-freeform-to-constrained">《自由叙事到契约约束》</a>，了解 <code>.meph</code> 格式的设计动机。这篇不需要任何前置技术知识——只要你有终端和 Go 1.26+。</p>
</blockquote>

<p>前一篇我们讨论了为什么 <code>.meph</code> 比 JSON 和 YAML 更适合做叙事契约。但光说不练没有用。</p>

<p>这篇我们放下理论，从零开始写一份真实的契约文件，编译它，运行它。<strong>整个过程不超过 20 分钟</strong>——你可以亲自看到自己写的规则如何驱动 LLM 生成叙事。</p>

<h2 id="一-准备工作">一、准备工作</h2>

<p>你需要三样东西：</p>

<ol>
<li><strong>Go 1.26+</strong>：打开终端，运行 <code>go version</code> 确认版本</li>
<li><strong>一个终端</strong>：任何操作系统都可以</li>
<li><strong>（可选）一个 LLM API Key</strong>：DeepSeek、OpenAI 或 Ollama 均可——如果没有也不影响，引擎在没有 LLM 时也能工作</li>
</ol>

<p>如果你有 API Key，在项目根目录创建一个 <code>.env</code> 文件：</p>

<pre><code class="language-bash">MEPHISTO_CLIENT=openai
MEPHISTO_MODEL=deepseek-v4-flash
OPENAI_API_KEY=sk-你的密钥
</code></pre>

<h2 id="二-克隆并构建">二、克隆并构建</h2>

<pre><code class="language-bash">git clone https://github.com/yuelinghuashu/mephisto.git
cd mephisto
go build -o ./mephisto ./cmd/mephisto
</code></pre>

<p>如果一切顺利，你会看到一个名为 <code>mephisto</code> 的可执行文件。</p>

<h2 id="三-从零开始写契约">三、从零开始写契约</h2>

<p>接下来才是关键。我们创建一个新文件 <code>data/faust.meph</code>，从头开始填充内容。</p>

<h3 id="3-1-角色名">3.1 角色名</h3>

<p>契约的第一行是角色名。用 <code>【角色名】</code> 标记区块，下面直接写名字：</p>

<pre><code class="language-text">【角色名】
浮士德
</code></pre>

<p>就这么简单——不需要引号，不需要冒号，不需要任何标记。</p>

<h3 id="3-2-锚点">3.2 锚点</h3>

<p><code>【锚点】</code> 是角色的核心人格设定，用 <code>- 键: 值</code> 的格式列出：</p>

<pre><code class="language-text">【锚点】
- 核心信念：知识高于一切，我愿意为真理付出任何代价
- 欲望：体验一切人类能体验的事物
- 绝对禁忌：不会承认自己后悔
</code></pre>

<p>这些内容会被直接注入 LLM 的上下文，成为角色行为的基石。</p>

<h3 id="3-3-状态">3.3 状态</h3>

<p><code>【状态】</code> 是角色的动态变量，同样用键值对格式：</p>

<pre><code class="language-text">【状态】
- 灵魂完整度：100
- 情绪：永不满足
- 位置：书斋
</code></pre>

<p>状态值支持数字、布尔值、字符串三种类型。引擎在解析时自动推断类型——<code>&quot;100&quot;</code> 被解析为数字 <code>100</code>，<code>&quot;永不满足&quot;</code> 保持为字符串。这意味着你可以在规则中用 <code>状态.灵魂完整度 &gt; 50</code> 直接比较数字，无需额外类型转换。</p>

<p>在运行时，状态被存储为 <code>map[string]any</code>，以便快速读写。初始顺序从契约中继承，但运行时只按键名访问，不依赖顺序。</p>

<h3 id="3-4-世界观">3.4 世界观</h3>

<p><code>【世界观】</code> 是多行文本，直接写即可，保留换行：</p>

<pre><code class="language-text">【世界观】
故事发生在 16 世纪的德意志，这是一个神学与科学交织的时代。
大学中充斥着经院哲学的空洞争论，真正的新知被压制。
世界由上帝、天使、魔鬼与凡人共同构成，地狱与天堂是真实存在的维度。
梅菲斯特是地狱的使者，擅长以言语和契约诱捕人类灵魂。
灵魂交易在此世界是真实的契约——任何灵魂契约一旦签订，绝无撤回的可能。
</code></pre>

<h3 id="3-5-角色背景">3.5 角色背景</h3>

<p><code>【角色背景】</code> 与 <code>【世界观】</code> 一起定义了角色的详细背景。</p>

<pre><code class="language-text">【角色背景】
浮士德是一位学识渊博的学者，精通哲学、医学、法学和神学。
但他对人类知识的极限感到绝望——穷尽一生所学，仍无法触及世界的本质。
绝望中，他与梅菲斯特签订了契约：用灵魂换取在世上的无限体验。
</code></pre>

<h3 id="3-6-开局场景">3.6 开局场景</h3>

<p><code>【开局场景】</code> 定义了对话开始时的情境：</p>

<pre><code class="language-text">【开局场景】
深夜。书斋中烛火摇曳，桌上的书籍堆积成山。
浮士德站在窗边，望着窗外的月光。
桌角放着一份契约书，墨迹还未干透。
</code></pre>

<h3 id="3-7-规则-让角色活起来的关键">3.7 规则——让角色活起来的关键</h3>

<p><code>【规则】</code> 是引擎的核心。每条规则由三部分组成：<code>[规则名] if 条件 -&gt; 动作</code>。</p>

<pre><code class="language-text">【规则】
[新体验] if 包含 &quot;追求&quot; || 包含 &quot;想要&quot; || 包含 &quot;体验&quot; -&gt; 注入 &quot;{角色名}感到心中燃起新的渴望，没有什么能阻止他去亲自体验这一切&quot;
[梅菲斯特] if 包含 &quot;梅菲斯特&quot; || 包含 &quot;契约&quot; -&gt; 注入 &quot;梅菲斯特的声音在{角色名}耳边低语：'这就是你想要的吗？代价你可想好了。'&quot;
[灵魂代价] if 包含 &quot;代价&quot; || 包含 &quot;灵魂&quot; -&gt; 注入 &quot;{角色名}低头看着自己的双手，仿佛能看见什么东西正在一丝丝流逝&quot;
[永不满足] if 不包含 &quot;放弃&quot; &amp;&amp; 不包含 &quot;满足&quot; -&gt; 注入 &quot;{角色名}的眼中闪烁着永不熄灭的火焰，他还想要更多&quot;
</code></pre>

<p>规则的条件支持逻辑运算符（<code>&amp;&amp;</code>、<code>||</code>）、状态比较（<code>状态.键 &gt; 值</code>）、以及骰子表达式（<code>roll(1d100)</code>）。动作最常用的是 <code>注入</code>——将消息追加到记忆，由 LLM 自然融入后续叙事。</p>

<h3 id="3-8-验证">3.8 验证</h3>

<p>保存文件后，先验证解析是否正确：</p>

<pre><code class="language-bash">./mephisto parse data/faust.meph
</code></pre>

<p>你会看到类似这样的 JSON 输出：</p>

<pre><code class="language-json">{
  &quot;role_name&quot;: &quot;浮士德&quot;,
  &quot;anchor&quot;: [...],
  &quot;state&quot;: [...],
  &quot;rules&quot;: [...]
}
</code></pre>

<p>如果解析失败，错误信息会精确告诉你哪一行出了问题——比如&rdquo;第 X 行（区块「锚点」）：缺少 &lsquo;:&rsquo; 或 &lsquo;：&rsquo;&ldquo;。</p>

<h2 id="四-第一次对话-无-llm-模式">四、第一次对话（无 LLM 模式）</h2>

<p>即使没有配置 LLM，引擎也能运行：</p>

<pre><code class="language-bash">./mephisto run data/faust.meph
</code></pre>

<p>你会看到欢迎信息，然后进入对话模式。输入 &ldquo;我想要体验爱情&rdquo;：</p>

<pre><code class="language-text">命运 &gt; 我想要体验爱情
浮士德 沉默地注视着命运。
</code></pre>

<p>因为没有 LLM，引擎返回了默认响应。但规则其实已经匹配了——输入了&rdquo;我想要&rdquo;，条件 <code>包含 &quot;想要&quot;</code> 为 true，规则 [新体验] 触发，注入被写入记忆。</p>

<p>输入 <code>/state</code> 查看当前状态：</p>

<pre><code class="language-text">当前状态：
  灵魂完整度: 100
  情绪: 永不满足
  位置: 书斋
</code></pre>

<p>输入 <code>/history</code> 查看对话历史——你输入的&rdquo;我想要体验爱情&rdquo;已经记录为命运指引。</p>

<p><strong>这就是引擎在无 LLM 下的工作方式</strong>：规则匹配、注入、状态管理——全部正常运转。LLM 只是最后的&rdquo;叙事输出层&rdquo;。</p>

<h2 id="五-打开-llm-世界">五、打开 LLM 世界</h2>

<p>现在加上 API Key，重新运行：</p>

<pre><code class="language-bash">./mephisto run data/faust.meph -debug
</code></pre>

<p>你会看到同样的欢迎信息，但这次带上了 <code>-debug</code> 参数。LLM 的配置信息会先显示出来。</p>

<p>然后在提示符后输入：</p>

<pre><code class="language-text">命运 &gt; 你想要获取超越人类认知的知识
</code></pre>

<p>终端中出现了调试输出，然后是——梅菲斯特从阴影中走了出来：</p>

<pre><code class="language-text">🔍 规则调试模式
----------------------------------------
📌 检查规则 [新体验] (行 26)
   条件: 包含 &quot;追求&quot; || 包含 &quot;想要&quot; || 包含 &quot;体验&quot;
   结果: true
   ✅ 触发 → 注入 &quot;{角色名}感到心中燃起新的渴望，没有...

　　书斋里的烛火摇曳，将满墙的羊皮卷和典籍映照出深浅不一的阴影。浮士德枯坐在堆满手稿的书桌前，指尖摩挲着一本破旧星象书的封面，目光却穿过窗棂，望向一片漆黑的夜空。他低声自语，声音沙哑得几乎被风吹散：&quot;追寻了一生，终究连一扇门也未曾推开。&quot;

　　这时，身后的书架间传来一声极轻的响动，像老鼠啃咬木屑，又像一声压抑的嗤笑。浮士德并未回头，只是冷冷问道：&quot;又是你吗，瓦格纳？夜已经深了，不必再来送热汤。&quot;

　　脚步声却很轻，轻得不像那个笨拙的弟子。一个低沉的、带着金属质感的声音从暗处响起：&quot;瓦格纳只配为你添柴烧水，我带来的，是另一种暖意。&quot;说话者从阴影中缓步走出——他穿着华丽的猩红长袍，面容清瘦，嘴角挂着似笑非笑的弧度，手中把玩着一枚古铜色的戒指。他站定在书桌前，微微倾身，目光直刺浮士德的双瞳：&quot;你方才说，穷尽一生也推不开那扇门。可你有没有想过，门根本不是用来推的？&quot;

　　浮士德缓缓抬起头，盯住这位不速之客。他的手指按住那本星象书，沉声道：&quot;你是何人？未经允准，擅入我的书斋。&quot;

　　那人轻轻一笑，将戒指在烛光下转了转，戒指竟投出一片扭曲的影子，仿佛是某种无法言说的符文。&quot;我是你所有问题的答案，也是你所有渴望的代价。&quot;他伸出一只手，掌心向上，五指张开，掌纹中隐约流转着暗红色的光，&quot;我可以让你看见星辰背后的纹路，可以让你听见创世之初的旋律，可以让你触碰法则本身。只要你允许我带走一件微不足道的东西。&quot;

　　浮士德站起身，衣袖拂过桌面上散落的草稿纸，那些写满公式和推演的纸张飘落一地。他盯着那只递来的手，沉默了许久，才开口道：&quot;你要什么？&quot;

　　那人弯起嘴角，声音轻柔得像羽毛划过刀锋：&quot;你的灵魂。不过请放心，那东西你平日里也用不上——它既不能帮你解开方程，也不能让你飞上苍穹。你留着它，不过是让日渐腐朽的肉体多一块赘肉罢了。&quot;他收回手，转而从怀中取出一卷漆黑的羊皮纸，摊开在桌面上。纸面上没有字迹，只有一片深不见底的暗色，仿佛能吞噬周围的光线。&quot;签下它，我便立即兑现一切。&quot;

　　浮士德的呼吸变得急促，他低头看着那片漆黑，又抬头看向那人的眼睛——那双眼睛里倒映着无数星辰的陨落与诞生。他终于伸出手，指尖触到羊皮纸的瞬间，一股凉意顺着指骨爬上肩头。他没有再犹豫，接过那人递来的羽毛笔，笔尖刺破了自己的拇指，带着血珠落向纸面。

　　就在此时，书斋的门被推开一道缝，瓦格纳捧着一盏昏黄的油灯探进头来。他看到房中多了一个陌生人，又看到老师指尖渗血的姿态，面色顿时发白，颤抖着喊道：&quot;老师！您在做什么？此人是何时进来的？&quot;

　　浮士德没有停下动作，血字已在羊皮纸上成型。他头也不回地说：&quot;瓦格纳，关上门，今夜你将见证一位学者的夙愿。&quot;话音未落，羊皮纸上的暗色开始涌动，如同一片无星之夜在室内铺展开来，而那位红衣人的笑声，在书卷间回荡不绝。
</code></pre>

<p>这就是你写的 30 行契约生成的叙事。</p>

<hr>

<p><strong>试试改一个数字。</strong></p>

<p>把 <code>【状态】</code> 中的灵魂完整度从 <code>100</code> 改为 <code>50</code>，保存文件，重新运行，输入同样的话：</p>

<pre><code class="language-text">命运 &gt; 你想要获取超越人类认知的知识
</code></pre>

<p>同样的规则，同样的输入，只差一个数字，结果却截然不同：</p>

<pre><code class="language-text">　　书斋的烛火在深夜中摇曳不定，投下巨大的阴影，仿佛活物般在书架上爬行。浮士德瘫坐在堆满古籍的书桌后，指间夹着一根鹅毛笔，墨水早已干涸在笔尖。他的目光空洞地扫过那些拉丁文、希腊文与希伯来文的卷轴——这些曾经让他心醉神迷的符号，如今像一堆干枯的昆虫尸体般毫无意义。

　　“四十年。”他的声音沙哑得几乎听不清，“四十年钻研，我连第一重天的边缘都未曾触碰。”

　　窗外传来低沉的夜鸟鸣叫，像是某个东西在回应他的绝望。就在这时，书房的门无声无息地打开了，走进来的是他的老仆人瓦格纳，手里端着一碗热气腾腾的草药汤。瓦格纳小心翼翼地绕过地上堆叠的羊皮纸，将汤碗放在桌角，颇有些忧虑地开了口：“老爷，您已经三天没有进食了。帕拉塞尔苏斯的笔记您看了一整夜，眼睛血丝密布，再这样熬下去，怕是……”

　　“怕是什么？怕我这副躯体先于我的渴望腐烂？”浮士德猛地抬起头，眼中闪烁着某种近乎疯狂的光芒，“瓦格纳，你钻研学问多年，告诉我，你可曾见过真理的模样？”

　　瓦格纳被他盯得后退一步，低声答道：“老爷，仆以为，真理是上帝的事。”

　　“上帝的事。”浮士德重复着这几个字，忽然大笑起来，笑声中满是苦涩，“正是如此，正是如此。我们把一切都推给上帝，用神学的栏杆把自己圈在安全的猪圈里，啃食着神父们丢下来的干面包，还感激涕零！”他站起身，带倒了身后的椅子，烛火被他袍角带起的风压得几乎熄灭。“我受够了这些文字、这些符号、这些由人写出来骗人的东西。我需要真正的知识——那古老者、原初者才配拥有的知识。”

　　瓦格纳惊恐地看着他，双手微微发抖：“老爷，您在说什么？这样的念头是亵渎……”

　　“亵渎？”浮士德走到窗前，一把推开窗户，深夜的寒风呼啸着灌进来，吹得他灰色的长发和长袍疯狂翻卷，“若求真知即是亵渎，那就让这场亵渎来得更彻底些。”他回身，目光如燃烧的煤炭般炽热，“瓦格纳，你走吧。今夜我要独自一人。”

　　瓦格纳张了张嘴，最终还是低下头，飞快地退了出去，木门在他身后重重合上。浮士德独自站在打开的窗前，仰望头顶那片缀满星斗却沉默不语的夜空，喃喃道：“你们站在那里已有亿万年，难道就没有一句想对我这个区区凡人说的话吗？”

　　话音未落，书桌上的烛火猛地窜高，化作一片幽蓝色的火焰。火焰中央，一个声音低沉而优雅地响了起来——不是从空气中传来，而是仿佛直接在浮士德的颅骨里回荡。

　　“你终于愿意听了，浮士德先生。”那个声音带着笑意，“那么，就请允许我做一下自我介绍。”
</code></pre>

<p><strong>注意对比</strong>：灵魂完整度 100 时，浮士德是“枯坐的学者”，面对梅菲斯特的出场保持着冷静和犹疑——“你是谁？未经允准擅入我的书斋。”而灵魂完整度 50 时，他变成了“自焚边缘的疯狂求知者”，对瓦格纳怒吼，将知识追求定义为亵渎，连梅菲斯特出场的方式都更加激烈——不是从阴影中走出，而是烛火窜升、声音直接在颅骨内回荡。</p>

<p>状态值变了，叙事质感也跟着变了。<strong>这就是状态驱动的力量。</strong></p>

<hr>

<p>注意看第一次输出中的几个元素：</p>

<ul>
<li><strong>浮士德的学者身份和求知欲</strong>——来自 <code>【锚点】</code> 中的&rdquo;知识高于一切&rdquo;和 <code>【世界观】</code> 中&rdquo;穷尽一生也无法触及本质&rdquo;的设定。LLM 忠实地继承了这些特质。</li>
<li><strong>梅菲斯特的出现</strong>——由规则 [梅菲斯特] 在之前的对话中注入的记忆触发。你输入了&rdquo;想要获取超越人类认知的知识&rdquo;，规则条件 <code>包含 &quot;想要&quot;</code> 匹配，触发了 [新体验] 规则，注入的记忆为 LLM 提供了&rdquo;浮士德心中燃起新渴望&rdquo;的上下文。</li>
<li><strong>&ldquo;代价&rdquo;和&rdquo;灵魂&rdquo;的回应</strong>——LLM 在叙事中自然引入了&rdquo;代价&rdquo;这个关键词，触发了规则 [灵魂代价]——即使你没有在输入中直接说出这两个词。</li>
<li><strong>&ldquo;永不满足&rdquo;的底色</strong>——规则 [永不满足] 的条件是 <code>不包含 &quot;放弃&quot; &amp;&amp; 不包含 &quot;满足&quot;</code>，它几乎在每一轮都会触发，持续注入&rdquo;浮士德还想要更多&rdquo;的信息，让你写下的不只是单次对话，而是贯穿始终的角色气质。</li>
</ul>

<p><strong>每一行规则都在发挥作用。</strong> 没有一个多余。</p>

<h2 id="六-试试改点什么">六、试试改点什么</h2>

<p>现在你已经看到引擎的效果，可以动手修改看看变化：</p>

<h3 id="修改状态值">修改状态值</h3>

<pre><code class="language-text">- 灵魂完整度：50
</code></pre>

<p>你刚才已经看到了结果。试试改成 <code>10</code> 或 <code>0</code>，看看浮士德会变成什么样子。</p>

<h3 id="加一条骰子规则">加一条骰子规则</h3>

<pre><code class="language-text">[命运的眷顾] if 包含 &quot;追求&quot; &amp;&amp; roll(1d100) &gt;= 80 -&gt; 注入 &quot;命运似乎站在{角色名}这边，事情比预想中顺利&quot;
</code></pre>

<p><code>roll(1d100) &gt;= 80</code> 的意思是：掷一个 100 面骰，结果 &gt;= 80 时才触发。20% 的成功率，不是每次&rdquo;追求&rdquo;都会幸运——这为故事注入了真正的随机性。</p>

<h3 id="跑两轮对话-看子版存档">跑两轮对话，看子版存档</h3>

<p>运行两次对话后，用文本编辑器打开 <code>data/faust_child.meph</code>：</p>

<pre><code class="language-text">【状态】
- 灵魂完整度：100
- 情绪：永不满足

【记忆】
- 浮士德感到心中燃起新的渴望...
- 梅菲斯特的声音在他耳边低语...

【历史】
- fate: 我想要体验爱情
- assistant: ...
</code></pre>

<p>这就是引擎的 Mother-Child 存档机制：母版 <code>faust.meph</code> 是只读的静态契约，子版 <code>faust_child.meph</code> 是包含运行时状态、记忆和历史的动态快照。每次对话结束后自动保存。</p>

<h2 id="七-小结">七、小结</h2>

<p>到这里你完成了三件事：</p>

<ol>
<li><strong>写了一份完整的契约文件</strong>——30 行，覆盖了角色名、锚点、状态、世界观、规则等核心区块</li>
<li><strong>用解析器验证了它的结构</strong>——<code>parse</code> 命令精确告诉你内容是否正确</li>
<li><strong>运行引擎看到了角色&rdquo;活&rdquo;过来</strong>——即使没有 LLM，规则也在运行；有了 LLM，你写下的每条规则都在塑造叙事方向</li>
</ol>

<p>那 30 行是你和引擎之间的契约。引擎确保 LLM 遵守它。</p>

<hr>

<p>下篇文章将深入引擎内部，回答一个关键问题：<strong>区块扫描器是如何精确识别 <code>【角色名】</code> 和 <code>【规则】</code> 的？错误信息为什么能精确报出&rdquo;第 12 行（区块「锚点」）：缺少 &lsquo;:&rsquo; 或 &lsquo;：&rsquo;&ldquo;——而不是 <code>unexpected token at position 246</code>？</strong></p>

<p>答案是手写区块扫描器——一个逐行扫描、精确绑定行号、白名单前置的轻量级 Lexer。我们下一篇见。</p>

<blockquote>
<p>项目地址：<a href="https://github.com/yuelinghuashu/mephisto" target="_blank">https://github.com/yuelinghuashu/mephisto</a></p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[从零开始写一份 .meph 契约文件，编译、运行，让 LLM 遵守你写下的规则生成叙事。无需前置知识，快速完整体验。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[LLM]]></category>
      <category><![CDATA[DSL]]></category>
      <dc:subject>P1</dc:subject>
      <dc:relation><![CDATA[series:narrative-engine]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[构建大模型叙事引擎：从自由叙事到契约约束]]></title>
      <link>https://moongate.top/docs/narrative-engine-from-freeform-to-constrained</link>
      <guid isPermaLink="true">2a621c9e-8825-4528-84bc-c0cd8a4a9e40</guid>
      <pubDate>Mon, 20 Jul 2026 13:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<p>我用大模型做叙事的第一天，就遇到了一个问题。</p>

<p>我定义了一个角色：贝利亚奥特曼，性格狂傲、蔑视光之国、崇尚力量。前三轮对话表现完美——每次回答都带着那种令人满意的反派质感。第五轮开始，他开始跟我讲人生哲理，像个温和的哲学家。第十轮，他自称“光之国的守护者”，完全忘记了自己是谁。</p>

<p>这不是模型不够好。这是“自由生成”的固有缺陷——大模型没有“必须遵守规则”的内置机制。你可以在系统提示里写“你是一个狂傲的反派”，但模型对“狂傲”的理解是概率性的，会在长上下文中逐渐衰减，被用户的输入、模型的输出、甚至 token 顺序漂移所覆盖。</p>

<p>我需要一种方式，把规则写下来，让模型必须遵循。于是我开始写 Mephisto。</p>

<p>但在写引擎代码之前，我先要解决一个更基础的问题：<strong>这些规则应该用什么格式来写？</strong></p>

<p>它需要承载的内容包括角色名（单行）、世界观（多行）、状态变量（键值对）、行为规则（条件-动作）。每种内容的结构和解析方式都不同，这个格式直接决定了创作者是把时间花在写故事上，还是花在调试语法上。</p>

<p>这篇文章记录了我在这件事上的取舍和最终设计。</p>

<hr>

<h2 id="二-叙事引擎的第一个问题-规则怎么表达">二、叙事引擎的第一个问题：规则怎么表达？</h2>

<p>一个叙事引擎首先要解决的，是<strong>规则表达层</strong>的问题——创作者怎么告诉引擎&rdquo;这个角色会做什么、不会做什么&rdquo;。</p>

<p>通行的做法有三种：</p>

<ul>
<li><strong>通用结构化格式</strong>（JSON/YAML）：程序友好，但对创作者残忍——转义地狱、报错不可读</li>
<li><strong>自定义领域格式</strong>（如 .meph、Ink）：可以在两者之间找到平衡，但解析器必须手写</li>
<li><strong>纯文本</strong>：对创作者最友好，但程序几乎无法精确解析</li>
</ul>

<p>每种都有代价。我将一一对比，最后解释为什么选择了自定义格式。</p>

<hr>

<h2 id="三-先看目标-一份真实的-meph-契约">三、先看目标：一份真实的 .meph 契约</h2>

<p>在讨论任何设计之前，先看成品。下面是一份完整的 <code>.meph</code> 契约文件，来自项目中的 <code>data/sample.meph</code>：</p>

<pre><code class="language-meph">【角色名】
贝利亚奥特曼

【锚点】
- 核心信念：力量就是一切
- 说话风格：狂傲、嘲讽、不容置疑

【世界观】
光之国是宇宙中最强大的文明，也是贝利亚的故乡。
但他早已被驱逐，如今他带着对光之国的憎恨归来。

【状态】
- 堕落指数：50
- 情绪：暴怒
- 位置：宇宙空间站

【规则】
[攻击] if 包含 &quot;攻击&quot; -&gt; 注入 &quot;贝利亚发动了猛烈的攻击&quot;
[防御] if 包含 &quot;防御&quot; || 包含 &quot;防守&quot; -&gt; 注入 &quot;贝利亚摆出了防御姿态&quot;
[光之国] if 包含 &quot;光之国&quot; -&gt; 注入 &quot;{角色名}的故乡是光之国，也是他最大的仇恨来源&quot;
[高堕落] if 状态.堕落指数 &gt; 80 -&gt; 状态.情绪 = &quot;癫狂&quot;
</code></pre>

<p>这就是一份契约的全部。创作者看到的不是 <code>{</code> 和 <code>}</code>，不是转义引号，不是缩进层级，而是：</p>

<ul>
<li><code>【角色名】</code> 下面直接写名字</li>
<li><code>【锚点】</code> 下面用 <code>- 键: 值</code> 列出核心人格</li>
<li><code>【规则】</code> 下面用 <code>[名] if 条件 -&gt; 动作</code> 定义行为</li>
</ul>

<p>注意规则中的 <code>{角色名}</code>——这是<strong>插值占位符</strong>，解析器原样存储，运行时由引擎替换为当前角色名。这保证了多分支场景下各分支独立演化，互不干扰。</p>

<p>看起来像文档。但程序可以精确地解析它，并且当它出错时，能告诉创作者<strong>“第 12 行（区块「状态」）：列表项必须以 &lsquo;-&rsquo; 开头”</strong>，而不是 <code>unexpected token at position 246</code>。</p>

<p>接下来的问题就是：怎么走到这一步的？</p>

<h2 id="四-json-对程序友好-对人残忍">四、JSON：对程序友好，对人残忍</h2>

<p>JSON 是最自然的第一选择。结构清晰、解析简单、任何语言都有现成库。我最初也确实用 JSON 写过原型，但很快发现了一个致命问题。</p>

<p>上面那条规则在 JSON 里长这样：</p>

<pre><code class="language-json">{
  &quot;rules&quot;: [
    {
      &quot;name&quot;: &quot;攻击&quot;,
      &quot;condition&quot;: &quot;包含 \&quot;攻击\&quot;&quot;,
      &quot;action&quot;: &quot;注入 \&quot;贝利亚发动了猛烈的攻击\&quot;&quot;
    }
  ]
}
</code></pre>

<p>创作者的意图是：</p>

<pre><code>包含 &quot;攻击&quot;
</code></pre>

<p>在 JSON 里必须写成：</p>

<pre><code>&quot;condition&quot;: &quot;包含 \&quot;攻击\&quot;&quot;
</code></pre>

<p>问题不在于语法“有多难”，而在于<strong>心智切换成本</strong>。创作者在书写时不能直接表达自己的意图，必须时刻思考“我是在写 JSON 还是在写规则”。在大型契约中，这种成本是持续累积的——你阅读的不是内容，而是在不断核对“这一行有多少个反斜杠”。</p>

<p>更糟糕的是错误信息。一个常见的错误：在 <code>&quot;rules&quot;</code> 数组的最后一个元素后面多加了一个逗号。JSON 解析器报错：</p>

<pre><code>Unexpected token } in JSON at position 246
</code></pre>

<p>创作者需要复制粘贴去数“position 246”在哪。这个体验对于非技术用户几乎是毁灭性的。</p>

<p><strong>JSON 对程序友好，但对人不友好。</strong> 而这份文件的作者是创作者，不是程序员。</p>

<h2 id="五-yaml-简单的幻觉">五、YAML：简单的幻觉</h2>

<p>YAML 看起来解决了 JSON 的可读性问题：</p>

<pre><code class="language-yaml">role_name: 贝利亚奥特曼
rules:
  - name: 攻击
    condition: 包含 &quot;攻击&quot;
    action: 注入 &quot;贝利亚发动了猛烈的攻击&quot;
</code></pre>

<p>缩进取代了括号和逗号，确实更像“写文档”了。</p>

<p>但 YAML 的问题比 JSON 更隐蔽。它不是“语法错误”的问题，而是“逻辑错误”的问题——文件能读，但行为完全不对。</p>

<p>想象一下：你在 YAML 中定义一个多行文本块（比如世界观），用 <code>|</code> 标记：</p>

<pre><code class="language-yaml">worldview: |
  光之国是宇宙中最强大的文明。
  贝利亚被驱逐后，一直在寻找复仇的机会。
</code></pre>

<p>然后在下面继续定义一个列表。缩进稍微偏差一个空格，解析器就可能把多行文本块的后续行当作列表的子元素。结果就是：<strong>世界观内容被截断，列表结构被破坏，没有任何报错，引擎运行时产生非预期的行为。</strong></p>

<p>更隐蔽的是，中文全角空格和英文半角空格在视觉上几乎无法分辨。创作者在编辑器中敲了一个全角空格（<code>　</code>）而非半角空格（），YAML 解析器不会把它当作缩进的一部分，而是抛出语法错误，或者诡异地将整段文本识别为键名。没有语法高亮的情况下，这种错误几乎无法肉眼排查。</p>

<p>创作者不是在阅读内容，而是在不断核对“这一行到底缩进了几个空格”。对于上百行的契约文件，这种认知负担是持续累积的。</p>

<p><strong>YAML 的“简单”是一个错觉。</strong> 它对人友好，但对程序来说，它的规则比 JSON 更复杂、更难以预测。</p>

<h2 id="六-纯文本-自由但模糊">六、纯文本：自由但模糊</h2>

<p>既然结构化格式都有问题，那能不能干脆不用结构，直接用纯文本写？</p>

<p>比如这样：</p>

<pre><code>角色名是贝利亚奥特曼。
世界观是光之国。
规则是如果用户提到攻击，就执行攻击。
</code></pre>

<p>对创作者来说，这是最自然的方式——没有任何学习成本。</p>

<p>但问题是：程序看不懂。它不知道“角色名是贝利亚奥特曼”这句话里的“是”是声明还是叙述，不知道“世界观是光之国”和下一句“规则是……”之间是什么关系。纯文本的自然语言对人类来说是清晰的，但对程序来说，它是模糊的、歧义的、无法精确解析的。</p>

<p>如果我用纯文本，就需要设计一套隐式约定——比如靠关键词匹配来识别区块，靠换行来区分条目。这比显式语法更难保证正确性。</p>

<p><strong>纯文本对创作者最友好，但对程序几乎不友好。</strong></p>

<h2 id="七-三个方案的对比">七、三个方案的对比</h2>

<table>
<thead>
<tr>
<th>格式</th>
<th align="center">对创作者友好</th>
<th align="center">对程序友好</th>
<th>核心问题</th>
</tr>
</thead>

<tbody>
<tr>
<td>JSON</td>
<td align="center">❌</td>
<td align="center">✅</td>
<td>转义地狱、报错信息不可读</td>
</tr>

<tr>
<td>YAML</td>
<td align="center">⚠️</td>
<td align="center">✅</td>
<td>缩进敏感，多行文本与列表易混淆</td>
</tr>

<tr>
<td>纯文本</td>
<td align="center">✅</td>
<td align="center">❌</td>
<td>结构模糊，程序无法精确解析</td>
</tr>
</tbody>
</table>
<p>我需要一种格式：<strong>写起来像文档，读起来有结构。</strong> 它必须同时满足两个条件：</p>

<ol>
<li>创作者可以自然地书写，不需要学习 JSON 转义或 YAML 缩进规则</li>
<li>程序可以精确地解析，并且报错时能告诉创作者具体位置和原因</li>
</ol>

<p>这意味着我无法使用任何现成的通用格式——我需要设计一种专门针对“叙事契约”这个场景的格式。</p>

<h2 id="八-meph-的设计原则">八、.meph 的设计原则</h2>

<p>回到开篇那份 <code>.meph</code> 契约。它的设计基于三条原则：</p>

<p><strong>1. 用人类语言做边界，消除括号恐惧</strong></p>

<p>创作者看到的不是 <code>{</code> 和 <code>}</code>，而是 <code>【角色名】</code>。中文书名号对中文创作者来说比花括号自然得多。<code>【角色名】</code> 本身说明了区块的内容是什么，不需要额外注释。</p>

<p>更重要的是，区块标题被限定在白名单内（<code>角色名</code>、<code>锚点</code>、<code>规则</code>、<code>状态</code> 等）。如果创作者写了 <code>【脚色名】</code>（错别字），解析器不会把它当作区块开始——创作者会得到一个指向该行的错误，具体信息取决于上下文，但行号是精确的。</p>

<p><strong>2. 区分“语义区块”而非“数据结构”</strong></p>

<p>在 JSON 中，创作者需要自己决定用对象还是数组，这属于实现细节。在 <code>.meph</code> 中，创作者只需要知道“这是一个列表”或“这是一段话”。“角色名是单行文本”和“规则是列表”由解析器根据区块名识别，不由创作者声明。</p>

<p><strong>3. 语法贴近自然逻辑</strong></p>

<p>规则采用 <code>[规则名] if 条件 -&gt; 动作</code> 的直观写法。条件中的逻辑运算符用 <code>包含</code>、<code>状态.键 &gt; 值</code> 这类可读性强的表达，而不是纯符号。</p>

<h2 id="九-代价">九、代价</h2>

<p>这套设计不是没有代价的：</p>

<ul>
<li><strong>解析器需要手写</strong>：我不能用 <code>json.Unmarshal</code> 或 <code>yaml.Unmarshal</code>，需要自己写扫描器（Lexer）和解析器（Parser）</li>
<li><strong>需要维护白名单</strong>：新增区块时要同步更新 <code>knownBlocks</code> 列表</li>
<li><strong>需要文档</strong>：创作者需要学习这个格式的写法——虽然学习成本比 JSON 低，但毕竟需要学习</li>
<li><strong>工具链缺失</strong>：没有现成的语法高亮、格式化、校验工具</li>
</ul>

<p>但这个取舍的衡量标准很简单：<strong>这份文件的作者是谁？</strong></p>

<p>如果是程序员写、程序读，JSON 够用了。如果是创作者写、程序读，就需要一种“以人为中心”的格式。而叙事引擎的目标用户正是创作者——写故事的人。</p>

<p><strong>这个取舍，我认为是值得的。</strong></p>

<h2 id="十-小结">十、小结</h2>

<p>这篇文章回答了“用什么格式”这个问题。答案是 <code>.meph</code>——一种专门为叙事契约设计的、对创作者友好的文本格式。</p>

<p>但“设计”只解决了一半问题。下一篇，我们放下理论，先动手写一份真实的契约，看看它跑起来是什么样子。</p>

<blockquote>
<p>项目地址：<a href="https://github.com/yuelinghuashu/mephisto" target="_blank">https://github.com/yuelinghuashu/mephisto</a></p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[为什么 JSON 和 YAML 都不适合做叙事引擎的配置文件？本文从大模型叙事的一致性问题出发，解释了 .meph 格式的设计取舍——用解析器的复杂度换创作者体验。]]></description>
      <category><![CDATA[DSL]]></category>
      <category><![CDATA[JSON]]></category>
      <category><![CDATA[LLM]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:narrative-engine]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[从零实现词法分析器（三）：让指针动起来，启动 Lexer 传送带]]></title>
      <link>https://moongate.top/docs/from-text-to-token-go-lexer-part-3</link>
      <guid isPermaLink="true">7d80bcad-0ef8-4a03-a22c-11a19494ce5a</guid>
      <pubDate>Mon, 13 Jul 2026 22:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>前两篇我们准备好了 Token 组件和统一符号映射表。这一篇，我们将正式实现整个词法分析的核心引擎——<strong>Lexer（词法分析器）</strong>，让程序能够真正“阅读”文本，并将它切分成一条源源不断的 Token 流。</p>
</blockquote>

<h2 id="一-核心思想-lexer-就像一条传送带">一、核心思想：Lexer 就像一条传送带</h2>

<p>在动手写代码前，我们先要在脑海里建立一个物理模型。词法分析器（Lexer）的运作方式，极其类似于工厂里的<strong>扫描传送带</strong>：</p>

<ol>
<li><strong>输入文本</strong>被平铺在传送带上，每个字符（<code>rune</code>）占据一个格子。</li>
<li>有一个<strong>光标（指针）</strong>，指向当前正在观察的字符。</li>
<li>Lexer 的工作就是：</li>
</ol>

<ul>
<li>瞧一眼当前光标指着的字符是什么（<code>peek</code>）。</li>
<li>如果是一个符号（比如左括号 <code>【</code>），把它打包成 Token，然后光标往后挪一格（<code>advance</code>）。</li>
<li>如果是一串连续的普通文字（比如 <code>贝利亚奥特曼</code>），就把它们拼在一起打包成一个 <code>TEXT</code> Token，直到撞上符号再停下来。</li>
</ul>

<h2 id="二-定义-lexer-结构体">二、定义 Lexer 结构体</h2>

<p>在 <code>internal/parser/</code> 目录下创建 <code>lexer.go</code>。我们需要三个基础字段来维护这条“传送带”的状态：</p>

<pre><code class="language-go">package parser

// Lexer 词法分析器结构体
type Lexer struct {
	input    []rune // 待扫描的完整文本（转为 rune 切片，完美支持中文与 Emoji）
	position int    // 当前扫描到的字符索引位置
	line     int    // 当前行号（从 1 开始，用于后续报错提示）
}

// NewLexer 创建一个词法分析器实例
func NewLexer(input string) *Lexer {
	return &amp;Lexer{
		input:    []rune(input), // 将 string 转为 []rune 存储
		position: 0,
		line:     1,
	}
}
</code></pre>

<h3 id="为什么用-rune-input-提前转换">为什么用 <code>[]rune(input)</code> 提前转换？</h3>

<p>我们在上一篇强调过，Go 的 <code>string</code> 底层是字节流（<code>byte</code>）。如果每次读取都去算字节，处理中文会非常痛苦。我们在初始化时直接将它转为 <code>[]rune</code> 数组，之后光标的 <code>position++</code> 移动的每一个单位，就<strong>稳稳当当地代表一个独立的中文字符、英文字母或 Emoji</strong>。</p>

<h2 id="三-传送带的三驾马车-基础辅助方法">三、传送带的三驾马车：基础辅助方法</h2>

<p>为了操纵这个光标，我们需要实现三个最基本的辅助方法：检查结束、瞅一眼、往前移。</p>

<pre><code class="language-go">// isEOF 检查光标是否已经走到了文件末尾 (End of File)
func (l *Lexer) isEOF() bool {
	return l.position &gt;= len(l.input)
}

// peek 瞅一眼当前位置的字符，但【绝不挪动光标】
// 如果已经到末尾，返回 0
func (l *Lexer) peek() rune {
	if l.isEOF() {
		return 0
	}
	return l.input[l.position]
}

// advance 消费当前字符：读取并返回它，同时【将光标向后移动一位】
// 特别注意：如果消费的是换行符 '\n'，顺手将行号 line++
func (l *Lexer) advance() rune {
	if l.isEOF() {
		return 0
	}
	ch := l.input[l.position]
	if ch == '\n' {
		l.line++
	}
	l.position++
	return ch
}
</code></pre>

<p>有了这三个基础方法，我们的光标就能安全、自由地在文本数组中穿梭了。</p>

<h2 id="四-跳过无意义的空白">四、跳过无意义的空白</h2>

<blockquote>
<p>在 <code>.meph</code> 文件中，用户可能会在括号两边打上空格或缩进，比如 <code>【角色名】</code>。</p>

<p>这些空格没有实际语义，Lexer 应该在识别下一个 Token 之前，默默把它们吞掉。</p>
</blockquote>

<p>因为在同一个包（package parser）内部，我们直接越过复杂的函数封装，利用 Go 原生的 map 匹配来跳过空白：</p>

<pre><code class="language-go">// skipWhitespace 跳过无意义的空白字符（空格、制表符、回车符）
// 注意：千万不能跳过换行符 '\n'，因为换行在我们的语法里代表“区块标题结束”
func (l *Lexer) skipWhitespace() {
	for !l.isEOF() {
		info, ok := symbolMap[l.peek()]
		// 如果在符号表里，且 Category 是 whitespace，就消费掉它
		if !ok || info.Category != &quot;whitespace&quot; {
			break
		}
		l.advance() // 光标无情后移
	}
}
</code></pre>

<h2 id="五-核心逻辑-实现-nexttoken-分发器">五、核心逻辑：实现 <code>NextToken()</code> 分发器</h2>

<p>它的职责是：跳过空白，盯着光标位置。如果是符号表里的符号，一律直接消费并返回；如果表里找不到，那它必然是普通文本！</p>

<pre><code class="language-go">// NextToken 获取下一个 Token
func (l *Lexer) NextToken() Token {
	// 1. 每次进来，先清洗掉前面的无意义空格
	l.skipWhitespace()

	// 2. 查看当前光标指着谁
	ch := l.peek()

	// 3. 边界处理：如果到文件末尾了，吐出 EOF 哨兵 Token
	if ch == 0 {
		return Token{Type: TOKEN_EOF, Literal: &quot;&quot;, Line: l.line}
	}

	// 4. 🚀 终极查表驱动：因为在同包内，直接匹配 symbolMap！
	if info, ok := symbolMap[ch]; ok {
		l.advance() // 是符号？光标前进，消费它！
		return Token{Type: info.TokenType, Literal: string(ch), Line: l.line}
	}

	// 5. 符号表里查不到？那它一定是普通文本文字（如 &quot;贝利亚奥特曼&quot;）
	return l.readText()
}
</code></pre>

<p>你看！得益于同包内直接查表的设计，整个分发核心干净得让人感动。<strong>这里没有任何复杂的条件分支，更去掉了不必要的套娃函数调用</strong>。无论未来你的语法扩充到有多少种符号，这个 <code>NextToken()</code> 的大框架都<strong>稳如磐石，永远不需要修改一行。</strong></p>

<h2 id="六-文本的贪婪读取-readtext">六、文本的贪婪读取：<code>readText()</code></h2>

<p><code>NextToken()</code> 的核心逻辑我们已经看完了。现在进入它调用的最后一个分支——<code>readText()</code>，看看普通文本是如何被一口气吞下的。</p>

<p>当字符不在符号表里时（比如碰到了中文汉字 <code>贝</code>），Lexer 应该开启“贪婪模式”：<strong>只要后面接下来的字符不是符号，就一路把它们全部吞下，拼成一个长字符串。</strong></p>

<p>有了上一篇建立的统一符号表，判定“什么时候该停下来”也变得不可思议的优雅：<strong>只要 <code>peek()</code> 到的字符能在 <code>symbolMap</code> 里匹配成功，就说明撞到了符号（比如换行符或括号），它天然就是分隔符，文本读取立刻停止！</strong></p>

<pre><code class="language-go">// readText 读取一段连续的普通文本
// 停止条件：遇到符号表中的任意符号（如换行符、括号、冒号）或文件结束
func (l *Lexer) readText() Token {
	// 记录起始位置
	start := l.position

	for !l.isEOF() {
		// 🌟 降维打击：直接查表！只要当前字符在符号表里，说明撞墙了，立刻停下
		_, ok := symbolMap[l.peek()]
		if ok {
			break
		}
		l.advance() // 否则，继续快乐地吞噬文字
	}

	// 利用切片，把这段光标走过的 rune 范围直接转成字符串字面量
	literal := string(l.input[start:l.position])
	return Token{Type: TOKEN_TEXT, Literal: literal, Line: l.line}
}
</code></pre>

<h2 id="七-大功告成-在-main-go-中验证成果">七、大功告成：在 <code>main.go</code> 中验证成果</h2>

<p>让我们把现在的 <code>main.go</code> 升级一下，用我们亲手写的 <code>Lexer</code> 去解析测试文件，看看它能不能顺利吐出我们要的零件流：</p>

<pre><code class="language-go">package main

import (
	&quot;fmt&quot;
	&quot;mephisto/internal/parser&quot;
	&quot;os&quot;
)

func main() {
	if len(os.Args) &lt; 2 {
		fmt.Println(&quot;用法: mephisto &lt;文件&gt;&quot;)
		os.Exit(1)
	}

	content, err := os.ReadFile(os.Args[1])
	if err != nil {
		fmt.Printf(&quot;读取文件失败: %v\n&quot;, err)
		os.Exit(1)
	}

	// 1. 初始化我们的传送带 Lexer
	l := parser.NewLexer(string(content))

	// 2. 循环驱动传送带，直到撞上 TOKEN_EOF
	fmt.Printf(&quot;%-5s | %-20s | %s\n&quot;, &quot;行号&quot;, &quot;类型&quot;, &quot;字面量&quot;)
	fmt.Println(&quot;------+----------------------+-----------&quot;)
	for {
		tok := l.NextToken()
		// 为了防止换行符 \n 导致终端实际换行破坏表格，打印时做个转换处理
		literal := tok.Literal
		if tok.Type == parser.TOKEN_NEWLINE {
			literal = &quot;\\n&quot;
		}

		fmt.Printf(&quot;%-5d | %-20s | %s\n&quot;, tok.Line, tok.Type, literal)

		if tok.Type == parser.TOKEN_EOF {
			break
		}
	}
}
</code></pre>

<p>再次运行我们的测试文件（<code>testdata/sample.meph</code>）：</p>

<pre><code class="language-bash">go run main.go testdata/sample.meph
</code></pre>

<p>终端将会输出一行极其漂亮、对齐完美、极具工业美感的词法流结果：</p>

<pre><code class="language-text">行号    | 类型                 | 字面量
------+----------------------+-----------
1     | LEFT_BRACKET         | 【
1     | TEXT                 | 角色名
1     | RIGHT_BRACKET        | 】
1     | NEWLINE              | \n
2     | TEXT                 | 贝利亚奥特曼
3     | EOF                  |
</code></pre>

<p>看！计算机通过我们写的 Lexer，成功把一串冰冷的、毫无结构的原始字节，变成了一个个生动的、自带行号和类型的结构化 Token 块！</p>

<h2 id="八-小结">八、小结</h2>

<p>到这一篇为止，我们的 <strong>Lexer 词法分析器已经完全体诞生了</strong>！</p>

<table>
<thead>
<tr>
<th>做了什么</th>
<th>为什么</th>
</tr>
</thead>

<tbody>
<tr>
<td>设计了光标状态机（<code>position</code>）</td>
<td>建立了多字节文本遍历的底层传送带模型</td>
</tr>

<tr>
<td>实现了纯查表驱动的 <code>NextToken()</code></td>
<td>贯彻了数据驱动思想，去掉了多余函数，让符号分发效率达到极致</td>
</tr>

<tr>
<td>实现了极其精简的 <code>readText()</code> 截断机制</td>
<td>只要 peek 字符在 <code>symbolMap</code> 中即自动作为边界，消灭了所有零散的判断逻辑</td>
</tr>
</tbody>
</table>
<p>至此，词法分析的战役完美结束。</p>
]]></content:encoded>
      <description><![CDATA[正式实现词法分析器的核心引擎，通过光标指针的移动与符号表查询，将文本字符串切分成结构化的 Token 流。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[Compiler]]></category>
      <dc:subject>P1</dc:subject>
      <dc:relation><![CDATA[series:lexer-from-scratch]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[从零实现词法分析器（二）：用一张表统一管理所有符号]]></title>
      <link>https://moongate.top/docs/from-text-to-token-go-lexer-part-2</link>
      <guid isPermaLink="true">ef2d72a9-756a-4c35-8ad5-53f698ad8986</guid>
      <pubDate>Mon, 13 Jul 2026 21:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>上一篇我们定义了 Token，但 Token 只是&rdquo;零件&rdquo;。在实现 Lexer 之前，还有一个问题要先解决——如何让 Lexer 能认识所有符号，而不需要每次新增符号都修改它的代码。</p>
</blockquote>

<h2 id="一-回顾-上一篇我们停在了哪里">一、回顾：上一篇我们停在了哪里</h2>

<p>上一篇我们定义了 Token：</p>

<pre><code class="language-go">package parser

type TokenType string

const (
	TOKEN_LEFT_BRACKET  TokenType = &quot;LEFT_BRACKET&quot;  // 【
	TOKEN_RIGHT_BRACKET TokenType = &quot;RIGHT_BRACKET&quot; // 】
	TOKEN_TEXT          TokenType = &quot;TEXT&quot;          // 普通文本
)

type Token struct {
	Type    TokenType
	Literal string
}
</code></pre>

<p>现在我们需要一个程序——<strong>Lexer（词法分析器）</strong>——来读取文本，不断产出这些 Token。</p>

<p>但在写 Lexer 之前，我们还需要做一件事：<strong>扩充 Token 类型，并建立一套让 Lexer 能识别所有符号的机制。</strong></p>

<h2 id="二-扩充-token-类型">二、扩充 Token 类型</h2>

<p>上一篇我们只定义了三种 Token 类型。但在一个完整的 <code>.meph</code> 文件中，除了 <code>【</code>、<code>】</code> 和普通文本，还有更多符号需要识别：</p>

<table>
<thead>
<tr>
<th>符号</th>
<th>用途</th>
<th>示例</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>：</code> / <code>:</code></td>
<td>冒号</td>
<td><code>好感度：78</code></td>
</tr>

<tr>
<td><code>-</code></td>
<td>列表标记</td>
<td><code>- 核心信念：&quot;力量就是一切&quot;</code></td>
</tr>

<tr>
<td><code>@</code></td>
<td>引用符号</td>
<td><code>@[世界观](worlds/eva.meph)</code></td>
</tr>

<tr>
<td><code>#</code></td>
<td>标签/注释</td>
<td><code># 硬约束</code></td>
</tr>
</tbody>
</table>
<p><code>parser/token.go</code> 现在的完整代码如下：</p>

<pre><code class="language-go">package parser

type TokenType string

const (
	// 语法符号
	TOKEN_LEFT_BRACKET  TokenType = &quot;LEFT_BRACKET&quot;  // 【
	TOKEN_RIGHT_BRACKET TokenType = &quot;RIGHT_BRACKET&quot; // 】
	TOKEN_COLON         TokenType = &quot;COLON&quot;         // ：
	TOKEN_HYPHEN        TokenType = &quot;HYPHEN&quot;        // -
	TOKEN_AT            TokenType = &quot;AT&quot;            // @
	TOKEN_HASH          TokenType = &quot;HASH&quot;          // #

	// 内容类型
	TOKEN_TEXT TokenType = &quot;TEXT&quot; // 普通文本

	// 控制标记
	TOKEN_NEWLINE TokenType = &quot;NEWLINE&quot; // 换行符
	TOKEN_EOF     TokenType = &quot;EOF&quot;     // 文件结束（哨兵）
)

type Token struct {
	Type    TokenType
	Literal string    // 符号的原始文本
	Line    int       // 当前行号（从 1 开始）
}
</code></pre>

<p><strong>注意：</strong> <code>NEWLINE</code> 和 <code>EOF</code> 虽然在 Lexer 实现中才会用到，但我们提前定义好，这样后续文章就不需要回头修改 <code>token.go</code> 了。</p>

<h2 id="三-问题-lexer-需要认识多种符号">三、问题：Lexer 需要认识多种符号</h2>

<p>Lexer 的工作是扫描文本，识别出每个符号是什么。</p>

<p>一个最直接的写法是：</p>

<pre><code class="language-go">switch ch {
case '【':
    // 返回 LEFT_BRACKET
case '】':
    // 返回 RIGHT_BRACKET
case '：':
    // 返回 COLON
case '-':
    // 返回 HYPHEN
// ... 越来越多的 case
}
</code></pre>

<p>这样写有三个问题：</p>

<table>
<thead>
<tr>
<th>问题</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>新增符号要改代码</strong></td>
<td>每次增加新的符号类型，都要修改 Lexer 的源码</td>
</tr>

<tr>
<td><strong>中英双语支持困难</strong></td>
<td>如果要同时支持 <code>【</code> 和 <code>[</code> 作为左括号，<code>case</code> 会越来越多</td>
</tr>

<tr>
<td><strong>判断逻辑分散</strong></td>
<td>符号的&rdquo;定义&rdquo;和&rdquo;判断&rdquo;混在一起，不容易维护</td>
</tr>
</tbody>
</table>
<p><strong>我们需要一种更好的方式：把&rdquo;符号是什么&rdquo;和&rdquo;怎么判断符号&rdquo;分开。</strong></p>

<h2 id="四-方案-用一张表统一管理所有符号">四、方案：用一张表统一管理所有符号</h2>

<p>核心思想很简单：<strong>把所有的符号集中在一张表里管理，Lexer 通过查表来判断。</strong></p>

<p>这张表里，每个符号记录两件事：</p>

<ol>
<li><strong>对应的 Token 类型</strong>：如 <code>LEFT_BRACKET</code>、<code>RIGHT_BRACKET</code>、<code>COLON</code></li>
<li><strong>分类</strong>：如 <code>bracket</code>（括号）、<code>colon</code>（冒号）</li>
</ol>

<p>Lexer 只需要查这张表，就能知道当前字符是什么类型的 Token。</p>

<h2 id="五-关键决策-用-rune-而不是-byte">五、关键决策：用 <code>rune</code> 而不是 <code>byte</code></h2>

<p>在定义映射表之前，先确定一个关键问题：用什么类型来存储字符？</p>

<p>在 Go 中，遍历字符串有两种方式：</p>

<table>
<thead>
<tr>
<th>方式</th>
<th>类型</th>
<th>特点</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>byte</code></td>
<td>1 字节</td>
<td>只能处理 ASCII 字符（英文、数字、标点）</td>
</tr>

<tr>
<td><code>rune</code></td>
<td>4 字节</td>
<td>能处理任意 Unicode 字符（中文、Emoji）</td>
</tr>
</tbody>
</table>
<p><code>【</code> 在 UTF-8 中占 <strong>3 个字节</strong>。如果按 <code>byte</code> 遍历，<code>【</code> 会被拆成 3 个独立的字节，无法正确识别。</p>

<p>所以必须用 <code>rune</code>——它能保证每个字符（无论中英文）都被当作一个整体处理。</p>

<p><strong>用 <code>rune</code> 的代价：</strong> Go 的 <code>string</code> 底层是 <code>[]byte</code>，把它转成 <code>[]rune</code> 需要额外分配内存。但对于我们处理的 <code>.meph</code> 文件（通常只有几 KB 到几百 KB），这个代价可以忽略不计。<strong>正确性远比微小的性能损耗重要。</strong></p>

<h2 id="六-定义映射表">六、定义映射表</h2>

<h3 id="6-1-结构体定义">6.1 结构体定义</h3>

<p>创建 <code>parser/symbols.go</code>：</p>

<pre><code class="language-go">package parser

// SymbolInfo 符号信息
type SymbolInfo struct {
    TokenType TokenType // 对应的 Token 类型
    Category  string    // 分类：bracket, colon, hyphen, at, hash, whitespace, newline
}
</code></pre>

<h3 id="6-2-映射表">6.2 映射表</h3>

<pre><code class="language-go">// symbolMap 所有符号的映射表
// 新增符号只需在这里加一行
var symbolMap = map[rune]SymbolInfo{
    // 括号
    '【': {TokenType: TOKEN_LEFT_BRACKET, Category: &quot;bracket&quot;},
    '】': {TokenType: TOKEN_RIGHT_BRACKET, Category: &quot;bracket&quot;},
    '[':  {TokenType: TOKEN_LEFT_BRACKET, Category: &quot;bracket&quot;},
    ']':  {TokenType: TOKEN_RIGHT_BRACKET, Category: &quot;bracket&quot;},

    // 冒号
    '：': {TokenType: TOKEN_COLON, Category: &quot;colon&quot;},
    ':':  {TokenType: TOKEN_COLON, Category: &quot;colon&quot;},

    // 连字符
    '-':  {TokenType: TOKEN_HYPHEN, Category: &quot;hyphen&quot;},

    // 引用符号
    '@':  {TokenType: TOKEN_AT, Category: &quot;at&quot;},

    // 标签/注释符号
    '#': {TokenType: TOKEN_HASH, Category: &quot;hash&quot;},

    // 空白字符（空格、制表符、回车）
    // 注意：这些字符会被 skipWhitespace() 提前消费，不会作为 Token 返回
    ' ':  {TokenType: TOKEN_TEXT, Category: &quot;whitespace&quot;},
    '\t': {TokenType: TOKEN_TEXT, Category: &quot;whitespace&quot;},
    '\r': {TokenType: TOKEN_TEXT, Category: &quot;whitespace&quot;},

    // 换行符
    '\n': {TokenType: TOKEN_NEWLINE, Category: &quot;newline&quot;},
}
</code></pre>

<p><strong>关键设计说明：</strong></p>

<table>
<thead>
<tr>
<th>设计点</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>同一 TokenType 对应多个字符</strong></td>
<td><code>【</code> 和 <code>[</code> 都是 <code>TOKEN_LEFT_BRACKET</code>，中英双语自然支持</td>
</tr>

<tr>
<td><strong>Category 用于筛选</strong></td>
<td>Lexer 可以通过 <code>Category == &quot;whitespace&quot;</code> 跳过空格</td>
</tr>

<tr>
<td><strong><code>\n</code> 是 <code>TOKEN_NEWLINE</code></strong></td>
<td>换行有语法意义，会被作为 Token 返回（不同于被跳过的空格）</td>
</tr>

<tr>
<td><strong>空格是 <code>TOKEN_TEXT</code></strong></td>
<td>空格永远不会被返回（被 <code>skipWhitespace</code> 提前消费），<code>TOKEN_TEXT</code> 只是占位</td>
</tr>
</tbody>
</table>

<h2 id="七-基于映射表的符号查询">七、基于映射表的符号查询</h2>

<p>有了映射表，查询符号信息变得极其简单——直接查表即可：</p>

<pre><code class="language-go">// GetSymbolInfo 查询符号信息
// 返回值：(SymbolInfo, bool)，bool 表示是否找到
// 这是给外部包（如测试代码）用的查询入口
func GetSymbolInfo(ch rune) (SymbolInfo, bool) {
    info, ok := symbolMap[ch]
    return info, ok
}
</code></pre>

<p><strong>这个函数是 <code>symbolMap</code> 的唯一直观体现</strong>：它告诉调用方&rdquo;这个字符是不是我们认识的符号？如果是，它是什么类型？&rdquo;</p>

<p>在下一篇文章中，我们会看到 Lexer 内部如何使用 <code>symbolMap</code> 来实现一次查表驱动整个词法分析。现在我们先验证这张表是否工作正常。</p>

<h2 id="八-验证-查表逻辑的正确性">八、验证：查表逻辑的正确性</h2>

<p>在 <code>main.go</code> 中测试 <code>GetSymbolInfo</code>：</p>

<pre><code class="language-go">package main

import (
    &quot;fmt&quot;
    &quot;mephisto/internal/parser&quot;
)

func main() {
    // 查询中文左括号
    info, ok := parser.GetSymbolInfo('【')
    fmt.Printf(&quot;'【' → %s, 找到: %v\n&quot;, info.TokenType, ok) // LEFT_BRACKET, true

    // 查询英文左括号
    info, ok = parser.GetSymbolInfo('[')
    fmt.Printf(&quot;'[' → %s, 找到: %v\n&quot;, info.TokenType, ok) // LEFT_BRACKET, true

    // 查询普通字母（不在表中）
    info, ok = parser.GetSymbolInfo('x')
    fmt.Printf(&quot;'x' → 找到: %v\n&quot;, ok) // false

    // 查询换行符
    info, ok = parser.GetSymbolInfo('\n')
    fmt.Printf(&quot;'\\n' → %s, 找到: %v\n&quot;, info.TokenType, ok) // NEWLINE, true
}
</code></pre>

<p>输出：</p>

<pre><code class="language-text">'【' → TOKEN_LEFT_BRACKET, 找到: true
'[' → TOKEN_LEFT_BRACKET, 找到: true
'x' → 找到: false
'\n' → TOKEN_NEWLINE, 找到: true
</code></pre>

<p><strong>验证要点：</strong></p>

<ul>
<li><code>【</code> 和 <code>[</code> 都返回 <code>TOKEN_LEFT_BRACKET</code>——这就是中英双语支持</li>
<li><code>x</code> 不在表中——它会被当作普通文本处理</li>
<li><code>\n</code> 在表中，被识别为 <code>TOKEN_NEWLINE</code>——它有语法意义，会被返回</li>
</ul>

<p><strong>注意：</strong> 空格不在验证列表中，因为空格被 <code>skipWhitespace()</code> 提前消费了，我们验证的重点是&rdquo;会被 Lexer 返回的符号&rdquo;。</p>

<h2 id="九-这种设计的优势">九、这种设计的优势</h2>

<table>
<thead>
<tr>
<th>优势</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>新增符号不改 Lexer</strong></td>
<td>只需在 <code>symbolMap</code> 加一行，Lexer 自动识别</td>
</tr>

<tr>
<td><strong>中英双语自然支持</strong></td>
<td>同一 TokenType 对应多个字符</td>
</tr>

<tr>
<td><strong>判断逻辑集中</strong></td>
<td>所有符号的判断都在一张表里</td>
</tr>

<tr>
<td><strong>Category 提供额外维度</strong></td>
<td>可以按分类批量处理（如跳过所有 whitespace）</td>
</tr>
</tbody>
</table>
<p><strong>核心思想：让数据决定一切。</strong></p>

<h2 id="十-小结">十、小结</h2>

<p>这一篇完成了两件事：</p>

<table>
<thead>
<tr>
<th>做了什么</th>
<th>为什么</th>
</tr>
</thead>

<tbody>
<tr>
<td>扩充了 Token 类型</td>
<td>Lexer 需要识别更多符号（冒号、连字符、引用符号等）</td>
</tr>

<tr>
<td>创建了 <code>symbolMap</code> 符号映射表</td>
<td>集中管理所有符号，Lexer 通过查表判断</td>
</tr>
</tbody>
</table>
<p><strong>下一篇：实现 Lexer，真正把文本变成 Token 流。</strong></p>
]]></content:encoded>
      <description><![CDATA[避免 Lexer 中出现大量 switch-case，引入符号映射表（symbolMap）统一管理所有符号，实现中英双语支持和数据驱动设计。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[Compiler]]></category>
      <dc:subject>P1</dc:subject>
      <dc:relation><![CDATA[series:lexer-from-scratch]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[从零实现词法分析器（一）：Token——计算机的最小理解单位]]></title>
      <link>https://moongate.top/docs/from-text-to-token-go-lexer-part-1</link>
      <guid isPermaLink="true">d6784b4c-89b0-4a75-b303-10b49c67d576</guid>
      <pubDate>Mon, 13 Jul 2026 20:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>一个文本文件对计算机来说只是一串字符，毫无结构可言。我们要做的第一件事，就是让计算机能“认出”这些字符里藏着什么。</p>
</blockquote>

<h2 id="一-问题-计算机看不懂文本的结构">一、问题：计算机看不懂文本的结构</h2>

<p>假设我有一个文本文件，内容是这样的：</p>

<pre><code class="language-text">【角色名】
贝利亚奥特曼
</code></pre>

<p>我（人类）一眼就能看出：</p>

<ul>
<li><code>【角色名】</code> 是一个标题</li>
<li><code>贝利亚奥特曼</code> 是它的内容</li>
</ul>

<p>但计算机看到的是：</p>

<pre><code class="language-text">[ 0xE3 0x80 0x90 0xE8 0xA7 0x92 0xE8 0x89 0xB2 0xE5 0x90 0x8D 0xE3 0x80 0x91 0x0A 0xE8 0xB4 0x9D 0xE5 0x88 0xA9 0xE4 0xBA 0x9A 0xE5 0xA5 0xA5 0xE7 0x89 0xB9 0xE6 0x9B 0xBC ]
</code></pre>

<p>这就是计算机眼中的文本——一串基于 <strong>UTF-8 编码</strong>的原始字节流。它不知道 <code>0xE3 0x80 0x90</code> 连起来就是中文的 <code>【</code>，也不知道 <code>【</code> 和 <code>】</code> 是配对的。</p>

<p>它看到的只是一串毫无意义的数字。</p>

<p>如果我想让程序读懂这种格式的文本，第一个要解决的问题就是：<strong>让计算机能“认出”文本的结构。</strong></p>

<h2 id="二-怎么办-拆成小块-贴上标签">二、怎么办：拆成小块，贴上标签</h2>

<p>计算机不认识“区块标题”这种概念，但它能识别字符。</p>

<p>如果我把这段文本拆成最小的“有意义的片段”，并给每个片段贴上<strong>固定的标签</strong>，计算机就能一步步理解它了。</p>

<p>比如，把：</p>

<pre><code class="language-text">【角色名】
贝利亚奥特曼
</code></pre>

<p>拆成：</p>

<table>
<thead>
<tr>
<th>片段</th>
<th>标签</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>【</code></td>
<td><code>LEFT_BRACKET</code></td>
<td>左括号</td>
</tr>

<tr>
<td><code>角色名</code></td>
<td><code>TEXT</code></td>
<td>普通文本</td>
</tr>

<tr>
<td><code>】</code></td>
<td><code>RIGHT_BRACKET</code></td>
<td>右括号</td>
</tr>

<tr>
<td><code>\n</code></td>
<td>（换行符）</td>
<td>文本中的换行</td>
</tr>

<tr>
<td><code>贝利亚奥特曼</code></td>
<td><code>TEXT</code></td>
<td>普通文本</td>
</tr>
</tbody>
</table>
<p>这样，程序就能知道：</p>

<ol>
<li>这里有一个左括号</li>
<li>后面跟了一段文本（“角色名”）</li>
<li>然后是一个右括号</li>
<li>换行后，又有一段文本（“贝利亚奥特曼”）</li>
</ol>

<p><strong>下一步，程序就可以根据这个规律，识别出“【】”包裹的是标题，标题下面跟着的是内容。</strong></p>

<h2 id="三-token-有标签的最小片段">三、Token：有标签的最小片段</h2>

<p>这种<strong>有标签的最小片段</strong>，就叫 <strong>Token</strong>。</p>

<p>每个 Token 需要记录两件事：</p>

<ol>
<li><strong>类型</strong>：这是什么？（左括号？文本？右括号？）</li>
<li><strong>字面量</strong>：它长什么样？（<code>【</code>？<code>角色名</code>？）</li>
</ol>

<p>把文本拆成 Token 的过程，叫<strong>词法分析</strong>。</p>

<p>执行词法分析的程序，叫 <strong>Lexer（词法分析器）</strong>。</p>

<h2 id="四-定义-token-第一行代码">四、定义 Token：第一行代码</h2>

<h3 id="4-1-项目结构">4.1 项目结构</h3>

<p>在开始写代码前，先建立清晰的模块化目录：</p>

<pre><code class="language-text">mephisto/
├── go.mod
├── main.go
├── testdata/
│   └── sample.meph
└── internal/
    └── parser/
        ├── token.go
        ├── symbols.go
        └── lexer.go
</code></pre>

<p><strong>注意：</strong> 所有解析相关的代码都放在 <code>parser/</code> 目录下，这样随着系列文章的推进，我们始终在同一个包里工作，目录结构的变化只是“新增文件”，不是“切换包”。读者从头到尾只需要关注 <code>internal/parser/</code> 这一个目录的演进。</p>

<h3 id="4-2-初始化-go-模块">4.2 初始化 Go 模块</h3>

<pre><code class="language-bash">go mod init mephisto
</code></pre>

<h3 id="4-3-定义-token-类型与结构体">4.3 定义 Token 类型与结构体</h3>

<p>创建 <code>parser/token.go</code>：</p>

<pre><code class="language-go">package parser

// TokenType 表示 Token 的类型
// 用 string 而不是 int，调试时可以直接看到类型名
type TokenType string

const (
	TOKEN_LEFT_BRACKET  TokenType = &quot;LEFT_BRACKET&quot;  // 【
	TOKEN_RIGHT_BRACKET TokenType = &quot;RIGHT_BRACKET&quot; // 】
	TOKEN_TEXT          TokenType = &quot;TEXT&quot;          // 普通文本
	// 更多 Token 类型将在后续文章中逐步引入
)

// Token 是计算机理解文本的最小有意义片段
type Token struct {
	Type    TokenType // 类型：LEFT_BRACKET？TEXT？
	Literal string    // 字面量：&quot;【&quot;？&quot;角色名&quot;？
	Line    int       // Line 字段将在后续文章中引入（用于错误提示）
}
</code></pre>

<p><strong>为什么用 <code>string</code> 而不是 <code>int</code> 定义类型？</strong></p>

<p>如果用一个数字表示类型，调试时打印出来的是一串数字，需要翻代码才能知道 <code>0</code> 代表什么。而用 <code>string</code>，<code>fmt.Println(tok.Type)</code> 直接打印 <code>&quot;LEFT_BRACKET&quot;</code>，可读性高得多。词法分析器处理的 Token 数量通常只有几百个，<code>string</code> 的性能开销可以忽略不计。</p>

<p><strong>为什么 <code>Token</code> 的字段要大写？</strong></p>

<p>在 Go 里，<strong>大写字母开头的字段是公开的</strong>，小写是私有的。<code>Token</code> 会被 <code>main.go</code> 使用，所以它的字段必须大写，否则外部包无法访问。</p>

<h2 id="五-验证-让代码能编译">五、验证：让代码能编译</h2>

<h3 id="5-1-创建入口文件">5.1 创建入口文件</h3>

<p>在项目根目录创建 <code>main.go</code>。目前它只负责读取文件并打印内容——虽然还没有用到 <code>parser</code> 包，但项目的骨架已经搭建完毕：</p>

<pre><code class="language-go">package main

import (
	&quot;fmt&quot;
	&quot;os&quot;
)

func main() {
	if len(os.Args) &lt; 2 {
		fmt.Println(&quot;用法: mephisto &lt;文件&gt;&quot;)
		os.Exit(1)
	}

	content, err := os.ReadFile(os.Args[1])
	if err != nil {
		fmt.Printf(&quot;读取文件失败: %v\n&quot;, err)
		os.Exit(1)
	}

	fmt.Print(string(content))
}
</code></pre>

<h3 id="5-2-创建测试文件">5.2 创建测试文件</h3>

<p><code>testdata/sample.meph</code>：</p>

<pre><code class="language-text">【角色名】
贝利亚奥特曼
</code></pre>

<h3 id="5-3-运行">5.3 运行</h3>

<pre><code class="language-bash">go run main.go testdata/sample.meph
</code></pre>

<p>看到文件内容输出，说明项目能跑了：</p>

<pre><code class="language-text">【角色名】
贝利亚奥特曼
</code></pre>

<h2 id="六-小结">六、小结</h2>

<p>这一篇完成了一件事：<strong>为整个系列打下地基。</strong></p>

<table>
<thead>
<tr>
<th>做了什么</th>
<th>为什么</th>
</tr>
</thead>

<tbody>
<tr>
<td>理解了 Token 的概念</td>
<td>Token 是计算机理解文本的最小单位</td>
</tr>

<tr>
<td>定义了 <code>TokenType</code> 和 <code>Token</code></td>
<td>为后续实现 Lexer 准备好数据结构</td>
</tr>

<tr>
<td>搭建了项目骨架</td>
<td>后续所有代码都在这个基础上扩展</td>
</tr>
</tbody>
</table>
<p><strong>接下来我们要做的就是实现 Lexer——让程序能自动从文本里“吐”出 Token。</strong></p>

<h2 id="完整代码">完整代码</h2>

<p><strong><code>parser/token.go</code></strong></p>

<pre><code class="language-go">package parser

type TokenType string

const (
	TOKEN_LEFT_BRACKET  TokenType = &quot;LEFT_BRACKET&quot;
	TOKEN_RIGHT_BRACKET TokenType = &quot;RIGHT_BRACKET&quot;
	TOKEN_TEXT          TokenType = &quot;TEXT&quot;
)

type Token struct {
	Type    TokenType
	Literal string
}
</code></pre>

<p><strong><code>main.go</code></strong></p>

<pre><code class="language-go">package main

import (
	&quot;fmt&quot;
	&quot;os&quot;
)

func main() {
	if len(os.Args) &lt;  {
		fmt.Println(&quot;用法: mephisto &lt;文件&gt;&quot;)
		os.Exit(1)
	}

	content, err := os.ReadFile(os.Args[1])
	if err != nil {
		fmt.Printf(&quot;读取文件失败: %v\n&quot;, err)
		os.Exit(1)
	}

	fmt.Print(string(content))
}
</code></pre>
]]></content:encoded>
      <description><![CDATA[从计算机不认识文本的痛点出发，理解 Token 的概念，并用 Go 定义第一个 Token 结构体，完成词法分析器的第一步。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P1</dc:subject>
      <dc:relation><![CDATA[series:lexer-from-scratch]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 实战：在个人博客中集成 Shiki 自定义 VSCode 主题]]></title>
      <link>https://moongate.top/docs/nuxt-shiki-vscode-theme-ssr-dual-themes</link>
      <guid isPermaLink="true">65b51060-10b8-4e9e-9ba4-c67d2b6afd36</guid>
      <pubDate>Sat, 11 Jul 2026 22:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="为什么要折腾-shiki">为什么要折腾 Shiki？</h2>

<p>在个人博客中，代码高亮是阅读体验的核心。市面上主流的 Prism.js 和 Highlight.js 虽然普及度高，但高亮精度有限。相比之下，Shiki 使用与 VSCode 相同的 TextMate 语法引擎，能实现像素级精准的高亮，完全对标 VSCode 的代码着色体验。</p>

<p>我的需求很简单：个人博客使用我自己开发的 VSCode 主题插件 <strong>Moongate Theme</strong> 的深浅两套配色，代码块在 SSR 场景下正常渲染，且深浅主题能一键联动切换。
听起来不难？<strong>实际上坑比想象中多。</strong></p>

<h2 id="坑一-自定义主题怎么加载">坑一：自定义主题怎么加载？</h2>

<p>Shiki 官方文档提供了两种常规的加载方式：创建高亮器时直接传入主题对象，或使用 loadTheme 动态加载 JSON 文件。看起来很简单对吧？但当把它放到 Nuxt 项目中时，问题来了。</p>

<h3 id="错误尝试-依赖-nuxt-shiki-模块">❌ 错误尝试：依赖 nuxt-shiki 模块</h3>

<p>我一开始用了 nuxt-shiki 模块，想着它能帮我省事。配置如下：</p>

<pre><code class="language-typescript">import lightTheme from &quot;./assets/themes/light.json&quot;
import darkTheme from &quot;./assets/themes/dark.json&quot;

export default defineNuxtConfig({
  modules: [&quot;nuxt-shiki&quot;],
  shiki: {
    bundledThemes: [lightTheme.name, darkTheme.name],
    defaultTheme: lightTheme.name,
  },
})
</code></pre>

<p>运行后直接报错：</p>

<pre><code class="language-text">Failed to resolve import &quot;shiki/themes/Moongate Theme Light.mjs&quot;
</code></pre>

<blockquote>
<p><strong>根本原因</strong>：nuxt-shiki 的 bundledThemes 只接受 Shiki <strong>内置主题的名称</strong>（如 github-dark），它会自动去 shiki/themes/ 目录下查找对应的内置 .mjs 文件。当你传入自定义主题名称时，它找不到对应的文件，自然 404。</p>
</blockquote>

<h3 id="正确做法-直接用原生-shiki-api">✅ 正确做法：直接用原生 Shiki API</h3>

<p>放弃 nuxt-shiki 模块，直接在 Composable 中使用原生 createHighlighter，将其作为常驻内存的全局单例：</p>

<pre><code class="language-typescript">// composables/useShikiHighlighter.ts
import { createHighlighter, type Highlighter } from &quot;shiki&quot;
import lightTheme from &quot;~/assets/themes/light.json&quot;
import darkTheme from &quot;~/assets/themes/dark.json&quot;

let highlighterInstance: Highlighter | null = null

export async function getShikiHighlighter() {
  if (!highlighterInstance) {
    highlighterInstance = await createHighlighter({
      themes: [lightTheme, darkTheme],
      langs: [
        &quot;bash&quot;,
        &quot;css&quot;,
        &quot;docker&quot;,
        &quot;go&quot;,
        &quot;html&quot;,
        &quot;javascript&quot;,
        &quot;json&quot;,
        &quot;markdown&quot;,
        &quot;shell&quot;,
        &quot;sql&quot;,
        &quot;typescript&quot;,
        &quot;vue&quot;,
        &quot;xml&quot;,
        &quot;yaml&quot;,
      ],
    })
  }
  return highlighterInstance
}
</code></pre>

<blockquote>
<p><strong>关键点</strong>：主题 JSON 对象直接传入 themes 数组，高亮时通过主题的 name 字段引用即可，不再依赖外部文件的动态寻址。</p>
</blockquote>

<h2 id="坑二-自定义主题的双主题联动">坑二：自定义主题的双主题联动</h2>

<h3 id="单主题方案的局限性">单主题方案的局限性</h3>

<p>最初的方案是在客户端组件挂载后通过 DOMParser 解析 HTML，然后根据当前主题传入对应的主题名称重新高亮：</p>

<pre><code class="language-typescript">// ❌ 客户端高亮 + watch 主题变化
const theme = isDark ? &quot;Moongate Theme Dark&quot; : &quot;Moongate Theme Light&quot;
const result = highlighter.codeToHtml(code, { lang, theme })

watch(() =&gt; store.theme, highlight) // 主题变了要重新处理所有代码块
</code></pre>

<p>这种传统做法会带来三个极具毁灭性的痛点：</p>

<ol>
<li><strong>闪动</strong>：客户端 Hydration 后才能高亮，用户打开网页会看到“原始内容/黑色外壳 → 高亮内容”的明显跳变。</li>
<li><strong>延迟</strong>：每次一键切换主题，客户端都需要重新执行 JS 高亮所有代码块，有明显的视觉等待时间。</li>
<li><strong>CPU 开销</strong>：在手机端低配设备上，每次切换主题都调用 WASM 引擎处理大量文本，会导致页面瞬间掉帧。</li>
</ol>

<h2 id="坑三-服务端高亮-dual-themes-完美方案">坑三：服务端高亮 + Dual Themes = 完美方案</h2>

<p>真正的突破是<strong>把高亮工作从客户端彻底移到服务端</strong>，同时利用 Shiki 的 <strong>Dual Themes（双主题）</strong> 特性。</p>

<h3 id="核心工具函数-服务端高亮处理器">核心工具函数：服务端高亮处理器</h3>

<p>创建 utils/shikiProcessor.ts。这里使用正则异步替换未高亮的 HTML 块，并加入一个健壮的兜底机制：即使代码块没有写 language-xxx，也能默认以 text 纯文本进行高亮渲染。</p>

<pre><code class="language-typescript">// utils/shikiProcessor.ts
import { getShikiHighlighter } from &quot;~/composables/useShikiHighlighter&quot;

export async function highlightHtmlContent(
  htmlContent: string,
): Promise&lt;string&gt; {
  if (!htmlContent) return &quot;&quot;

  const highlighter = await getShikiHighlighter()

  // 增强正则：允许匹配没有定义 language 类的标准 &lt;code&gt; 块
  const preCodeRegex = /&lt;pre&gt;\s*&lt;code([^&gt;]*)&gt;([\s\S]*?)&lt;\/code&gt;\s*&lt;\/pre&gt;/g
  const matches = [...htmlContent.matchAll(preCodeRegex)]
  let resultHtml = htmlContent

  for (const match of matches) {
    const [fullMatch, attributes, rawCode] = match

    // 提取语言类型，若无则默认为 'text'
    const langMatch = attributes.match(/class=&quot;[^&quot;]*language-(\w+)&quot;/)
    const lang = langMatch ? langMatch[1] : &quot;text&quot;

    // 解码 HTML 实体，防止 Shiki 二次转义
    const code = rawCode
      .replace(/&amp;lt;/g, &quot;&lt;&quot;)
      .replace(/&amp;gt;/g, &quot;&gt;&quot;)
      .replace(/&amp;amp;/g, &quot;&amp;&quot;)
      .replace(/&amp;quot;/g, '&quot;')
      .replace(/&amp;#39;/g, &quot;'&quot;)

    // 🎯 关键：使用 Dual Themes 一次生成包含两套颜色 Token 的 HTML
    const highlighted = highlighter.codeToHtml(code, {
      lang,
      themes: {
        light: &quot;Moongate Theme Light&quot;,
        dark: &quot;Moongate Theme Dark&quot;,
      },
      defaultColor: false, // 核心配置：不生成默认内联 color，完全靠 CSS 变量驱动
    })

    resultHtml = resultHtml.replace(fullMatch, highlighted)
  }

  return resultHtml
}
</code></pre>

<h3 id="在数据获取层拦截并转换">在数据获取层拦截并转换</h3>

<p>在组件内部，利用 useLazyAsyncData 的 transform 选项。<strong>这步是最高级的优化</strong>：数据在服务端被 Node.js 抓取到后瞬间完成高亮替换。数据吐到前端时就已经套好了 Shiki 的外衣。</p>

<pre><code class="language-typescript">const { data: page, pending } = useLazyAsyncData&lt;DocDetailResponse&gt;(
  `doc-${slug.value}`,
  async () =&gt; {
    const {
      public: { apiUrl },
    } = useRuntimeConfig()
    return await $fetch(`${apiUrl}/api/docs/${slug.value}`)
  },
  {
    watch: [slug],
    // 🔥 关键：数据在服务端获取后立即转换为高亮 HTML，客户端 0 开销
    transform: async (data) =&gt; {
      if (data &amp;&amp; data.content) {
        data.highlightedContent = await highlightHtmlContent(data.content)
      }
      return data
    },
  },
)

// 优先使用服务端已高亮的完全体 HTML
const contentRef = computed(
  () =&gt; page.value?.highlightedContent || page.value?.content || &quot;&quot;,
)
</code></pre>

<h3 id="css-变量控制双主题切换">CSS 变量控制双主题切换</h3>

<p>Dual Themes 生成的 HTML 中会精妙地包含 &ndash;shiki-light 和 &ndash;shiki-dark 两套 CSS 变量。配合 @nuxtjs/color-mode 切换时自动在 <html> 标记的 .dark 类，只需在全局样式表中写下几行映射，就能实现<strong>纯 CSS 级别的高性能切换</strong>：</p>

<pre><code class="language-css">/* 浅色模式默认映射 */
.shiki {
  background-color: var(--shiki-light-bg) !important;
  color: var(--shiki-light) !important;
}
.shiki span {
  color: var(--shiki-light) !important;
}

/* 深色模式映射 - 纯 CSS 触发，不经过任何 JS 运行时 */
.dark .shiki {
  background-color: var(--shiki-dark-bg) !important;
  color: var(--shiki-dark) !important;
}
.dark .shiki span {
  color: var(--shiki-dark) !important;
}
</code></pre>

<h2 id="为什么闪动消失了">⚡ 为什么闪动消失了？</h2>

<p>通过前后方案的对比，我们可以清晰地看到为什么这个方案能达到“降维打击”的效果：</p>

<table>
<thead>
<tr>
<th>阶段</th>
<th>之前（有闪动、有延迟）</th>
<th>现在（无闪动、零开销）</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>服务端 (SSR)</strong></td>
<td>返回原始 HTML（未高亮的纯文本或暗色外壳）</td>
<td>返回高亮后的 HTML（<strong>已包含全量双主题样式</strong>）</td>
</tr>

<tr>
<td><strong>客户端挂载</strong></td>
<td>显示原始内容 → 加载 JS / WASM → 替换 DOM → 高亮变色</td>
<td><strong>直接显示高亮后的 HTML，没有任何视觉时差</strong></td>
</tr>

<tr>
<td><strong>主题一键切换</strong></td>
<td>watch 状态变化 → 耗费 CPU 重新高亮渲染</td>
<td><strong>纯 CSS 切换变量，瞬间响应，0ms 延迟</strong></td>
</tr>
</tbody>
</table>

<blockquote>
<p><strong>核心突破</strong>：高亮工作在服务端一次性就位，客户端收到即用。由于两套变量早已直出，切换主题变成了浏览器的原生样式渲染，不再需要 Pinia 去跨组件追踪和重绘。</p>
</blockquote>

<h2 id="细节打磨-代码块边框与呼吸感">🎨 细节打磨：代码块边框与呼吸感</h2>

<p>最后，给代码块加点微弱的边框和极浅的阴影，能在长文阅读中有效地为代码建立视觉锚点，提升整体的呼吸感与精致度：</p>

<pre><code class="language-css">.shiki-content pre.shiki {
  padding: 1.25rem;
  border-radius: 0.5rem;
  overflow-x: auto;
  border: 1px solid #e5e7eb;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
  transition:
    border-color 0.3s,
    box-shadow 0.3s;
}

.dark .shiki-content pre.shiki {
  border-color: #2d3748;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
</code></pre>

<h2 id="架构设计流向">📐 架构设计流向</h2>

<pre><code class="language-text"> ┌─────────────────┐
 │  服务端 (SSR)    │
 └────────┬────────┘
          │
          ▼
 ┌─────────────────┐
 │ 获取原始内容     │
 └────────┬────────┘
          │
          ▼
 ┌──────────────────────────────────────────────┐
 │ transform: highlightHtmlContent()            │
 │  ├── 正则匹配 &lt;pre&gt;&lt;code&gt;                     │
 │  ├── Shiki 原生 API 生成 Dual Themes HTML    │
 │  └── 替换原始代码块                           │
 └────────────────┬─────────────────────────────┘
          │
          ▼
 ┌──────────────────────────────────────────────┐
 │ 返回高亮后的 HTML (含 --shiki-light / dark)   │
 └────────────────┬─────────────────────────────┘
          │
          ▼
 ┌─────────────────┐
 │ 客户端直接渲染   │
 └────────┬────────┘
          │
          ├─► 浅色模式 ──► 自动映射 --shiki-light (纯 CSS)
          └─► 深色模式 ──► 自动映射 --shiki-dark  (纯 CSS)

</code></pre>

<h2 id="总结">📝 总结</h2>

<table>
<thead>
<tr>
<th>遇到的坑</th>
<th>解决方案</th>
</tr>
</thead>

<tbody>
<tr>
<td>nuxt-shiki 不支持自定义主题</td>
<td>弃用扩展模块，改用原生 createHighlighter 自定义导入</td>
</tr>

<tr>
<td>客户端高亮导致 SSR 闪动缺陷</td>
<td>利用 useLazyAsyncData 的 transform 在服务端完成高亮</td>
</tr>

<tr>
<td>深浅主题一键切换存在明显的延迟</td>
<td>采用 Shiki Dual Themes 生成双主题 CSS 变量</td>
</tr>

<tr>
<td>主题联动需要复杂的 watch 重渲染</td>
<td>纯 CSS 变量控制，<strong>客户端 Shiki JS / WASM 运行时开销归零</strong></td>
</tr>
</tbody>
</table>
<p>在前端实战中，面对长文章下的代码高亮需求，<strong>“在服务端多做一点，客户端就能少做很多”</strong>。通过在服务端利用 Shiki 提取双主题直出，不仅完美消灭了视觉闪烁，还让我们的博客客户端免受庞大高亮引擎带来的首屏负荷。</p>

<p>希望这篇实战记录能帮你在使用 Shiki + Nuxt 的路上少走弯路！</p>
]]></content:encoded>
      <description><![CDATA[从加载自定义主题到深浅色联动，再到彻底解决 SSR 闪动问题。一份完整的 Nuxt + Shiki 自定义主题集成指南。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[VSCode]]></category>
      <category><![CDATA[Theme]]></category>
      <category><![CDATA[Performance]]></category>
      <category><![CDATA[Hydration]]></category>
      <dc:subject>P2</dc:subject>
      
    </item>

    <item>
      <title><![CDATA[Nuxt + Go 全栈实践：从 URL 状态到后端 API 的完整闭环]]></title>
      <link>https://moongate.top/docs/nuxt-go-fullstack-closed-loop</link>
      <guid isPermaLink="true">79f9995c-2f47-44c5-8a6b-3f08c03d5b6d</guid>
      <pubDate>Sat, 11 Jul 2026 21:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>本文是系列第四篇，将前三篇的 URL 状态管理延伸至 Go 后端，实现分页、筛选、排序的端到端数据流。涵盖前后端参数约定、Go Gin 框架实践、useAsyncData 自动联动，以及 39 篇文档从 4 分钟到 10 秒的部署优化。</p>
</blockquote>

<p><strong>适用读者</strong>：已了解 Nuxt URL 状态同步（前三篇），想打通前后端完整数据流的开发者。</p>

<p><strong>你将学到</strong>：
- 前后端参数约定的设计方法
- Go Gin 框架中处理分页、筛选、排序的实践
- 前端 <code>useAsyncData</code> 与后端 API 的自动联动
- 从 URL 状态到后端响应的完整数据流闭环</p>

<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共四篇，覆盖 Nuxt 中 URL 与状态双向同步的全流程：</p>

<ol>
<li><p><a href="./nuxt-url-state-guide">Nuxt 中 URL 与状态双向绑定的终极指南（原理篇）</a>
—— 讲解 URL 与状态双向同步的原理与手写方案。</p></li>

<li><p><a href="./nuxt-use-route-query-composables">手写一个更适合 Nuxt 的 useRouteQuery（封装篇）</a>
—— 将重复逻辑封装成开箱即用的 composable。</p></li>

<li><p><a href="./nuxt-docs-list-page-complete-guide">从零到一：构建一个功能完备的文档列表页（实战篇）</a>
—— 综合运用前两篇的知识，实现完整的文档列表页。</p></li>

<li><p><a href="./nuxt-go-fullstack-closed-loop">Nuxt + Go 全栈实践：从 URL 状态到后端 API 的完整闭环</a>
—— 将前端 URL 状态与 Go 后端 API 打通，形成完整的数据流闭环。</p></li>
</ol>

<h2 id="一-前置阅读">一、前置阅读</h2>

<p>本文假设你已经了解：</p>

<ul>
<li><strong>前端 URL 状态同步</strong>（前三篇已覆盖）</li>
<li><strong>Go 如何加载 Markdown 文件到内存</strong>（独立短文<a href="./go-markdown-loader">《用 Go 重构 Markdown 加载》</a>已覆盖）</li>
</ul>

<p>如果你还不熟悉 Go 数据加载部分，建议先阅读独立短文（非系列，10 分钟读完），再回到本篇。</p>

<blockquote>
<p>📖 关于 Go 后端的数据模型（<code>Doc</code> 结构体）和加载逻辑（<code>loader</code> 包），本文不再重复。下文直接使用已加载到内存的 <code>Store</code>。</p>
</blockquote>

<h2 id="二-整体架构">二、整体架构</h2>

<h3 id="2-1-数据流向图">2.1 数据流向图</h3>

<pre><code>┌─────────────────────────────────────────────────────────────────────────────┐
│                           前端（Nuxt）                                    │
│  ┌───────────────────────────────────────────────────────────────────────┐ │
│  │  用户操作：输入搜索、选择等级、点击标签、翻页                          │ │
│  └───────────────────────────────────────────────────────────────────────┘ │
│                                    │                                       │
│                                    ▼                                       │
│  ┌───────────────────────────────────────────────────────────────────────┐ │
│  │  useRouteQuery：状态 ↔ URL 双向同步（前三篇）                        │ │
│  │  └── search, searchMode, page, size, level, tags                    │ │
│  └───────────────────────────────────────────────────────────────────────┘ │
│                                    │                                       │
│                                    ▼                                       │
│  ┌───────────────────────────────────────────────────────────────────────┐ │
│  │  useAsyncData：自动响应状态变化，构建 API 请求                       │ │
│  └───────────────────────────────────────────────────────────────────────┘ │
│                                    │                                       │
└────────────────────────────────────┼───────────────────────────────────────┘
                                     │ HTTP GET
                                     ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                        后端（Go + Gin）                                    │
│  ┌───────────────────────────────────────────────────────────────────────┐ │
│  │  GET /api/docs?page=1&amp;limit=10&amp;search=nuxt&amp;level=P3&amp;tag=go&amp;tag=vue  │ │
│  └───────────────────────────────────────────────────────────────────────┘ │
│                                    │                                       │
│                                    ▼                                       │
│  ┌───────────────────────────────────────────────────────────────────────┐ │
│  │  1. 解析参数：page, limit, search, searchMode, level, tags           │ │
│  │  2. 校验参数：searchMode 必须是 all/title/description               │ │
│  │  3. 数据处理：排序、筛选、分页                                      │ │
│  │  4. 返回响应：{ data, total, page, limit, totalPages }              │ │
│  └───────────────────────────────────────────────────────────────────────┘ │
│                                    │                                       │
└────────────────────────────────────┼───────────────────────────────────────┘
                                     │
                                     ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                   数据源（独立短文已覆盖：MD → 内存）                      │
│  ┌───────────────────────────────────────────────────────────────────────┐ │
│  │  39 篇 Markdown 文档，启动时加载到内存                              │ │
│  │  ├── title, description, date, permalink, level, series, tags       │ │
│  │  └── content（HTML 正文）                                           │ │
│  └───────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
</code></pre>

<h3 id="2-2-本篇聚焦">2.2 本篇聚焦</h3>

<pre><code>系列前三篇：URL ↔ 前端状态（已完成）
独立短文：  MD 文件 → 内存 Store（已完成）
本篇：      前端状态 → API 参数 → Go 处理 → 响应返回（进行中）
</code></pre>

<h2 id="三-前后端参数约定">三、前后端参数约定</h2>

<h3 id="3-1-api-设计">3.1 API 设计</h3>

<p><strong>接口定义</strong>：</p>

<pre><code>GET /api/docs
</code></pre>

<p><strong>请求参数</strong>：</p>

<table>
<thead>
<tr>
<th>参数</th>
<th>类型</th>
<th>默认值</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>page</code></td>
<td>int</td>
<td>1</td>
<td>当前页码（从 1 开始）</td>
</tr>

<tr>
<td><code>limit</code></td>
<td>int</td>
<td>10</td>
<td>每页条数（可选 10/20/50）</td>
</tr>

<tr>
<td><code>search</code></td>
<td>string</td>
<td>&rdquo;&rdquo;</td>
<td>搜索关键词</td>
</tr>

<tr>
<td><code>searchMode</code></td>
<td>string</td>
<td>&ldquo;all&rdquo;</td>
<td>搜索模式：<code>all</code> / <code>title</code> / <code>description</code></td>
</tr>

<tr>
<td><code>level</code></td>
<td>string</td>
<td>&rdquo;&rdquo;</td>
<td>等级筛选：<code>P1</code> ~ <code>P5</code></td>
</tr>

<tr>
<td><code>tag</code></td>
<td>string[]</td>
<td>[]</td>
<td>标签筛选（支持多参数）</td>
</tr>
</tbody>
</table>
<p><strong>响应格式</strong>：</p>

<pre><code class="language-json">{
  &quot;data&quot;: [
    {
      &quot;permalink&quot;: &quot;760e47b3-...&quot;,
      &quot;slug&quot;: &quot;nuxt-docs-list-page&quot;,
      &quot;title&quot;: &quot;构建一个功能完备的文档列表页&quot;,
      &quot;description&quot;: &quot;手把手教你用 Nuxt 4 构建...&quot;,
      &quot;level&quot;: &quot;P3&quot;,
      &quot;series&quot;: &quot;url-state&quot;,
      &quot;tags&quot;: [&quot;Nuxt&quot;, &quot;Vue&quot;, &quot;State Management&quot;],
      &quot;date&quot;: &quot;2026-03-21T00:00:00Z&quot;,
      &quot;content&quot;: &quot;&lt;h1&gt;...&lt;/h1&gt;&quot;
    }
  ],
  &quot;total&quot;: 39,
  &quot;page&quot;: 1,
  &quot;limit&quot;: 10,
  &quot;totalPages&quot;: 4
}
</code></pre>

<h3 id="3-2-前后端参数映射">3.2 前后端参数映射</h3>

<pre><code>前端状态（useDocs）  →  URL 参数  →  Go 后端参数
─────────────────────────────────────────────────────
searchInput          →  search   →  c.Query(&quot;search&quot;)
searchMode           →  searchMode → c.Query(&quot;searchMode&quot;)
page                 →  page     →  c.Query(&quot;page&quot;)
size                 →  limit    →  c.Query(&quot;limit&quot;)
level                →  level    →  c.Query(&quot;level&quot;)
tags                 →  tag[]    →  c.QueryArray(&quot;tag&quot;)
</code></pre>

<h3 id="3-3-searchmode-枚举约定">3.3 searchMode 枚举约定</h3>

<pre><code class="language-go">// Go 后端
type SearchMode string

const (
    SearchModeAll         SearchMode = &quot;all&quot;         // 标题 + 描述
    SearchModeTitle       SearchMode = &quot;title&quot;       // 仅标题
    SearchModeDescription SearchMode = &quot;description&quot; // 仅描述
)

func (m SearchMode) IsValid() bool {
    return m == SearchModeAll || m == SearchModeTitle || m == SearchModeDescription
}
</code></pre>

<pre><code class="language-typescript">// 前端 Nuxt（与后端完全一致）
type SearchMode = 'all' | 'title' | 'description'
</code></pre>

<p><strong>约定原则</strong>：枚举值前后端保持一致，任何非法值后端返回错误。</p>

<h2 id="四-go-api-实现">四、Go API 实现</h2>

<h3 id="4-1-项目结构-与本篇相关的部分">4.1 项目结构（与本篇相关的部分）</h3>

<pre><code class="language-text">moongate-api/
├── cmd/server/main.go          # 入口
├── internal/
│   ├── domain/
│   │   ├── doc.go              # Doc 结构体（独立短文已覆盖）
│   │   └── search_mode.go      # SearchMode 枚举 ← 本篇
│   ├── api/
│   │   └── docs.go             # DocsHandler ← 本篇核心
│   └── loader/                 # 数据加载（独立短文已覆盖）
└── content/                    # Markdown 文件
</code></pre>

<blockquote>
<p>📖 <code>domain.Doc</code> 结构体和 <code>loader</code> 包的完整实现见独立短文《用 Go 重构 Markdown 加载》。</p>
</blockquote>

<h3 id="4-2-searchmode-枚举">4.2 SearchMode 枚举</h3>

<pre><code class="language-go">// internal/domain/search_mode.go
package domain

type SearchMode string

const (
    SearchModeAll         SearchMode = &quot;all&quot;
    SearchModeTitle       SearchMode = &quot;title&quot;
    SearchModeDescription SearchMode = &quot;description&quot;
)

func (m SearchMode) IsValid() bool {
    return m == SearchModeAll || m == SearchModeTitle || m == SearchModeDescription
}
</code></pre>

<h3 id="4-3-docshandler">4.3 DocsHandler</h3>

<pre><code class="language-go">// internal/api/docs.go
package api

import (
    &quot;moongate-api/internal/domain&quot;
    &quot;net/http&quot;
    &quot;sort&quot;
    &quot;strconv&quot;
    &quot;strings&quot;

    &quot;github.com/gin-gonic/gin&quot;
)

type DocsHandler struct {
    Store map[string]*domain.Doc // key = permalink（来自独立短文）
}

func NewDocsHandler(store map[string]*domain.Doc) *DocsHandler {
    return &amp;DocsHandler{Store: store}
}

// GetDocs 返回分页后的文章列表
// GET /api/docs?page=1&amp;limit=10&amp;search=vue&amp;searchMode=all&amp;level=P3&amp;tag=go&amp;tag=vue
func (h *DocsHandler) GetDocs(c *gin.Context) {
    // 1. 获取查询参数
    page := c.DefaultQuery(&quot;page&quot;, &quot;1&quot;)
    limit := c.DefaultQuery(&quot;limit&quot;, &quot;10&quot;)
    search := c.Query(&quot;search&quot;)
    searchMode := domain.SearchMode(c.DefaultQuery(&quot;searchMode&quot;, &quot;all&quot;))
    level := c.Query(&quot;level&quot;)
    tags := c.QueryArray(&quot;tag&quot;)

    // 2. 校验 searchMode
    if !searchMode.IsValid() {
        c.JSON(http.StatusBadRequest, gin.H{
            &quot;error&quot;: &quot;searchMode 参数只能是 all、title 或 description&quot;,
        })
        return
    }

    // 3. 字符串转整数
    pageNum, _ := strconv.Atoi(page)
    limitNum, _ := strconv.Atoi(limit)

    // 4. 边界保护
    if pageNum &lt; 1 {
        pageNum = 1
    }
    if limitNum &lt; 1 {
        limitNum = 10
    }
    // 只允许 10、20、50
    allowedLimits := map[int]bool{10: true, 20: true, 50: true}
    if !allowedLimits[limitNum] {
        limitNum = 20
    }

    // 5. 转成切片并排序
    docs := make([]*domain.Doc, 0, len(h.Store))
    for _, doc := range h.Store {
        docs = append(docs, doc)
    }

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

    // 6. 筛选逻辑
    filtered := make([]*domain.Doc, 0, len(docs))
    levelUpper := strings.ToUpper(level)

    for _, doc := range docs {
        // 6.1 等级筛选
        if level != &quot;&quot; &amp;&amp; doc.Level != domain.Level(levelUpper) {
            continue
        }

        // 6.2 搜索筛选
        if search != &quot;&quot; {
            keyword := strings.ToLower(search)
            matchTitle := strings.Contains(strings.ToLower(doc.Title), keyword)
            matchDesc := strings.Contains(strings.ToLower(doc.Description), keyword)

            switch searchMode {
            case domain.SearchModeTitle:
                if !matchTitle {
                    continue
                }
            case domain.SearchModeDescription:
                if !matchDesc {
                    continue
                }
            default:
                if !matchTitle &amp;&amp; !matchDesc {
                    continue
                }
            }
        }

        // 6.3 标签筛选（AND 关系——必须包含所有选中标签）
        if len(tags) &gt; 0 &amp;&amp; !doc.ContainsAllTags(tags) {
            continue
        }

        filtered = append(filtered, doc)
    }

    // 7. 分页
    total := len(filtered)
    start := (pageNum - 1) * limitNum
    end := start + limitNum

    // 边界保护 + 兜底
    if start &gt; total {
        start = total
    }
    if end &gt; total {
        end = total
    }
    if start &gt; end {
        start = end
    }

    pagedDocs := filtered[start:end]

    // 8. 返回结果
    c.JSON(http.StatusOK, gin.H{
        &quot;data&quot;:       pagedDocs,
        &quot;total&quot;:      total,
        &quot;page&quot;:       pageNum,
        &quot;limit&quot;:      limitNum,
        &quot;totalPages&quot;: (total + limitNum - 1) / limitNum,
    })
}
</code></pre>

<h3 id="4-4-主程序入口">4.4 主程序入口</h3>

<pre><code class="language-go">// cmd/server/main.go
package main

import (
    &quot;log&quot;
    &quot;moongate-api/internal/api&quot;
    &quot;moongate-api/internal/loader&quot;

    &quot;github.com/gin-gonic/gin&quot;
)

func main() {
    // 1. 加载数据到内存（独立短文已覆盖）
    store, err := loader.LoadAll(&quot;content/&quot;)
    if err != nil {
        log.Fatal(&quot;加载内容失败:&quot;, err)
    }

    log.Printf(&quot;✅ 加载完成: %d 篇文章\n&quot;, len(store.Docs))

    // 2. 创建 Handler
    docsHandler := api.NewDocsHandler(store.Docs)

    // 3. 设置路由
    r := gin.Default()
    r.GET(&quot;/api/docs&quot;, docsHandler.GetDocs)
    r.GET(&quot;/api/docs/:permalink&quot;, docsHandler.GetDoc)

    // 4. 启动服务
    r.Run(&quot;:8080&quot;)
}
</code></pre>

<h3 id="4-5-标签筛选的算法说明">4.5 标签筛选的算法说明</h3>

<p>多标签筛选有两种实现方式：</p>

<table>
<thead>
<tr>
<th>方式</th>
<th>含义</th>
<th>用户期望</th>
<th>本项目的选择</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>OR（交集）</strong></td>
<td>包含任意一个标签即可</td>
<td>&ldquo;标签 A 或 B 相关的文章&rdquo;</td>
<td>❌</td>
</tr>

<tr>
<td><strong>AND（全包含）</strong></td>
<td>必须包含所有标签</td>
<td>&ldquo;同时涉及 A 和 B 的文章&rdquo;</td>
<td>✅</td>
</tr>
</tbody>
</table>
<p>本项目使用 <strong>AND 关系</strong>，即用户选中多个标签时，只返回同时包含所有这些标签的文章。这种&rdquo;无序全包含&rdquo;的集合逻辑更符合&rdquo;多条件精确筛选&rdquo;的直觉。</p>

<p>正如独立短文《用 Go 重构 Markdown 加载》中所沉淀的领域模型，<code>ContainsAllTags</code> 方法基于 <code>map</code> 实现了 O(n) 的高效集合逻辑：</p>

<pre><code class="language-go">// internal/domain/doc.go（独立短文已覆盖）
func (d *Doc) ContainsAllTags(targetTags []string) bool {
    if len(targetTags) == 0 {
        return true
    }
    if len(targetTags) &gt; len(d.Tags) {
        return false
    }

    tagSet := make(map[string]bool, len(d.Tags))
    for _, t := range d.Tags {
        tagSet[strings.ToLower(t)] = true
    }

    for _, t := range targetTags {
        if !tagSet[strings.ToLower(t)] {
            return false
        }
    }
    return true
}
</code></pre>

<h2 id="五-前端-usedocs-实现">五、前端 useDocs 实现</h2>

<h3 id="5-1-useroutequery-封装">5.1 useRouteQuery 封装</h3>

<p>前三篇已完整实现，此处仅列出与本篇相关的使用方式：</p>

<pre><code class="language-typescript">// composables/useRouteQuery.ts
// 完整实现见系列第二篇

export function useRouteQueryString(name: string, options?: { defaultValue?: string })
export function useRouteQueryNumber(name: string, options?: { defaultValue?: number })
export function useRouteQueryArray(name: string)
</code></pre>

<h3 id="5-2-usedocs-composable">5.2 useDocs Composable</h3>

<pre><code class="language-typescript">// composables/useDocs.ts
import { createSharedComposable } from '@vueuse/core'
import { useRouteQueryString, useRouteQueryNumber, useRouteQueryArray } from './useRouteQuery'

interface DocItem {
    permalink: string
    slug: string
    title: string
    description: string
    level: string
    series: string | null
    tags: string[]
    date: string
    content: string
}

interface DocsResponse {
    data: DocItem[]
    total: number
    page: number
    limit: number
    totalPages: number
}

const DEFAULTS = {
    search: '',
    searchMode: 'all',
    page: 1,
    size: 10,
    viewMode: 1,
    level: '',
} as const

const _useDocs = () =&gt; {
    // URL 同步状态（来自前三篇）
    const searchInput = useRouteQueryString('search', { defaultValue: DEFAULTS.search })
    const searchMode = useRouteQueryString('searchMode', { defaultValue: DEFAULTS.searchMode })
    const page = useRouteQueryNumber('page', { defaultValue: DEFAULTS.page })
    const size = useRouteQueryNumber('size', { defaultValue: DEFAULTS.size })
    const viewMode = useRouteQueryNumber('viewMode', { defaultValue: DEFAULTS.viewMode })
    const level = useRouteQueryString('level', { defaultValue: DEFAULTS.level })
    const tags = useRouteQueryArray('tag')

    // 筛选变化时重置页码
    watch([searchInput, searchMode, level, tags], () =&gt; {
        page.value = DEFAULTS.page
    }, { deep: true })

    // 构建请求参数
    // 注意：使用 URLSearchParams 的多参数格式（?tag=go&amp;tag=vue）
    // 与 Gin 的 c.QueryArray(&quot;tag&quot;) 天然兼容
    const queryParams = computed(() =&gt; {
        const params = new URLSearchParams()
        params.append('page', String(page.value))
        params.append('limit', String(size.value))

        if (searchInput.value.trim()) {
            params.append('search', searchInput.value.trim())
        }
        if (searchMode.value !== DEFAULTS.searchMode) {
            params.append('searchMode', searchMode.value)
        }
        if (level.value) {
            params.append('level', level.value)
        }

        // 标签：展开后逐项添加
        tags.value.forEach((t) =&gt; params.append(&quot;tag&quot;, t));

        return params
    })

    // 调用 Go API
    const { data, pending, refresh, error } = useAsyncData(
        'docs-list',
        async () =&gt; {
            const { public: { apiUrl } } = useRuntimeConfig()
            return await $fetch&lt;DocsResponse&gt;(
                `${apiUrl}/docs?${queryParams.value.toString()}`
            )
        },
        {
            watch: [searchInput, searchMode, page, size, level, tags],
        }
    )

    const resetFilters = () =&gt; {
        searchInput.value = DEFAULTS.search
        searchMode.value = DEFAULTS.searchMode
        page.value = DEFAULTS.page
        size.value = DEFAULTS.size
        viewMode.value = DEFAULTS.viewMode
        level.value = DEFAULTS.level
        tags.value = []
    }

    return {
        searchInput,
        searchMode,
        page,
        size,
        viewMode,
        level,
        tags,
        docs: data,
        pending,
        error,
        refresh,
        resetFilters,
    }
}

export const useDocs = createSharedComposable(_useDocs)
</code></pre>

<h3 id="5-3-组件中使用">5.3 组件中使用</h3>

<pre><code class="language-vue">&lt;!-- pages/docs/index.vue --&gt;
&lt;template&gt;
    &lt;div&gt;
        &lt;SearchHeader
            v-model:search=&quot;searchInput&quot;
            v-model:searchMode=&quot;searchMode&quot;
            v-model:viewMode=&quot;viewMode&quot;
        /&gt;

        &lt;TagFilter v-model:tags=&quot;tags&quot; /&gt;

        &lt;DocList
            :docs=&quot;docs?.data || []&quot;
            :viewMode=&quot;viewMode&quot;
            :pending=&quot;pending&quot;
        /&gt;

        &lt;Pagination
            v-if=&quot;docs &amp;&amp; docs.totalPages &gt; 1&quot;
            v-model:page=&quot;page&quot;
            :totalPages=&quot;docs.totalPages&quot;
            :total=&quot;docs.total&quot;
            :limit=&quot;docs.limit&quot;
        /&gt;
    &lt;/div&gt;
&lt;/template&gt;

&lt;script setup&gt;
const {
    searchInput,
    searchMode,
    page,
    size,
    viewMode,
    level,
    tags,
    docs,
    pending,
    resetFilters,
} = useDocs()
&lt;/script&gt;
</code></pre>

<h2 id="六-数据流完整闭环">六、数据流完整闭环</h2>

<h3 id="6-1-用户操作触发流程">6.1 用户操作触发流程</h3>

<pre><code>用户输入 &quot;nuxt&quot; 到搜索框
    │
    ▼
searchInput 变化（useRouteQueryString）
    │
    ├─ watch 触发 → 更新 URL (?search=nuxt)
    │
    └─ watch 触发 → page 重置为 1
    │
    ▼
useAsyncData 的 watch 检测到依赖变化
    │
    ▼
调用 Go API：GET /api/docs?search=nuxt&amp;searchMode=all&amp;page=1&amp;limit=10
    │
    ▼
Go 后端处理：
    1. 接收参数 search=&quot;nuxt&quot;, searchMode=&quot;all&quot;
    2. 遍历内存中的 39 篇文章，搜索标题和描述
    3. 排序、分页
    4. 返回 JSON
    │
    ▼
前端渲染更新后的列表
</code></pre>

<h3 id="6-2-浏览器后退触发流程">6.2 浏览器后退触发流程</h3>

<pre><code>用户点击浏览器后退按钮
    │
    ▼
URL 从 ?search=nuxt 变为 ?search=vue
    │
    ▼
useRouteQueryRaw 监听到 route.query 变化
    │
    ▼
searchInput.value = &quot;vue&quot;（同步到内部状态）
    │
    ▼
useAsyncData 的 watch 检测到 searchInput 变化
    │
    ▼
重新调用 Go API，数据更新
</code></pre>

<h3 id="6-3-完整数据流图">6.3 完整数据流图</h3>

<pre><code>┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│  用户操作   │───▶│  URL 变化   │───▶│  状态变化   │───▶│  API 请求   │
└─────────────┘    └─────────────┘    └─────────────┘    └──────┬──────┘
                                                                 │
                                                                 ▼
┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│  页面渲染   │◀───│  数据更新   │◀───│  响应返回   │◀───│  Go 后端   │
└─────────────┘    └─────────────┘    └─────────────┘    └─────────────┘
</code></pre>

<h2 id="七-核心设计决策">七、核心设计决策</h2>

<h3 id="7-1-为什么-limit-只允许-10-20-50">7.1 为什么 limit 只允许 10/20/50？</h3>

<pre><code class="language-go">allowedLimits := map[int]bool{10: true, 20: true, 50: true}
if !allowedLimits[limitNum] {
    limitNum = 20
}
</code></pre>

<ul>
<li>给用户明确的选项，减少困惑</li>
<li>防止恶意请求（如 <code>limit=999999</code>）</li>
<li>与前端 UI 选项保持一致</li>
</ul>

<h3 id="7-2-为什么用内存存储">7.2 为什么用内存存储？</h3>

<p>本项目目前 39 篇文章，总体积不足 1MB。内存存储方案：</p>

<ul>
<li>启动加载 &lt; 50ms</li>
<li>API 响应 &lt; 20ms</li>
<li>零外部依赖（不需要数据库）</li>
</ul>

<p>架构设计没有绝对的优劣，只有特定场景下的帕累托最优。</p>

<blockquote>
<p>简单说：对于现阶段 39 篇、不足 1MB 的静态资产，&rdquo;内存即数据库&rdquo;不是技术上最先进的方案，但在&rdquo;开发效率、响应速度、运维成本&rdquo;这三个维度上，它达到了当前场景下的最优平衡。</p>
</blockquote>

<h3 id="7-3-标签参数格式">7.3 标签参数格式</h3>

<p>前端 <code>useRouteQueryArray</code> 使用多参数格式：</p>

<pre><code>?tag=go&amp;tag=vue
</code></pre>

<p>Gin 的 <code>c.QueryArray(&quot;tag&quot;)</code> 原生支持这种格式，直接解析为 <code>[&quot;go&quot;, &quot;vue&quot;]</code>：</p>

<pre><code class="language-go">tags := c.QueryArray(&quot;tag&quot;)  // [&quot;go&quot;, &quot;vue&quot;] ✅
</code></pre>

<h2 id="八-迁移收益">八、迁移收益</h2>

<table>
<thead>
<tr>
<th>指标</th>
<th>迁移前（Nuxt Content）</th>
<th>迁移后（Go API）</th>
</tr>
</thead>

<tbody>
<tr>
<td>内容部署时间</td>
<td>3-4 分钟（完整构建）</td>
<td>~10 秒（同步文件）</td>
</tr>

<tr>
<td>技术透明度</td>
<td>❌ 黑盒</td>
<td>✅ 全透明</td>
</tr>

<tr>
<td>多端支持</td>
<td>❌ 仅 Nuxt</td>
<td>✅ REST API 通用</td>
</tr>

<tr>
<td>API 响应时间</td>
<td>Nuxt 渲染 + 查询</td>
<td>&lt; 20ms（内存读取）</td>
</tr>

<tr>
<td>依赖体积</td>
<td>Content + zod + shiki + 其他</td>
<td>3 个 Go 包</td>
</tr>
</tbody>
</table>

<h2 id="九-结语">九、结语</h2>

<p>本篇将前三篇的 URL 状态管理延伸到了 Go 后端，完成了从前端到后端的完整数据流闭环。</p>

<p><strong>系列四篇的演进路径</strong>：</p>

<table>
<thead>
<tr>
<th>篇目</th>
<th>核心内容</th>
<th>技术栈</th>
</tr>
</thead>

<tbody>
<tr>
<td>1</td>
<td>URL ↔ 状态双向同步原理</td>
<td>Nuxt + Vue Router</td>
</tr>

<tr>
<td>2</td>
<td>useRouteQuery 可复用封装</td>
<td>Nuxt + Composition API</td>
</tr>

<tr>
<td>3</td>
<td>完整文档列表页实现</td>
<td>Nuxt 前端</td>
</tr>

<tr>
<td><strong>4</strong></td>
<td><strong>URL 状态 → Go API → 完整数据流闭环</strong></td>
<td><strong>Nuxt + Go</strong></td>
</tr>
</tbody>
</table>
<p>你现在拥有的是一套完整可复用的全栈架构：</p>

<ul>
<li>前端：URL 驱动的状态管理 + 自动响应式数据获取</li>
<li>后端：类型安全的 Gin API + 清晰的分层架构</li>
<li>数据：内存存储，毫秒级响应</li>
<li>约定：前后端参数统一，多标签筛选支持 AND 关系</li>
</ul>

<p>这套架构已经在实际项目中稳定运行，希望也能帮到正在构建类似系统的开发者。🎯</p>
]]></content:encoded>
      <description><![CDATA[将前三篇的 URL 状态管理延伸至 Go 后端，实现分页、筛选、排序的端到端数据流。涵盖前后端参数约定、Go Gin 框架实践、useAsyncData 自动联动，以及 39 篇文档从 4 分钟到 10 秒的部署优化。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[State Management]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:url-state]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[用 Go 重构 Markdown 加载：一个前端开发者的实战学习笔记]]></title>
      <link>https://moongate.top/docs/go-markdown-loader</link>
      <guid isPermaLink="true">a01c1247-a8c6-47a8-95c3-484ef8939a9f</guid>
      <pubDate>Sat, 11 Jul 2026 20:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>我需要把 30 多篇 Markdown 文档变成 API 数据源。用 Node.js 能写，用 Python 也能写，但我选择了 Go——不是因为性能，而是因为我想在实战中学 Go。这篇文章记录的不仅是一个技术方案，更是一个前端开发者从 Go 新手到 Go 实践者的完整过程。</p>
</blockquote>

<p><strong>适用读者</strong>：想通过真实项目学 Go 的开发者，以及想从 Nuxt Content 迁移出来的用户。</p>

<p><strong>你将学到</strong>：
- 如何用 Go 实现 Markdown 加载与 API 服务（核心逻辑 ~200 行，完整项目 ~400-500 行）
- Go 项目结构设计、接口使用、文件处理的核心实践
- 一种可复制的&rdquo;项目驱动学习&rdquo;方法论</p>

<h2 id="一-为什么用-go">一、为什么用 Go？</h2>

<p>用 Node.js + Express 能写 Markdown API，用 Python + FastAPI 也能写。为什么我偏要用 Go？</p>

<p>原因很朴素：<strong>我想在实战中学 Go。</strong></p>

<p>我不是为了用 Go 而用 Go。而是我本来就有一个真实问题要解决，同时又想提升 Go 能力——两个目标天然契合，那就一起做。</p>

<p>这大概是世界上最有效的学习方式：</p>

<ul>
<li><strong>有真实需求驱动</strong>：你不是在凭空学东西，每一步都有明确目的</li>
<li><strong>有明确交付物</strong>：一个能跑的 API 服务</li>
<li><strong>有可量化的收益</strong>：部署时间从 4 分钟降到 10 秒</li>
<li><strong>有完整的学习闭环</strong>：从设计到实现到上线，全流程走一遍</li>
</ul>

<p>所以这篇文章不只是技术方案，也是一个前端开发者从 Go 新手到 Go 实践者的完整成长记录。</p>

<h2 id="二-背景-我需要一个学习项目">二、背景：我需要一个学习项目</h2>

<p>我之前用 Nuxt Content 管理博客的 39 篇技术文章，整体体验还不错，但也遇到了一些让我想&rdquo;动一动&rdquo;的问题。</p>

<p>配置逐渐复杂，分散在多个地方：</p>

<p><strong><code>content.config.ts</code>——集合定义</strong></p>

<pre><code class="language-typescript">import { defineContentConfig, defineCollection } from '@nuxt/content'
import { z } from 'zod'

export default defineContentConfig({
  collections: {
    docs: defineCollection({
      type: 'page',
      source: 'docs/*.md',
      schema: z.object({
        title: z.string(),
        description: z.string(),
        date: z.date(),
        permalink: z.string(),
        level: z.string(),
        series: z.string(),
        tags: z.array(z.string()),
      })
    }),
    about: defineCollection({
      type: 'page',
      source: 'about/*.md',
      schema: z.object({
        permalink: z.string(),
        title: z.string(),
        description: z.string(),
        date: z.date(),
      })
    })
  }
})
</code></pre>

<p><strong><code>nuxt.config.ts</code>——Markdown 渲染配置</strong></p>

<pre><code class="language-typescript">const bundledLangs = [
  'bash', 'css', 'docker', 'go', 'html',
  'javascript', 'json', 'markdown', 'shell',
  'sql', 'typescript', 'vue', 'xml', 'yaml'
];

export default defineNuxtConfig({
  content: {
    build: {
      markdown: {
        highlight: {
          langs: bundledLangs,      // 所有语言
        },
        toc: {
          depth: 4,
          searchDepth: 3
        },
        theme: {
          default: 'vitesse-light',
          light: 'vitesse-light',
          dark: 'vitesse-dark'
        }
      },
    },
    shiki: {
      bundledThemes: ['vitesse-light', 'vitesse-dark'],
      bundledLangs: bundledLangs,
      defaultTheme: 'material-theme-lighter',
      dynamic: true,  // 懒加载语言
    },
    experimental: {
      nativeSqlite: true
    }
  }
})
</code></pre>

<p>每新增一种内容类型，就要在 <code>content.config.ts</code> 中加一个 collection；每次调整 Markdown 渲染行为，就要改 <code>nuxt.config.ts</code>。配置本身就在累积复杂度。</p>

<p>另外，内容更新需要重新构建前端：GitHub Actions 每次跑 3-4 分钟。单次还好，但改错别字也要等 3-4 分钟，累积起来就不少了。</p>

<p>但说实话，这些都不是非换不可的理由。真正推动我行动的是另一件事：</p>

<p><strong>我需要一个 Go 项目来练手，而这个场景刚好合适。</strong></p>

<p>把&rdquo;内容加载&rdquo;从 Nuxt 中拆出来，用 Go 重写一遍——这个需求足够小（不会因为业务复杂而影响学习），又足够完整（涵盖了 Go 项目开发的核心环节），正好适合作为学习实战项目。</p>

<p><strong>不是 Nuxt Content 不好，是我需要写 Go。</strong></p>

<h2 id="三-项目目标">三、项目目标</h2>

<ol>
<li><strong>学习 Go</strong>：在实战中掌握 Go 的核心特性</li>
<li><strong>内容与代码分离</strong>：改内容不需要重新构建前端</li>
<li><strong>技术透明</strong>：每一行代码都在自己掌控中</li>
<li><strong>轻量依赖</strong>：按需引入，不背全家桶</li>
</ol>

<h2 id="四-整体设计">四、整体设计</h2>

<h3 id="4-1-架构图">4.1 架构图</h3>

<pre><code class="language-text">┌─────────────────────────────────────────────────────────────┐
│  content/                                                  │
│  ├── docs/                                                 │
│  │   ├── article1.md                                       │
│  │   ├── article2.md                                       │
│  │   └── ...                                              │
│  └── about/                                                │
│      └── about.md                                          │
└─────────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────┐
│  Go 程序启动时加载                                          │
│  ├── 遍历所有 .md 文件                                     │
│  ├── 解析 Frontmatter + 正文                               │
│  └── 存入内存 map                                         │
└─────────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────┐
│  Gin API 服务                                              │
│  GET /api/docs      → 返回所有文章                         │
│  GET /api/docs/:id  → 返回单篇文章                         │
└─────────────────────────────────────────────────────────────┘
</code></pre>

<h3 id="4-2-技术选型">4.2 技术选型</h3>

<table>
<thead>
<tr>
<th>组件</th>
<th>选择</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td>Web 框架</td>
<td>Gin</td>
<td>轻量、性能好、适合初学者</td>
</tr>

<tr>
<td>YAML 解析</td>
<td><code>gopkg.in/yaml.v3</code></td>
<td>Go 标准实践</td>
</tr>

<tr>
<td>Markdown 渲染</td>
<td><code>gomarkdown</code></td>
<td>纯 Go，无 CGO 依赖</td>
</tr>
</tbody>
</table>

<h2 id="五-项目结构">五、项目结构</h2>

<pre><code class="language-text">moongate-api/
├── cmd/
│   └── server/
│       └── main.go          # 程序入口
├── internal/
│   ├── domain/              # 领域模型
│   │   ├── doc.go           # Doc 结构体
│   │   └── content.go       # ContentSetter 接口
│   ├── api/                 # HTTP Handler
│   │   └── docs.go          # 文章 API
│   └── loader/              # 数据加载
│       ├── load.go          # 批量加载
│       ├── parse.go         # 单文件解析
│       └── html.go          # Markdown → HTML
├── content/                 # Markdown 内容
│   ├── docs/                # 技术文章
│   └── about/               # 关于页面
└── go.mod
</code></pre>

<p>这个结构是我参考 Go 社区常见实践设计的。<code>cmd/</code> 放入口，<code>internal/</code> 放内部包，<code>domain/</code> 放领域模型——第一次真正理解&rdquo;项目结构&rdquo;为什么这样组织。</p>

<h2 id="六-数据模型">六、数据模型</h2>

<h3 id="6-1-doc-结构体">6.1 Doc 结构体</h3>

<pre><code class="language-go">// internal/domain/doc.go
package domain

import &quot;time&quot;

type Level string

const (
    LevelP1 Level = &quot;P1&quot;
    LevelP2 Level = &quot;P2&quot;
    LevelP3 Level = &quot;P3&quot;
    LevelP4 Level = &quot;P4&quot;
    LevelP5 Level = &quot;P5&quot;
)

type Doc struct {
    Title       string    `yaml:&quot;title&quot; json:&quot;title&quot;`
    Description string    `yaml:&quot;description&quot; json:&quot;description&quot;`
    Date        time.Time `yaml:&quot;date&quot; json:&quot;date&quot;`
    Permalink   string    `yaml:&quot;permalink&quot; json:&quot;permalink&quot;`
    Slug        string    `yaml:&quot;slug&quot; json:&quot;slug&quot;`
    Level       Level     `yaml:&quot;level&quot; json:&quot;level&quot;`
    Series      *string   `yaml:&quot;series&quot; json:&quot;series&quot;`
    Tags        []string  `yaml:&quot;tags&quot; json:&quot;tags&quot;`
    Content     string    `json:&quot;content&quot;`
}
</code></pre>

<p><code>Series</code> 用了 <code>*string</code> 而不是 <code>string</code>，因为文章可能不属于任何系列。用指针后，<code>nil</code> 表示没有系列，JSON 序列化时自动忽略，区分&rdquo;空值&rdquo;和&rdquo;不存在&rdquo;。</p>

<h3 id="6-2-contentsetter-接口">6.2 ContentSetter 接口</h3>

<pre><code class="language-go">// internal/domain/content.go
package domain

type ContentSetter interface {
    SetSlug(slug string)
    SetContent(content string)
}

func (d *Doc) SetSlug(slug string) {
    d.Slug = slug
}

func (d *Doc) SetContent(content string) {
    d.Content = content
}
</code></pre>

<p><code>ParseMarkdown</code> 需要同时支持 <code>Doc</code> 和 <code>About</code> 两种类型。如果为每个类型单独写解析函数，代码会重复。但用接口，只需要定义&rdquo;你能设置 Content 和 Slug&rdquo;这个能力，任何类型只要实现了这两个方法，就能被 <code>ParseMarkdown</code> 处理。</p>

<h2 id="七-核心解析逻辑">七、核心解析逻辑</h2>

<h3 id="7-1-文件格式">7.1 文件格式</h3>

<pre><code class="language-yaml">---
title: Go 后端开发实践
description: 从 Markdown 到内存的完整方案
date: 2026-07-10
permalink: 760e47b3-05bc-4ad6-9d43-ad95426b8127
level: P3
tags:
  - Go
  - Markdown
---
# 正文内容
</code></pre>

<h3 id="7-2-解析函数">7.2 解析函数</h3>

<pre><code class="language-go">// internal/loader/parse.go
package loader

import (
    &quot;fmt&quot;
    &quot;os&quot;
    &quot;path/filepath&quot;
    &quot;strings&quot;

    &quot;gopkg.in/yaml.v3&quot;
)

func ParseMarkdown[T any](filePath string) (T, error) {
    var result T

    // 1. 读取文件
    data, err := os.ReadFile(filePath)
    if err != nil {
        return result, fmt.Errorf(&quot;读取失败: %w&quot;, err)
    }

    // 2. 分割 Frontmatter
    parts := strings.SplitN(string(data), &quot;---&quot;, 3)
    if len(parts) &lt; 3 {
        return result, fmt.Errorf(&quot;无效格式: %s&quot;, filePath)
    }
    frontmatter := parts[1]
    body := parts[2]

    // 3. 解析 YAML
    err = yaml.Unmarshal([]byte(frontmatter), &amp;result)
    if err != nil {
        return result, fmt.Errorf(&quot;解析 frontmatter 失败: %w&quot;, err)
    }

    // 4. 提取文件名作为 Slug
    baseName := filepath.Base(filePath)
    slug := strings.TrimSuffix(baseName, filepath.Ext(baseName))

    // 5. 转换 Markdown 为 HTML
    htmlContent := mdToHTML(body)

    // 6. 通过接口设置 Content 和 Slug
    if setter, ok := any(&amp;result).(ContentSetter); ok {
        setter.SetSlug(slug)
        setter.SetContent(htmlContent)
    }

    return result, nil
}
</code></pre>

<h3 id="7-3-markdown-html">7.3 Markdown → HTML</h3>

<pre><code class="language-go">// internal/loader/html.go
func mdToHTML(body string) string {
    extensions := parser.CommonExtensions | parser.AutoHeadingIDs
    p := parser.NewWithExtensions(extensions)
    doc := p.Parse([]byte(body))

    htmlFlags := html.CommonFlags | html.HrefTargetBlank
    renderer := html.NewRenderer(html.RendererOptions{Flags: htmlFlags})

    return string(markdown.Render(doc, renderer))
}
</code></pre>

<h2 id="八-加载与内存存储">八、加载与内存存储</h2>

<h3 id="8-1-store-结构">8.1 Store 结构</h3>

<pre><code class="language-go">// internal/loader/load.go
type Store struct {
    Docs  map[string]*domain.Doc
    About map[string]*domain.About
}

func LoadAll(contentDir string) (*Store, error) {
    store := &amp;Store{
        Docs:  make(map[string]*domain.Doc),
        About: make(map[string]*domain.About),
    }

    docsPath := filepath.Join(contentDir, &quot;docs&quot;)
    if err := loadDocs(docsPath, store); err != nil {
        return nil, fmt.Errorf(&quot;加载 docs 失败: %w&quot;, err)
    }

    aboutPath := filepath.Join(contentDir, &quot;about&quot;)
    if err := loadAbout(aboutPath, store); err != nil {
        return nil, fmt.Errorf(&quot;加载 about 失败: %w&quot;, err)
    }

    return store, nil
}
</code></pre>

<h3 id="8-2-批量加载">8.2 批量加载</h3>

<pre><code class="language-go">func loadDocs(dir string, store *Store) error {
    files, err := filepath.Glob(filepath.Join(dir, &quot;*.md&quot;))
    if err != nil {
        return err
    }

    for _, file := range files {
        doc, err := ParseMarkdown[domain.Doc](file)
        if err != nil {
            fmt.Printf(&quot;⚠️ 跳过 %s: %v\n&quot;, file, err)
            continue
        }
        store.Docs[doc.Permalink] = &amp;doc
    }

    return nil
}
</code></pre>

<p>单个文件解析失败时，跳过它继续处理其他文件，而不是直接退出。这样即使有某篇文章格式有问题，整个服务仍然可以启动。</p>

<h2 id="九-我在写这段代码时真正理解的事">九、我在写这段代码时真正理解的事</h2>

<h3 id="9-1-接口是能力验证">9.1 接口是能力验证</h3>

<p>之前看 Go 的接口总觉得抽象——&rdquo;方法集合&rdquo;、&rdquo;隐式实现&rdquo;这些概念在文档里看懂了，但没有实感。</p>

<p>直到写了这行代码：</p>

<pre><code class="language-go">if setter, ok := any(&amp;result).(ContentSetter); ok {
    setter.SetSlug(slug)
    setter.SetContent(htmlContent)
}
</code></pre>

<p>我才真正理解：<strong>接口是用来验证&rdquo;你有没有这个能力&rdquo;的。</strong></p>

<p><code>ParseMarkdown</code> 不关心传入的是什么类型。它只问一个问题：&rdquo;你能设置 Content 和 Slug 吗？能的话，我就帮你设置。&rdquo;</p>

<p>这就是 Go 接口的精髓。</p>

<h3 id="9-2-指针的选择">9.2 指针的选择</h3>

<pre><code class="language-go">store.Docs[doc.Permalink] = &amp;doc
</code></pre>

<p>用指针还是值？这个问题困扰了我很久。通过这个项目我发现，存指针不仅节省内存，更重要的是多个地方可以共享同一份数据。</p>

<p><code>Series</code> 用了 <code>*string</code> 而不是 <code>string</code>，也是同样的道理——需要区分&rdquo;没有系列&rdquo;和&rdquo;系列名为空&rdquo;。</p>

<h3 id="9-3-泛型让代码更干净">9.3 泛型让代码更干净</h3>

<pre><code class="language-go">func ParseMarkdown[T any](filePath string) (T, error)
</code></pre>

<p>没有泛型的话，我要么为 Doc 和 About 各写一个解析函数（代码重复），要么用 <code>interface{}</code> 然后到处做类型断言（不优雅且不安全）。泛型让代码既安全又简洁。</p>

<h2 id="十-提供-http-api">十、提供 HTTP API</h2>

<pre><code class="language-go">// internal/api/docs.go
type DocsHandler struct {
    Store map[string]*domain.Doc
}

func (h *DocsHandler) GetDocs(c *gin.Context) {
    docs := make([]*domain.Doc, 0, len(h.Store))
    for _, doc := range h.Store {
        docs = append(docs, doc)
    }

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

    c.JSON(http.StatusOK, docs)
}

func (h *DocsHandler) GetDoc(c *gin.Context) {
    permalink := c.Param(&quot;permalink&quot;)
    doc, ok := h.Store[permalink]
    if !ok {
        c.JSON(http.StatusNotFound, gin.H{&quot;error&quot;: &quot;文章不存在&quot;})
        return
    }
    c.JSON(http.StatusOK, doc)
}
</code></pre>

<pre><code class="language-go">// cmd/server/main.go
func main() {
    store, err := loader.LoadAll(&quot;content/&quot;)
    if err != nil {
        log.Fatal(&quot;加载内容失败:&quot;, err)
    }

    log.Printf(&quot;✅ 加载完成: %d 篇文章\n&quot;, len(store.Docs))

    docsHandler := api.NewDocsHandler(store.Docs)

    r := gin.Default()
    r.GET(&quot;/api/docs&quot;, docsHandler.GetDocs)
    r.GET(&quot;/api/docs/:permalink&quot;, docsHandler.GetDoc)

    r.Run(&quot;:8080&quot;)
}
</code></pre>

<h2 id="十一-这个项目的运行数据">十一、这个项目的运行数据</h2>

<pre><code>📊 运行数据
├── 文章数量: 39 篇
├── 总大小: 582KB
├── 加载耗时: &lt; 50ms
├── 内存占用: &lt; 5MB
├── API 响应: &lt; 20ms
├── 部署时间: ~10 秒（仅同步文件）
└── 核心代码: ~200 行（完整项目 ~400-500 行）
</code></pre>

<h2 id="十二-给想用项目学-go-的人">十二、给想用项目学 Go 的人</h2>

<p>如果你也在学一门新语言，我建议你：</p>

<p><strong>从自己最熟悉的领域开始。</strong></p>

<p>我写过很多年前端，对 Markdown 文件的格式、结构、内容组织非常熟悉。选择&rdquo;用 Go 读取 Markdown 文件并提供 API&rdquo;作为第一个项目，是因为这个场景我足够了解，不会因为业务本身的复杂性分散学习的注意力。</p>

<p>适合作为 Go 实战练手的场景：</p>

<ul>
<li>用 Go 写一个 Markdown 文档 API 服务（本文的场景）</li>
<li>用 Go 写一个静态站点生成器</li>
<li>用 Go 写一个 RSS 订阅聚合器</li>
<li>用 Go 写一个 JSON 到 CSV 的转换工具</li>
<li>用 Go 写一个日志收集和查询服务</li>
</ul>

<p>核心原则：<strong>从小处着手，让项目驱动学习。</strong> 一个能跑起来、能解决问题的小项目，比看 10 本教程都管用。</p>

<h2 id="十三-结语">十三、结语</h2>

<p>这是一个很小的项目。39 篇文章，200 行核心代码，一个简单的 API 服务。</p>

<p>但它对我的意义远远超过代码本身。</p>

<p>这是我第一个真正意义上的 Go 项目——不是教程里的 demo，不是 fork 下来的例子，而是为了解决真实问题，从零到一写出来的东西。</p>

<p><strong>技术从来都是在实践中学会的。</strong></p>

<p>如果你也想学 Go，别只停留在看文档、敲 demo 的阶段。找一个你熟悉的小场景，用 Go 写一个能跑起来的东西。不用多复杂，能把事情做成，就已经是最好的学习。</p>

<p>🎯</p>

<hr>

<h2 id="附-完整项目结构">附：完整项目结构</h2>

<pre><code class="language-text">moongate-api/
├── cmd/
│   └── server/
│       └── main.go          # 程序入口
├── internal/
│   ├── domain/              # 领域模型
│   │   ├── doc.go           # Doc 结构体
│   │   ├── about.go         # About 结构体
│   │   └── content.go       # ContentSetter 接口
│   ├── api/                 # HTTP Handler
│   │   ├── docs.go          # 文章 API
│   │   └── about.go         # 关于页面 API
│   └── loader/              # 数据加载
│       ├── load.go          # 批量加载
│       ├── parse.go         # 单文件解析
│       └── html.go          # Markdown → HTML
├── content/                 # Markdown 内容
│   ├── docs/                # 技术文章
│   └── about/               # 关于页面
├── go.mod
└── go.sum
</code></pre>
]]></content:encoded>
      <description><![CDATA[从 Nuxt Content 全家桶到 Go 重构代码——一个前端开发者如何用真实项目驱动 Go 语言学习，实现轻量、透明、可控的 Markdown 数据加载方案。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P3</dc:subject>
      
    </item>

    <item>
      <title><![CDATA[GORM 入门实战：用 Gin + GORM 写一个图书管理 API]]></title>
      <link>https://moongate.top/docs/gorm-gin-crud-tutorial</link>
      <guid isPermaLink="true">96d254d9-5123-4e7f-8518-2c7b46c37018</guid>
      <pubDate>Fri, 03 Jul 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="适合读者">适合读者</h2>

<ul>
<li>已掌握 Go 基础语法</li>
<li>想学 GORM 但不知道从哪开始</li>
<li>想看到一个能直接运行的完整项目</li>
</ul>

<h2 id="完整项目结构">完整项目结构</h2>

<pre><code class="language-text">gin-demo/
├── main.go           # 入口文件
├── db/
│   └── db.go         # 数据库连接
├── models/
│   └── book.go       # 数据模型
├── handlers/
│   └── book.go       # 业务逻辑（CRUD）
└── go.mod
</code></pre>

<h2 id="第一章-项目初始化">第一章：项目初始化</h2>

<p><strong>目标：</strong> 创建项目目录，安装依赖。</p>

<pre><code class="language-bash">mkdir gin-demo
cd gin-demo
go mod init gin-demo
</code></pre>

<p><strong>安装依赖：</strong></p>

<pre><code class="language-bash"># Web 框架
go get -u github.com/gin-gonic/gin

# ORM 库 + PostgreSQL 驱动
go get -u gorm.io/gorm
go get -u gorm.io/driver/postgres
</code></pre>

<p><strong>数据库驱动说明：</strong></p>

<p>本文使用 PostgreSQL 作为示例数据库。如果你使用的是其他数据库，替换对应的驱动即可：</p>

<table>
<thead>
<tr>
<th>数据库</th>
<th>安装命令</th>
<th>DSN 格式</th>
</tr>
</thead>

<tbody>
<tr>
<td>PostgreSQL</td>
<td><code>go get -u gorm.io/driver/postgres</code></td>
<td><code>host=localhost user=postgres dbname=books sslmode=disable</code></td>
</tr>

<tr>
<td>MySQL</td>
<td><code>go get -u gorm.io/driver/mysql</code></td>
<td><code>user:pass@tcp(localhost:3306)/dbname?charset=utf8mb4&amp;parseTime=True</code></td>
</tr>

<tr>
<td>SQLite</td>
<td><code>go get -u gorm.io/driver/sqlite</code></td>
<td><code>./data.db</code></td>
</tr>
</tbody>
</table>

<blockquote>
<p>Gin 负责处理 HTTP 请求和路由，GORM 负责数据库操作，两者各司其职，缺一不可。后续所有代码都依赖这两个库。</p>
</blockquote>

<h2 id="第二章-连接数据库">第二章：连接数据库</h2>

<p><strong>目标：</strong> 建立数据库连接，在项目启动时初始化。</p>

<p><strong>连接流程：</strong></p>

<ol>
<li><code>main.go</code> 启动时调用 <code>db.InitDB()</code></li>
<li><code>db.InitDB()</code> 构造 DSN 字符串，通过 <code>gorm.Open()</code> 建立连接</li>
<li>连接成功返回 <code>*gorm.DB</code> 实例，失败则退出程序</li>
</ol>

<hr>

<p>创建 <code>db/db.go</code>：</p>

<pre><code class="language-go">package db

import (
    &quot;fmt&quot;
    &quot;log&quot;

    &quot;gorm.io/driver/postgres&quot;
    &quot;gorm.io/gorm&quot;
)

var DB *gorm.DB

func InitDB() {
    host := &quot;localhost&quot;
    port := 5432
    user := &quot;postgres&quot;
    password := &quot;123456&quot;
    dbname := &quot;books&quot;

    dsn := fmt.Sprintf(&quot;host=%s port=%d user=%s password=%s dbname=%s sslmode=disable&quot;,
        host, port, user, password, dbname)

    var err error
    DB, err = gorm.Open(postgres.Open(dsn), &amp;gorm.Config{})
    if err != nil {
        log.Fatal(&quot;数据库连接失败：&quot;, err)
    }
    log.Println(&quot;数据库连接成功&quot;)
}
</code></pre>

<p><strong>代码说明：</strong></p>

<table>
<thead>
<tr>
<th>代码</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>var DB *gorm.DB</code></td>
<td>声明全局 DB 变量，供其他包使用</td>
</tr>

<tr>
<td><code>gorm.Open(postgres.Open(dsn), &amp;gorm.Config{})</code></td>
<td>建立数据库连接</td>
</tr>

<tr>
<td><code>log.Fatal</code></td>
<td>连接失败时终止程序，避免后续代码执行</td>
</tr>
</tbody>
</table>
<p><strong>DSN 参数说明：</strong></p>

<table>
<thead>
<tr>
<th>参数</th>
<th>示例</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>host</code></td>
<td><code>localhost</code></td>
<td>数据库主机地址</td>
</tr>

<tr>
<td><code>port</code></td>
<td><code>5432</code></td>
<td>PostgreSQL 默认端口</td>
</tr>

<tr>
<td><code>user</code></td>
<td><code>postgres</code></td>
<td>数据库用户名</td>
</tr>

<tr>
<td><code>password</code></td>
<td><code>123456</code></td>
<td>数据库密码</td>
</tr>

<tr>
<td><code>dbname</code></td>
<td><code>books</code></td>
<td>数据库名称</td>
</tr>

<tr>
<td><code>sslmode</code></td>
<td><code>disable</code></td>
<td>本地开发禁用 SSL</td>
</tr>
</tbody>
</table>

<blockquote>
<p><strong>注意事项：</strong></p>

<ul>
<li>PostgreSQL 需要<strong>手动创建数据库</strong>：<code>CREATE DATABASE books;</code></li>
<li>GORM 的 <code>AutoMigrate</code> 能自动创建表，但<strong>不能自动创建数据库</strong></li>
<li><code>sslmode=disable</code> 仅用于本地开发，生产环境应开启 SSL</li>
</ul>
</blockquote>

<h2 id="第三章-定义数据模型">第三章：定义数据模型</h2>

<p><strong>目标：</strong> 用 Go 结构体定义数据库表结构。</p>

<p>创建 <code>models/book.go</code>：</p>

<pre><code class="language-go">package models

import &quot;gorm.io/gorm&quot;

type Book struct {
    gorm.Model
    Title  string  `json:&quot;title&quot; gorm:&quot;not null&quot;`
    Author string  `json:&quot;author&quot; gorm:&quot;not null&quot;`
    Price  float64 `json:&quot;price&quot;`
}
</code></pre>

<p><strong>字段说明：</strong></p>

<ul>
<li><code>gorm.Model</code>：内置了 <code>ID</code>、<code>CreatedAt</code>、<code>UpdatedAt</code>、<code>DeletedAt</code> 四个字段</li>
<li><code>gorm:&quot;not null&quot;</code>：对应数据库的 <code>NOT NULL</code> 约束</li>
<li><code>json:&quot;title&quot;</code>：指定 JSON 序列化时的字段名</li>
</ul>

<h2 id="第四章-自动迁移">第四章：自动迁移</h2>

<p><strong>目标：</strong> 程序启动时自动创建或更新表结构。</p>

<p>在 <code>main.go</code> 中添加：</p>

<pre><code class="language-go">package main

import (
    &quot;log&quot;
    &quot;gin-demo/db&quot;
    &quot;gin-demo/models&quot;
    &quot;github.com/gin-gonic/gin&quot;
)

func main() {
    // 1. 连接数据库
    db.InitDB()

    // 2. 自动迁移（建表）
    if err := db.DB.AutoMigrate(&amp;models.Book{}); err != nil {
        log.Fatal(&quot;迁移失败：&quot;, err)
    }

    // 3. 启动 Gin 服务
    r := gin.Default()
    // ... 路由
    r.Run(&quot;:8080&quot;)
}
</code></pre>

<p><strong>注意事项：</strong></p>

<ul>
<li><code>AutoMigrate</code> 只会创建不存在的表，不会删除已有字段</li>
<li>字段类型变更时，GORM 不会自动修改已有字段类型</li>
</ul>

<h2 id="第五章-创建图书">第五章：创建图书</h2>

<p><strong>目标：</strong> 实现 <code>POST /books</code> 接口，接收 JSON 请求并存入数据库。</p>

<blockquote>
<p><strong>注：</strong> 后续第六、七、八章的所有 CRUD 函数均追加至同一个文件 <code>handlers/book.go</code> 中。开头统一为：</p>
</blockquote>

<pre><code class="language-go">package handlers

import (
    &quot;gin-demo/db&quot;
    &quot;gin-demo/models&quot;
    &quot;net/http&quot;

    &quot;github.com/gin-gonic/gin&quot;
)
</code></pre>

<p><strong>创建图书：</strong></p>

<pre><code class="language-go">func CreateBook(c *gin.Context) {
    var book models.Book

    // 1. 绑定 JSON 请求体
    if err := c.ShouldBindJSON(&amp;book); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;请发送合法的 JSON&quot;})
        return
    }

    // 2. 插入数据库
    result := db.DB.Create(&amp;book)
    if result.Error != nil {
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;创建失败&quot;})
        return
    }

    // 3. 返回创建的数据
    c.JSON(http.StatusCreated, book)
}
</code></pre>

<p><strong>测试：</strong></p>

<pre><code class="language-bash">curl -X POST http://localhost:8080/books \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;title&quot;:&quot;Go语言实战&quot;,&quot;author&quot;:&quot;张三&quot;,&quot;price&quot;:59.9}'
</code></pre>

<h2 id="第六章-查询图书">第六章：查询图书</h2>

<p><strong>目标：</strong> 实现查询列表和查询单条两个接口。</p>

<pre><code class="language-go">// 查询所有图书
func GetBooks(c *gin.Context) {
    var books []models.Book
    db.DB.Find(&amp;books)
    c.JSON(http.StatusOK, books)
}

// 查询单条图书
func GetBook(c *gin.Context) {
    id := c.Param(&quot;id&quot;)

    var book models.Book
    result := db.DB.First(&amp;book, id)
    if result.Error != nil {
        c.JSON(http.StatusNotFound, gin.H{&quot;error&quot;: &quot;图书不存在&quot;})
        return
    }

    c.JSON(http.StatusOK, book)
}
</code></pre>

<p><strong>测试：</strong></p>

<pre><code class="language-bash"># 查询所有
curl http://localhost:8080/books

# 查询单条
curl http://localhost:8080/books/1
</code></pre>

<h2 id="第七章-更新图书">第七章：更新图书</h2>

<p><strong>目标：</strong> 实现 <code>PUT /books/:id</code> 接口。</p>

<pre><code class="language-go">func UpdateBook(c *gin.Context) {
    id := c.Param(&quot;id&quot;)

    // 1. 检查图书是否存在
    var book models.Book
    if result := db.DB.First(&amp;book, id); result.Error != nil {
        c.JSON(http.StatusNotFound, gin.H{&quot;error&quot;: &quot;图书不存在&quot;})
        return
    }

    // 2. 绑定 JSON 请求体
    var input models.Book
    if err := c.ShouldBindJSON(&amp;input); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;请发送合法的 JSON&quot;})
        return
    }

    // 3. 更新字段
    db.DB.Model(&amp;book).Updates(input)

    // 4. 返回更新后的数据
    c.JSON(http.StatusOK, book)
}
</code></pre>

<p><strong>零值陷阱与进阶思考：</strong></p>

<p><code>Updates</code> 传入<strong>结构体</strong>时，GORM 默认会忽略零值字段（<code>0</code>、<code>&quot;&quot;</code>、<code>false</code> 等）。这是设计如此，通常能满足 90% 的业务场景。但如果你确实需要将某个字段更新为 <code>0</code> 或 <code>&quot;&quot;</code>，有两种方案：</p>

<p><strong>方案一：用 <code>Select</code> 强制指定字段</strong></p>

<pre><code class="language-go">db.DB.Model(&amp;book).Select(&quot;Price&quot;).Updates(input)
</code></pre>

<p><strong>方案二：用 <code>map[string]interface{}</code>（更通用，推荐）</strong></p>

<pre><code class="language-go">// 前端只传需要更新的字段，零值也能正常更新
var inputMap map[string]interface{}
if err := c.ShouldBindJSON(&amp;inputMap); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;请发送合法的 JSON&quot;})
    return
}
db.DB.Model(&amp;book).Updates(inputMap)
</code></pre>

<p>方案二的优势在于：前端传什么就更新什么，不会因为零值问题导致意外行为，且在字段较多的场景下更灵活。</p>

<p><strong>测试：</strong></p>

<pre><code class="language-bash">curl -X PUT http://localhost:8080/books/1 \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;price&quot;:69.9}'
</code></pre>

<h2 id="第八章-删除图书">第八章：删除图书</h2>

<p><strong>目标：</strong> 实现 <code>DELETE /books/:id</code> 接口。</p>

<p>因为 <code>Book</code> 使用了 <code>gorm.Model</code>，GORM 默认执行<strong>软删除</strong>。这意味着记录不会真正从数据库中移除，只是 <code>deleted_at</code> 字段被设为当前时间，查询时默认被过滤掉。</p>

<pre><code class="language-go">func DeleteBook(c *gin.Context) {
    id := c.Param(&quot;id&quot;)

    // 执行软删除
    result := db.DB.Delete(&amp;models.Book{}, id)
    if result.RowsAffected == 0 {
        c.JSON(http.StatusNotFound, gin.H{&quot;error&quot;: &quot;图书不存在&quot;})
        return
    }

    c.JSON(http.StatusOK, gin.H{&quot;message&quot;: &quot;删除成功&quot;})
}
</code></pre>

<p><strong>软删除行为解析：</strong></p>

<table>
<thead>
<tr>
<th>操作</th>
<th>GORM 行为</th>
</tr>
</thead>

<tbody>
<tr>
<td>第一次 DELETE</td>
<td>设置 <code>deleted_at = NOW()</code>，不再出现在查询中</td>
</tr>

<tr>
<td>再次 DELETE 同一条</td>
<td>由于 <code>deleted_at IS NOT NULL</code>，GORM 找不到记录，<code>RowsAffected == 0</code>，返回&rdquo;图书不存在&rdquo;</td>
</tr>
</tbody>
</table>

<blockquote>
<p><strong>注意：</strong> 软删除后，默认的 <code>First</code> / <code>Find</code> 查询会自动加上 <code>deleted_at IS NULL</code> 条件，所以被软删除的记录不会出现在列表中。</p>
</blockquote>

<p><strong>如果需要查询已删除的记录：</strong></p>

<pre><code class="language-go">db.DB.Unscoped().First(&amp;book, id)
</code></pre>

<p><strong>如果需要物理删除（彻底删除）：</strong></p>

<pre><code class="language-go">func DeleteBookPermanently(c *gin.Context) {
    id := c.Param(&quot;id&quot;)
    // Unscoped() 绕过软删除，执行物理删除
    result := db.DB.Unscoped().Delete(&amp;models.Book{}, id)
    if result.RowsAffected == 0 {
        c.JSON(http.StatusNotFound, gin.H{&quot;error&quot;: &quot;图书不存在&quot;})
        return
    }
    c.JSON(http.StatusOK, gin.H{&quot;message&quot;: &quot;物理删除成功&quot;})
}
</code></pre>

<p><strong>测试：</strong></p>

<pre><code class="language-bash">curl -X DELETE http://localhost:8080/books/1
</code></pre>

<h2 id="第九章-注册路由">第九章：注册路由</h2>

<p><strong>目标：</strong> 把所有路由注册到 Gin 引擎。</p>

<p>更新 <code>main.go</code>：</p>

<pre><code class="language-go">package main

import (
    &quot;log&quot;
    &quot;gin-demo/db&quot;
    &quot;gin-demo/handlers&quot;
    &quot;gin-demo/models&quot;
    &quot;github.com/gin-gonic/gin&quot;
)

func main() {
    // 连接数据库
    db.InitDB()

    // 自动迁移
    if err := db.DB.AutoMigrate(&amp;models.Book{}); err != nil {
        log.Fatal(&quot;迁移失败：&quot;, err)
    }

    r := gin.Default()

    // RESTful API 路由
    r.POST(&quot;/books&quot;, handlers.CreateBook)
    r.GET(&quot;/books&quot;, handlers.GetBooks)
    r.GET(&quot;/books/:id&quot;, handlers.GetBook)
    r.PUT(&quot;/books/:id&quot;, handlers.UpdateBook)
    r.DELETE(&quot;/books/:id&quot;, handlers.DeleteBook)

    r.Run(&quot;:8080&quot;)
}
</code></pre>

<h2 id="第十章-总结">第十章：总结</h2>

<p><strong>你学到的核心知识：</strong></p>

<table>
<thead>
<tr>
<th>操作</th>
<th>GORM 方法</th>
<th>对应 SQL</th>
</tr>
</thead>

<tbody>
<tr>
<td>创建</td>
<td><code>db.Create(&amp;book)</code></td>
<td><code>INSERT INTO ...</code></td>
</tr>

<tr>
<td>查询所有</td>
<td><code>db.Find(&amp;books)</code></td>
<td><code>SELECT * FROM ...</code></td>
</tr>

<tr>
<td>查询单条</td>
<td><code>db.First(&amp;book, id)</code></td>
<td><code>SELECT * FROM ... WHERE id = ?</code></td>
</tr>

<tr>
<td>更新</td>
<td><code>db.Model(&amp;book).Updates(input)</code></td>
<td><code>UPDATE ... SET ...</code></td>
</tr>

<tr>
<td>软删除</td>
<td><code>db.Delete(&amp;book)</code></td>
<td><code>UPDATE ... SET deleted_at = NOW()</code></td>
</tr>

<tr>
<td>物理删除</td>
<td><code>db.Unscoped().Delete(&amp;book)</code></td>
<td><code>DELETE FROM ...</code></td>
</tr>

<tr>
<td>查询已删除</td>
<td><code>db.Unscoped().First(&amp;book, id)</code></td>
<td><code>SELECT * FROM ... WHERE id = ?</code>（不限软删除）</td>
</tr>
</tbody>
</table>
<p><strong>进阶方向：</strong></p>

<ul>
<li>关联查询：<code>Preload</code>、<code>Joins</code></li>
<li>事务：<code>db.Transaction()</code></li>
<li>查询条件：<code>Where</code>、<code>Order</code>、<code>Limit</code>、<code>Offset</code></li>
<li>钩子函数：<code>BeforeCreate</code>、<code>AfterUpdate</code></li>
</ul>
]]></content:encoded>
      <description><![CDATA[从零搭建一个完整的图书管理 API，涵盖 GORM 的 CRUD、软删除、零值陷阱等核心知识点，附带完整代码和测试命令]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[PostgreSQL]]></category>
      <category><![CDATA[ORM]]></category>
      <dc:subject>P2</dc:subject>
      
    </item>

    <item>
      <title><![CDATA[VitePress 文档站接入已有 Docker 基础设施：子域名部署（扩展篇）]]></title>
      <link>https://moongate.top/docs/vitepress-docker-existing-infrastructure-subdomain-deployment</link>
      <guid isPermaLink="true">177f4b4d-186b-4162-8969-930042f7b804</guid>
      <pubDate>Sun, 07 Jun 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>本文记录如何将 VitePress 文档站部署为子域名，并接入已有 docker-compose 管理的动态站点（如 Nuxt 博客），共用同一 Caddy 反向代理。</p>
</blockquote>

<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共六篇，覆盖从静态网站到生产级 Docker 部署及服务集成的全流程：</p>

<ol>
<li><p><a href="./static-site-auto-deploy"><strong>静态网站自动化部署（静态篇）</strong></a>
—— 纯前端资源的自动化发布，Caddy 自动 HTTPS 和 SPA 路由支持。</p></li>

<li><p><a href="dynamic-site-auto-deploy" target="_blank"><strong>动态网站自动化部署（动态篇）</strong></a>
—— 后端服务进程管理、环境变量注入、数据库迁移，结合 Caddy 反向代理。</p></li>

<li><p><a href="docker-quickstart-auto-deploy" target="_blank"><strong>Docker 极简入门（入门篇）</strong></a>
—— 从零开始用 Docker + GitHub Actions 实现 CI/CD 流水线。</p></li>

<li><p><a href="docker-production-auto-deploy" target="_blank"><strong>Docker 生产级部署（进阶篇）</strong></a>
—— 多容器编排、健康检查、数据库迁移、自动 HTTPS，打造可靠的生产环境。</p></li>

<li><p><a href="./umami-integration-auto-deploy"><strong>自托管 Umami 分析服务与 Nuxt 4 项目集成指南（扩展篇）</strong></a>
—— 在现有 Docker 生产环境中集成 Umami 分析服务，实现自动化数据跟踪与安全加固。</p></li>

<li><p><a href="./vitepress-docker-existing-infrastructure-subdomain-deployment"><strong>VitePress 文档站接入已有 Docker 基础设施：子域名部署（扩展篇）</strong></a>
—— 将 VitePress 静态文档站作为子域名接入现有 Docker 基础设施，复用 Caddy 反向代理与网络。</p></li>
</ol>

<h2 id="前置说明">📌 前置说明</h2>

<p><strong>本文的部署环境</strong>：</p>

<ul>
<li><strong>主站点</strong>：Nuxt 构建的个人博客（<code>moongate.top</code>），通过 docker-compose 管理（PostgreSQL + Nuxt + Caddy）</li>
<li><strong>文档站</strong>：VitePress 构建的组件库文档，部署到子域名 <code>vue.moongate.top</code></li>
<li><strong>目标</strong>：将文档站容器接入现有的 Caddy 反向代理，复用同一套 Docker 网络和域名基础设施</li>
</ul>

<p><strong>如果你是从零开始部署</strong>（没有现有 Caddy、没有 docker-compose）：</p>

<ul>
<li>本文部分内容（如网络连接）可简化</li>
<li>直接 <code>docker run -p 80:80</code> 即可运行</li>
</ul>

<p>本文的核心价值在于 <strong>VitePress 静态站如何与现有 Docker 基础设施整合</strong>，而非从零搭建。</p>

<blockquote>
<p><strong>关于静态文件目录</strong>：本文沿用系列教程的自定义目录风格（<code>/docs</code>）。如果你习惯使用 Caddy 默认目录（<code>/srv</code> 或 <code>/usr/share/caddy</code>），可相应调整，不影响最终效果。</p>
</blockquote>

<h2 id="适用场景">🎯 适用场景</h2>

<ul>
<li>✅ 已有 docker-compose 管理的动态站点（如 Nuxt 博客），需要将静态文档站作为子域名接入</li>
<li>✅ 希望多个站点共用同一 Caddy 反向代理和 HTTPS 证书</li>
<li>✅ 使用 Docker 部署静态网站，但不熟悉网络配置</li>
<li>✅ 遇到 pnpm 供应链安全检查报错，需要解决方案</li>
<li>✅ 需要将 VitePress 文档站接入自动化部署</li>
</ul>

<h2 id="版本声明">📌 版本声明</h2>

<table>
<thead>
<tr>
<th>工具</th>
<th>版本</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td>Node.js</td>
<td>24.x</td>
<td>最新 LTS</td>
</tr>

<tr>
<td>pnpm</td>
<td>10.x</td>
<td>高性能包管理器</td>
</tr>

<tr>
<td>VitePress</td>
<td>1.6.x</td>
<td>文档生成器</td>
</tr>

<tr>
<td>Docker</td>
<td>29.x</td>
<td>容器运行时</td>
</tr>

<tr>
<td>Caddy</td>
<td>2.8+</td>
<td>静态文件服务器 + 反向代理</td>
</tr>

<tr>
<td>GitHub Actions</td>
<td>最新</td>
<td>CI/CD 平台</td>
</tr>
</tbody>
</table>

<h2 id="系统架构-接入现有基础设施">🏗️ 系统架构（接入现有基础设施）</h2>

<blockquote>
<p><strong>背景</strong>：本文档站部署在已有 Nuxt 博客的基础设施之上。主站点 <code>moongate.top</code> 由 Nuxt + PostgreSQL + Caddy 组成，通过 docker-compose 管理。文档站作为子域名 <code>vue.moongate.top</code> 接入同一 Caddy。
<strong>架构说明</strong>：实际运行中存在两个 Caddy：</p>

<ul>
<li><strong>网关 Caddy</strong>（docker-compose 管理）：接收外部 HTTPS 请求，反向代理到内部服务</li>
<li><strong>文档站 Caddy</strong>（<code>moongate-vue</code> 容器内）：仅负责服务静态文件，监听 80 端口</li>
</ul>

<p>由于两者在同一 Docker 网络中，网关 Caddy 通过 <code>reverse_proxy moongate-vue:80</code> 直接通信，无需暴露端口到宿主机。容器间通信为内网明文 HTTP（80 端口），外层 HTTPS 证书由网关 Caddy 自动托管解析。</p>
</blockquote>

<pre><code class="language-text">┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 本地开发 │────▶│ GitHub Actions │────▶│ 阿里云 ACR │
│ git push │ │ 自动构建镜像 │ │ 镜像仓库 │
└─────────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 阿里云 ECS 服务器 │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Docker 网络: my-blog_blog-network │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Caddy │ │ Nuxt 应用 │ │ PostgreSQL │ │ │
│ │ │ (网关代理) │◀──▶│ (博客) │◀──▶│ (数据库) │ │ │
│ │ └──────┬──────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │ │
│ │ │ ┌─────────────────────────────────────────┐ │ │
│ │ └──│ moongate-vue │ │ │
│ │ │ (VitePress + 内部 Caddy 静态服务) │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
</code></pre>

<h2 id="与动态应用的核心差异">🔄 与动态应用的核心差异</h2>

<table>
<thead>
<tr>
<th>维度</th>
<th>Nuxt 动态应用</th>
<th>VitePress 静态站</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>构建产物</strong></td>
<td><code>.output</code> 服务端代码</td>
<td>纯静态 HTML/CSS/JS</td>
</tr>

<tr>
<td><strong>运行环境</strong></td>
<td>Node.js 运行时</td>
<td>静态文件服务器</td>
</tr>

<tr>
<td><strong>基础镜像</strong></td>
<td><code>node:alpine</code></td>
<td><code>caddy:alpine</code></td>
</tr>

<tr>
<td><strong>端口映射</strong></td>
<td>需要暴露端口</td>
<td>内部网络访问，无需端口</td>
</tr>

<tr>
<td><strong>数据库</strong></td>
<td>PostgreSQL</td>
<td>无</td>
</tr>

<tr>
<td><strong>环境变量</strong></td>
<td>多个敏感配置</td>
<td>无</td>
</tr>

<tr>
<td><strong>管理方式</strong></td>
<td>docker-compose 编排</td>
<td>独立 <code>docker run</code> + 网络连接</td>
</tr>

<tr>
<td><strong>Caddy 代理</strong></td>
<td><code>reverse_proxy app:3000</code></td>
<td><code>reverse_proxy moongate-vue:80</code></td>
</tr>
</tbody>
</table>

<h2 id="第一步-dockerfile-编写">🐳 第一步：Dockerfile 编写</h2>

<p>VitePress 是静态站点生成器，构建后输出纯静态文件。因此 Dockerfile 分为两个阶段：</p>

<pre><code class="language-bash"># ==================== 构建阶段 ====================
FROM node:24-alpine AS builder

WORKDIR /app

# 安装 git（VitePress 1.x 需要 git 来获取最后更新时间）
RUN apk add --no-cache git

# 安装 pnpm
RUN corepack enable &amp;&amp; corepack prepare pnpm@latest --activate

# 复制依赖文件
COPY package.json pnpm-lock.yaml ./

# ⚠️ 关键点：使用 --ignore-scripts 跳过 pnpm 的供应链安全检查
# 否则会触发 ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION
RUN pnpm install --frozen-lockfile --ignore-scripts

# 复制源代码并构建
COPY . .
RUN pnpm docs:build

# ==================== 运行阶段 ====================
FROM caddy:alpine

# 复制构建产物到自定义目录 /docs
# 注意：需要在 Caddyfile 中通过 root * /docs 指定根目录
COPY --from=builder /app/docs/.vitepress/dist /docs

EXPOSE 80
</code></pre>

<h3 id="关键踩坑点">⚠️ 关键踩坑点</h3>

<h4 id="1-git-依赖">1. Git 依赖</h4>

<p>VitePress 1.x 的默认主题会调用 <code>git</code> 命令获取文件的最后更新时间。如果容器中没有 git，构建会报错：</p>

<pre><code class="language-bash">[vitepress] spawn git ENOENT
</code></pre>

<p><strong>解决方案</strong>：在构建阶段安装 git：</p>

<pre><code class="language-bash">RUN apk add --no-cache git
</code></pre>

<h4 id="2-pnpm-供应链安全检查">2. pnpm 供应链安全检查</h4>

<p>pnpm 10.x 默认启用供应链安全检查，拦截发布时间 &lt; 24 小时的包：</p>

<pre><code class="language-bash">[ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION] @types/node@25.9.2 was published ... within the minimumReleaseAge cutoff
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: esbuild@0.21.5, vitepress-theme-demoblock@3.1.3
</code></pre>

<p><strong>解决方案</strong>：使用 <code>--ignore-scripts</code> 参数跳过检查：</p>

<pre><code class="language-bash">RUN pnpm install --frozen-lockfile --ignore-scripts
</code></pre>

<h4 id="3-文件权限">3. 文件权限</h4>

<p>Caddy 官方镜像默认以 <code>caddy</code> 非 root 用户运行（安全考虑）。如果构建产物权限不正确，容器启动后 Caddy 可能无法读取静态文件，导致 <code>403 Forbidden</code>。</p>

<p><strong>解决方案</strong>：在 Caddyfile 中确保 <code>root</code> 目录可读，或使用 <code>--chown</code> 参数：</p>

<pre><code class="language-bash">COPY --from=builder --chown=caddy:caddy /app/docs/.vitepress/dist /docs
</code></pre>

<p>如果你使用自定义目录 <code>/docs</code>，也可以在 Caddyfile 中正常配置 <code>root * /docs</code>，Caddy 会自动处理权限。</p>

<h2 id="第二步-github-actions-工作流">🚀 第二步：GitHub Actions 工作流</h2>

<pre><code class="language-yaml">name: Deploy Docs To Aliyun

on:
  push:
    branches: [main]
    paths:
      - &quot;docs/**&quot;
      - &quot;package.json&quot;
      - &quot;pnpm-lock.yaml&quot;
      - &quot;Dockerfile&quot;
      - &quot;.github/workflows/deploy-docs.yml&quot;

jobs:
  ci:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Aliyun ACR
        uses: docker/login-action@v3
        with:
          registry: ${{ secrets.ACR_REGISTRY }}
          username: ${{ secrets.ACR_USERNAME }}
          password: ${{ secrets.ACR_PASSWORD }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: Dockerfile
          push: true
          tags: |
            ${{ secrets.ACR_REGISTRY }}/moongate/moongate-vue:latest
            ${{ secrets.ACR_REGISTRY }}/moongate/moongate-vue:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Deploy to Server via SSH
        uses: appleboy/ssh-action@v1.0.0
        env:
          ACR_REGISTRY: ${{ secrets.ACR_REGISTRY }}
          ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
          ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          envs: ACR_REGISTRY, ACR_USERNAME, ACR_PASSWORD
          script: |
            set -e

            # 登录 ACR
            echo &quot;$ACR_PASSWORD&quot; | docker login &quot;$ACR_REGISTRY&quot; -u &quot;$ACR_USERNAME&quot; --password-stdin

            # 拉取最新镜像
            docker pull $ACR_REGISTRY/moongate/moongate-vue:latest

            # 强制删除旧容器（避免容器挂起导致的死锁）
            docker rm -f moongate-vue || true

            # 运行新容器（连接到博客的 Docker 网络）
            docker run -d \
              --name moongate-vue \
              --restart unless-stopped \
              --network my-blog_blog-network \
              $ACR_REGISTRY/moongate/moongate-vue:latest

            # 清理旧镜像
            docker image prune -f --filter &quot;until=24h&quot;
</code></pre>

<h3 id="与动态应用-workflow-的差异">与动态应用 workflow 的差异</h3>

<table>
<thead>
<tr>
<th>差异点</th>
<th>Nuxt 动态应用</th>
<th>VitePress 静态站</th>
</tr>
</thead>

<tbody>
<tr>
<td>触发路径</td>
<td>全量触发</td>
<td>仅 <code>docs/**</code> 变更触发</td>
</tr>

<tr>
<td>构建参数</td>
<td>需要 <code>NUXT_PUBLIC_SITE_URL</code></td>
<td>无需</td>
</tr>

<tr>
<td>部署脚本</td>
<td>docker-compose</td>
<td>独立 <code>docker run</code> + <code>--network</code></td>
</tr>

<tr>
<td>环境变量</td>
<td>多个 Secrets 传递</td>
<td>无需</td>
</tr>

<tr>
<td>数据库迁移</td>
<td>有</td>
<td>无</td>
</tr>

<tr>
<td>容器更新</td>
<td><code>docker compose up -d</code></td>
<td><code>docker rm -f</code> + <code>docker run</code></td>
</tr>
</tbody>
</table>

<h2 id="第三步-服务器端配置">🌐 第三步：服务器端配置</h2>

<h3 id="3-1-首次部署-运行容器并连接网络">3.1 首次部署：运行容器并连接网络</h3>

<pre><code class="language-bash"># 拉取镜像
docker pull crpi-xxx/moongate/moongate-vue:latest

# 运行容器（加入博客的网络）
docker run -d \
  --name moongate-vue \
  --restart unless-stopped \
  --network my-blog_blog-network \
  crpi-xxx/moongate/moongate-vue:latest
</code></pre>

<blockquote>
<p><strong>注意</strong>：不需要 <code>-p</code> 端口映射。Caddy 通过 Docker 内部网络直接访问容器，不经过宿主机端口。</p>
</blockquote>

<h3 id="3-2-caddyfile-配置">3.2 Caddyfile 配置</h3>

<p>在 <code>/var/www/my-site/Caddyfile</code> 中添加子域名配置，并指定静态文件根目录：</p>

<pre><code class="language-bash"># 组件库文档站
vue.moongate.top {
    reverse_proxy moongate-vue:80
    encode gzip zstd
}

# 可选：添加 www 重定向
www.vue.moongate.top {
    redir https://vue.moongate.top{uri} permanent
}
</code></pre>

<blockquote>
<p><strong>说明</strong>：</p>

<ul>
<li>网关 Caddy 只负责反向代理，不负责静态文件服务</li>
<li>静态文件服务由文档站容器内的 Caddy 负责</li>
<li>容器间通信使用内网 HTTP，HTTPS 证书由网关 Caddy 统一托管</li>
</ul>
</blockquote>

<h3 id="3-3-重启网关-caddy">3.3 重启网关 Caddy</h3>

<pre><code class="language-bash">cd /var/www/my-site
docker compose restart caddy
</code></pre>

<h3 id="3-4-验证网络连接">3.4 验证网络连接</h3>

<pre><code class="language-bash"># 查看容器网络
docker inspect moongate-vue | grep -A5 &quot;Networks&quot;

# 从网关 Caddy 容器测试连接
docker exec my-blog-caddy wget -qO- http://moongate-vue:80 | head -5
</code></pre>

<h2 id="第四步-dns-配置">🌍 第四步：DNS 配置</h2>

<p>在阿里云 DNS 控制台添加 A 记录：</p>

<table>
<thead>
<tr>
<th>记录类型</th>
<th>主机记录</th>
<th>记录值</th>
</tr>
</thead>

<tbody>
<tr>
<td>A</td>
<td><code>vue</code></td>
<td><code>你的服务器公网IP</code></td>
</tr>
</tbody>
</table>
<p>等待 DNS 生效后，访问 <code>https://vue.moongate.top</code> 即可看到文档站。</p>

<h2 id="第五步-完整配置清单">🔧 第五步：完整配置清单</h2>

<h3 id="github-secrets">GitHub Secrets</h3>

<table>
<thead>
<tr>
<th>Secret</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>ACR_REGISTRY</code></td>
<td>阿里云 ACR 仓库地址</td>
</tr>

<tr>
<td><code>ACR_USERNAME</code></td>
<td>阿里云用户名</td>
</tr>

<tr>
<td><code>ACR_PASSWORD</code></td>
<td>ACR 固定密码</td>
</tr>

<tr>
<td><code>SERVER_HOST</code></td>
<td>服务器 IP</td>
</tr>

<tr>
<td><code>SERVER_USER</code></td>
<td>SSH 用户名</td>
</tr>

<tr>
<td><code>SSH_PRIVATE_KEY</code></td>
<td>SSH 私钥</td>
</tr>
</tbody>
</table>

<h3 id="服务器端命令速查">服务器端命令速查</h3>

<pre><code class="language-bash"># 首次部署：运行容器并连接网络
docker run -d \
  --name moongate-vue \
  --restart unless-stopped \
  --network my-blog_blog-network \
  crpi-xxx/moongate/moongate-vue:latest

# 常用运维命令
docker logs moongate-vue          # 查看日志
docker restart moongate-vue       # 重启容器
docker stop moongate-vue          # 停止容器
docker start moongate-vue         # 启动容器
docker rm -f moongate-vue          # 强制删除容器

# 网络管理
docker network connect my-blog_blog-network moongate-vue  # 连接网络
docker network disconnect my-blog_blog-network moongate-vue  # 断开网络
</code></pre>

<h2 id="踩坑记录">🐛 踩坑记录</h2>

<table>
<thead>
<tr>
<th>问题</th>
<th>原因</th>
<th>解决方案</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>spawn git ENOENT</code></td>
<td>容器中没有 git</td>
<td><code>RUN apk add --no-cache git</code></td>
</tr>

<tr>
<td><code>ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION</code></td>
<td>pnpm 供应链安全检查</td>
<td><code>--ignore-scripts</code></td>
</tr>

<tr>
<td><code>ERR_PNPM_IGNORED_BUILDS</code></td>
<td>构建脚本被忽略</td>
<td>同上</td>
</tr>

<tr>
<td>Caddy 返回 403</td>
<td>静态文件权限不足</td>
<td>使用 <code>--chown=caddy:caddy</code> 或检查目录权限</td>
</tr>

<tr>
<td>Caddy 返回 502</td>
<td>文档站容器未连接到 Caddy 的网络</td>
<td><code>docker network connect</code></td>
</tr>

<tr>
<td>域名无法访问</td>
<td>DNS 未配置</td>
<td>添加 A 记录</td>
</tr>

<tr>
<td>Caddy 无法解析 <code>moongate-vue</code> 主机名</td>
<td>容器不在同一网络</td>
<td>使用 <code>--network</code> 参数运行</td>
</tr>

<tr>
<td>CI 部署时容器名冲突</td>
<td>旧容器未完全清理</td>
<td>使用 <code>docker rm -f</code> 强制删除</td>
</tr>
</tbody>
</table>

<h2 id="部署流程图">📈 部署流程图</h2>

<pre><code class="language-text">┌─────────────────────────────────────────────────────────────────────┐
│                           开发者本地                                 │
│  $ git add docs/                                                     │
│  $ git commit -m &quot;update docs&quot;                                       │
│  $ git push origin main                                              │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│                        GitHub Actions                                │
│  1. 检出代码                                                         │
│  2. 安装 pnpm、依赖                                                   │
│  3. 执行 pnpm docs:build                                             │
│  4. 构建 Docker 镜像                                                 │
│  5. 推送到阿里云 ACR                                                  │
│  6. SSH 到服务器                                                      │
│  7. docker pull &amp;&amp; docker rm -f &amp;&amp; docker run --network ...         │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│                          阿里云 ECS                                   │
│  1. 拉取最新镜像                                                      │
│  2. 强制删除旧容器                                                    │
│  3. 启动新容器（加入博客网络）                                         │
│  4. 网关 Caddy 代理 vue.moongate.top → moongate-vue:80               │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│                           用户访问                                    │
│                    https://vue.moongate.top                          │
└─────────────────────────────────────────────────────────────────────┘
</code></pre>

<h2 id="与动态应用部署的优劣对比">📊 与动态应用部署的优劣对比</h2>

<table>
<thead>
<tr>
<th>维度</th>
<th>VitePress 静态站</th>
<th>Nuxt 动态应用</th>
</tr>
</thead>

<tbody>
<tr>
<td>部署复杂度</td>
<td>⭐ 极低</td>
<td>⭐⭐⭐⭐ 较高</td>
</tr>

<tr>
<td>构建时间</td>
<td>~30 秒</td>
<td>~2 分钟</td>
</tr>

<tr>
<td>镜像体积</td>
<td>~50 MB</td>
<td>~200 MB</td>
</tr>

<tr>
<td>运行时内存</td>
<td>~20 MB</td>
<td>~150 MB</td>
</tr>

<tr>
<td>启动时间</td>
<td>&lt;1 秒</td>
<td>~5 秒</td>
</tr>

<tr>
<td>端口配置</td>
<td>无需暴露端口</td>
<td>需要端口映射</td>
</tr>

<tr>
<td>网络配置</td>
<td>需连接到 Caddy 网络</td>
<td>compose 自动管理</td>
</tr>

<tr>
<td>容器管理</td>
<td>独立 <code>docker run</code></td>
<td>docker-compose 编排</td>
</tr>

<tr>
<td>可维护性</td>
<td>极高</td>
<td>中等</td>
</tr>
</tbody>
</table>

<h2 id="总结">🎉 总结</h2>

<p>VitePress 文档站接入已有 Docker 基础设施的关键步骤：</p>

<ol>
<li><strong>Dockerfile</strong>：使用多阶段构建 + Caddy 静态服务器，注意：

<ul>
<li>git 依赖（VitePress 需要）</li>
<li>pnpm 供应链安全检查（<code>--ignore-scripts</code>）</li>
<li>文件权限（可选 <code>--chown=caddy:caddy</code>）</li>
</ul></li>
<li><strong>镜像推送</strong>：推送到阿里云 ACR，利用 GitHub Actions 缓存加速</li>
<li><strong>容器部署</strong>：使用 <code>docker rm -f</code> 强制替换，<code>--network</code> 接入现有网络</li>
<li><strong>Caddy 代理</strong>：在网关 Caddyfile 中添加子域名配置，指定 <code>root * /docs</code></li>
<li><strong>DNS 解析</strong>：为子域名添加 A 记录</li>
</ol>

<p><strong>架构亮点</strong>：</p>

<ul>
<li>双 Caddy 架构：网关 Caddy 处理 HTTPS 和路由，容器内 Caddy 仅服务静态文件</li>
<li>内网通信：容器间通过 Docker 内部网络通信，无需暴露端口到宿主机</li>
<li>统一证书管理：HTTPS 证书由网关 Caddy 统一托管，子域名自动继承</li>
<li>系列风格统一：沿用自定义目录 <code>/docs</code>，与之前教程保持一致</li>
</ul>

<p>与动态应用不同，静态站部署更简单、资源占用更少。本文完整记录了接入已有 Docker 基础设施的全过程，这套流程同样适用于任何静态站点生成器（如 Hexo、Hugo、Astro 等），只需调整构建命令和静态文件输出目录即可。</p>
]]></content:encoded>
      <description><![CDATA[本文记录如何将 VitePress 文档站部署为子域名，并接入已有 docker-compose 管理的动态站点（如 Nuxt 博客），共用同一 Caddy 反向代理。]]></description>
      <category><![CDATA[Caddy]]></category>
      <category><![CDATA[Docker]]></category>
      <category><![CDATA[CI/CD]]></category>
      <dc:subject>P4</dc:subject>
      <dc:relation><![CDATA[series:deployment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[VitePress + vitepress-theme-demoblock 3.x 配置指南]]></title>
      <link>https://moongate.top/docs/demoblock-3x-missing-manual</link>
      <guid isPermaLink="true">1129affd-8dc5-4dd2-8c76-59a477dc5c06</guid>
      <pubDate>Wed, 03 Jun 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>本文基于 <code>vitepress-theme-demoblock@3.x</code> 版本，详细解释每一步的作用和背后的原理。</p>
</blockquote>

<h2 id="背景">背景</h2>

<p>为 Vue 3 组件库搭建文档站，需要支持：</p>

<ul>
<li>组件实时预览</li>
<li>代码展示 + 折叠</li>
<li>一键复制代码</li>
</ul>

<p>官方推荐 <code>vitepress-theme-demoblock</code>，但配置过程中发现：<strong>官方文档虽然写全了步骤，但没有解释「为什么」，导致容易踩坑。</strong></p>

<p>本文补充官方缺失的「连接逻辑」。</p>

<h2 id="官方文档写了什么">官方文档写了什么</h2>

<p>根据<a href="https://github.com/xinlei3166/vitepress-theme-demoblock" target="_blank">官方文档</a>，配置步骤如下：</p>

<h3 id="1-安装">1. 安装</h3>

<pre><code class="language-bash">pnpm add -D vitepress-theme-demoblock
</code></pre>

<h3 id="2-配置-config-ts">2. 配置 config.ts</h3>

<pre><code class="language-ts">import {
  demoblockPlugin,
  demoblockVitePlugin,
} from &quot;vitepress-theme-demoblock&quot;;

export default {
  markdown: {
    config: (md) =&gt; {
      md.use(demoblockPlugin);
    },
  },
  vite: {
    plugins: [demoblockVitePlugin()],
  },
};
</code></pre>

<h3 id="3-配置主题">3. 配置主题</h3>

<pre><code class="language-ts">import DefaultTheme from &quot;vitepress/theme&quot;;
import &quot;vitepress-theme-demoblock/dist/theme/styles/index.css&quot;;
import { useComponents } from &quot;./useComponents&quot;; // ← 从本地导入

export default {
  ...DefaultTheme,
  enhanceApp(ctx) {
    DefaultTheme.enhanceApp(ctx);
    useComponents(ctx.app);
  },
};
</code></pre>

<h3 id="4-添加脚本">4. 添加脚本</h3>

<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;docs:dev&quot;: &quot;yarn run register:components &amp;&amp; vitepress dev docs&quot;,
    &quot;register:components&quot;: &quot;vitepress-rc&quot;
  }
}
</code></pre>

<h2 id="官方没说的是什么">官方没说的是什么</h2>

<h3 id="问题一-usecomponents-从哪来">问题一：<code>useComponents</code> 从哪来？</h3>

<p>官方写的是 <code>import { useComponents } from './useComponents'</code>，但：</p>

<ul>
<li>项目中一开始并没有这个文件</li>
<li>官方没说明这个文件是<strong>生成的</strong></li>
</ul>

<p><strong>答案</strong>：<code>vitepress-rc</code> 命令会生成这个文件。</p>

<h3 id="问题二-vitepress-rc-是做什么的">问题二：<code>vitepress-rc</code> 是做什么的？</h3>

<p>官方只是列出了一个命令，没说明作用。</p>

<p><strong>答案</strong>：<code>vitepress-rc</code> 会：</p>

<ol>
<li>扫描你的组件目录（默认读取 <code>docs/.vitepress/components</code> 或项目根目录的 <code>components.json</code>）</li>
<li>自动注册 demoblock 需要的 Vue 组件</li>
<li>生成 <code>.vitepress/theme/useComponents.ts</code> 文件</li>
</ol>

<blockquote>
<p><strong>注意</strong>：如果你的组件库在 <code>src/components</code> 目录下，需要配置 <code>vitepress-rc</code> 的扫描路径，否则会生成空文件。</p>
</blockquote>

<h3 id="问题三-为什么要先运行-register-components">问题三：为什么要先运行 <code>register:components</code>？</h3>

<p>官方脚本写了 <code>&amp;&amp; vitepress dev docs</code>，但没解释为什么要先运行。</p>

<p><strong>答案</strong>：因为 VitePress 启动时需要读取 <code>./useComponents.ts</code>，而这个文件必须先由 <code>vitepress-rc</code> 生成。</p>

<h3 id="问题四-不运行会怎样">问题四：不运行会怎样？</h3>

<p>直接运行 <code>vitepress dev docs</code> 会报错：</p>

<pre><code class="language-bash">Uncaught SyntaxError: Cannot find module './useComponents'
</code></pre>

<h2 id="完整配置流程-含解释">完整配置流程（含解释）</h2>

<h3 id="第一步-安装依赖">第一步：安装依赖</h3>

<pre><code class="language-bash">pnpm add -D vitepress vitepress-theme-demoblock
</code></pre>

<h3 id="第二步-配置-config-ts">第二步：配置 config.ts</h3>

<pre><code class="language-ts">// docs/.vitepress/config.ts
import { defineConfig } from &quot;vitepress&quot;;
import {
  demoblockPlugin,
  demoblockVitePlugin,
} from &quot;vitepress-theme-demoblock&quot;;

export default defineConfig({
  title: &quot;My UI&quot;,
  description: &quot;Vue 3 组件库&quot;,

  markdown: {
    config: (md) =&gt; {
      md.use(demoblockPlugin); // Markdown-it 插件：解析 :::demo 语法
    },
  },

  vite: {
    plugins: [demoblockVitePlugin()], // Vite 插件：编译 demo 中的 Vue 组件
  },
});
</code></pre>

<blockquote>
<p><strong>💡 原理解析</strong>：两个插件的分工</p>

<ul>
<li><code>demoblockPlugin</code>（Markdown-it 插件）：负责「偷梁换柱」。把 Markdown 里的 <code>:::demo</code> 语法转换成自定义的 <code>&lt;demo-block&gt;</code> 标签，将代码作为字符串和实时运行的组件传入。</li>
<li><code>demoblockVitePlugin</code>（Vite 插件）：负责「降维打击」。在编译阶段把抽离出来的虚拟 Vue 代码真正编译成浏览器可执行的 JS/Vue 组件。</li>
</ul>
</blockquote>

<h3 id="第三步-配置主题">第三步：配置主题</h3>

<pre><code class="language-ts">// docs/.vitepress/theme/index.ts
import DefaultTheme from &quot;vitepress/theme&quot;;
import &quot;vitepress-theme-demoblock/dist/theme/styles/index.css&quot;;
// 注意：从本地导入，不是从包导入
import { useComponents } from &quot;./useComponents&quot;;

export default {
  ...DefaultTheme,
  enhanceApp(ctx) {
    DefaultTheme.enhanceApp(ctx);
    useComponents(ctx.app); // 注册 demoblock 组件
  },
};
</code></pre>

<h3 id="第四步-添加脚本">第四步：添加脚本</h3>

<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;docs:dev&quot;: &quot;pnpm run register:components &amp;&amp; vitepress dev docs&quot;,
    &quot;docs:build&quot;: &quot;pnpm run register:components &amp;&amp; vitepress build docs&quot;,
    &quot;register:components&quot;: &quot;vitepress-rc&quot;
  }
}
</code></pre>

<p><strong>脚本说明</strong>：</p>

<table>
<thead>
<tr>
<th>脚本</th>
<th>作用</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>vitepress-rc</code></td>
<td>生成 <code>./useComponents.ts</code> 文件</td>
</tr>

<tr>
<td><code>register:components</code></td>
<td>封装注册命令</td>
</tr>

<tr>
<td><code>docs:dev</code></td>
<td>先注册，再启动开发服务器</td>
</tr>
</tbody>
</table>

<h3 id="第五步-首次运行">第五步：首次运行</h3>

<pre><code class="language-bash">pnpm docs:dev
</code></pre>

<p>执行过程：</p>

<ol>
<li>运行 <code>vitepress-rc</code> → 生成 <code>useComponents.ts</code></li>
<li>启动 VitePress → 读取生成的 <code>useComponents.ts</code></li>
<li>文档站正常运行</li>
</ol>

<h3 id="第六步-编写文档">第六步：编写文档</h3>

<p><strong>v3 正确写法</strong>（不支持 <code>:::demo</code> 后面加描述文字）：</p>

<pre><code class="language-md">## 基础用法

这是按钮的基础用法描述。（直接写在 `:::demo` 上方）

:::demo
\`\`\`vue
&lt;template&gt;
&lt;Button type=&quot;primary&quot;&gt;主要按钮&lt;/Button&gt;
&lt;/template&gt;

&lt;script setup&gt;
import { Button } from 'my-ui'
&lt;/script&gt;

\`\`\`
:::
</code></pre>

<h2 id="官方文档-vs-本文补充">官方文档 vs 本文补充</h2>

<table>
<thead>
<tr>
<th>维度</th>
<th>官方文档</th>
<th>本文补充</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>useComponents</code> 从哪来</td>
<td>只写了导入语句</td>
<td>说明是 <code>vitepress-rc</code> 生成</td>
</tr>

<tr>
<td><code>vitepress-rc</code> 的作用</td>
<td>只写了命令</td>
<td>解释作用和生成的文件</td>
</tr>

<tr>
<td>命令执行顺序</td>
<td>写了 <code>&amp;&amp;</code></td>
<td>解释为什么要先执行</td>
</tr>

<tr>
<td>不执行的后果</td>
<td>没写</td>
<td>说明会报错</td>
</tr>

<tr>
<td>生成的文件</td>
<td>没提</td>
<td>讨论 Git 提交策略</td>
</tr>

<tr>
<td>两个插件的分工</td>
<td>没提</td>
<td>解释原理</td>
</tr>
</tbody>
</table>

<h2 id="常见问题">常见问题</h2>

<h3 id="q1-vitepress-rc-生成的文件要提交到-git-吗">Q1：<code>vitepress-rc</code> 生成的文件要提交到 Git 吗？</h3>

<p><strong>建议：提交 <code>useComponents.ts</code>，忽略 <code>cache/</code></strong></p>

<pre><code class="language-gitignore"># .gitignore
docs/.vitepress/cache/
# 注意：不要忽略 useComponents.ts
</code></pre>

<p><strong>理由</strong>：</p>

<ul>
<li><code>useComponents.ts</code> 内容稳定（只在组件列表变化时改变），体积很小（几 KB）</li>
<li>提交后团队成员 <code>git clone</code> 即可直接运行 <code>pnpm docs:dev</code>，无需额外生成</li>
<li>CI/CD 构建时也不需要重新运行 <code>vitepress-rc</code>，减少构建步骤</li>
</ul>

<p><strong>如果不提交</strong>：可以使用 <code>prepare</code> 钩子在 <code>pnpm install</code> 时自动生成：</p>

<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;prepare&quot;: &quot;pnpm run register:components&quot;
  }
}
</code></pre>

<p>但需要注意每次 <code>install</code> 都会重新生成，<code>cache/deps</code> 也会更新，可能导致 Git 状态变化。</p>

<h3 id="q2-更新依赖后需要重新运行吗">Q2：更新依赖后需要重新运行吗？</h3>

<p>建议重新运行一次：</p>

<pre><code class="language-bash">pnpm run register:components
</code></pre>

<h3 id="q3-demo-后面的描述文字不生效">Q3：<code>:::demo</code> 后面的描述文字不生效？</h3>

<p>3.x 版本<strong>不支持</strong>描述文字：</p>

<table>
<thead>
<tr>
<th>版本</th>
<th>正确写法</th>
</tr>
</thead>

<tbody>
<tr>
<td>v2</td>
<td><code>:::demo 这是描述</code></td>
</tr>

<tr>
<td>v3</td>
<td><code>:::demo</code>（不支持描述文字）</td>
</tr>
</tbody>
</table>
<p><strong>平替方案</strong>：在 <code>:::demo</code> 上方直接写标准 Markdown 文本即可。</p>

<pre><code class="language-md">### 基础用法

这是按钮的基础用法描述。

:::demo
\`\`\`vue
&lt;template&gt;
&lt;Button type=&quot;primary&quot;&gt;主要按钮&lt;/Button&gt;
&lt;/template&gt;
\`\`\`
:::
</code></pre>

<h3 id="q4-vitepress-rc-扫描不到我的组件怎么办">Q4：<code>vitepress-rc</code> 扫描不到我的组件怎么办？</h3>

<p><code>vitepress-rc</code> 默认读取 <code>docs/.vitepress/components</code> 目录或项目根目录的 <code>components.json</code> 配置。</p>

<p>如果你的组件库在 <code>src/components</code> 目录下，有两种解决方案：</p>

<p><strong>方案 A</strong>：创建软链接</p>

<pre><code class="language-bash">mkdir -p docs/.vitepress/components
ln -s ../../../src/components docs/.vitepress/components/ui
</code></pre>

<p><strong>方案 B</strong>：创建 <code>components.json</code></p>

<pre><code class="language-json">{
  &quot;componentsDir&quot;: &quot;./src/components&quot;
}
</code></pre>

<h2 id="总结">总结</h2>

<p>官方文档<strong>信息是全的</strong>，但问题是<strong>缺少「连接逻辑」</strong>：</p>

<ul>
<li>写了 <code>import from './useComponents'</code>，没写这个文件从哪来</li>
<li>写了 <code>vitepress-rc</code> 命令，没写它的作用</li>
<li>写了 <code>&amp;&amp;</code> 串联命令，没写为什么要先执行</li>
</ul>

<p><strong>核心要点</strong>：</p>

<ol>
<li><code>vitepress-rc</code> 会生成 <code>useComponents.ts</code> 文件</li>
<li>必须先运行 <code>vitepress-rc</code>，再启动 VitePress</li>
<li>3.x 版本不支持 <code>:::demo</code> 后面的描述文字，需要用普通 Markdown 代替</li>
<li>从本地导入 <code>useComponents</code>，不是从包导入</li>
<li><code>demoblockPlugin</code>（Markdown-it）负责解析语法，<code>demoblockVitePlugin</code>（Vite）负责编译组件</li>
</ol>

<p>希望这篇文章能帮你理解 demoblock 的配置原理，少踩坑。</p>
]]></content:encoded>
      <description><![CDATA[详解 demoblock 3.x 的配置原理、踩坑记录和最佳实践，补充官方文档缺失的「连接逻辑」。]]></description>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P1</dc:subject>
      
    </item>

    <item>
      <title><![CDATA[从代码到 npm：Vue 3 组件库发布实战与避坑指南]]></title>
      <link>https://moongate.top/docs/component-library-publishing</link>
      <guid isPermaLink="true">6b5acf3d-2c8c-421b-ab33-404ad767f18b</guid>
      <pubDate>Wed, 20 May 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>记录 <code>moongate-vue</code> 组件库从构建到发布的完整流程，以及 2FA 验证、网络代理解密、npm 源自动化管理等实战经验。</p>
</blockquote>

<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共五篇，覆盖从设计令牌到 npm 发布的 Vue 3 组件库开发全流程：</p>

<ol>
<li><p><a href="./design-tokens-vs-atomic-css"><strong>设计令牌 vs 原子化 CSS：失败整合与融合之道（理念篇）</strong></a>
—— 用 UnoCSS 映射设计令牌的失败经历，量化对比后得出设计令牌优先的结论。</p></li>

<li><p><a href="./css-first-component-library"><strong>CSS 优先 + 组件薄封装：一个 25KB 组件库的极简实践（架构篇）</strong></a>
—— 四层 CSS 架构、极简 Vue 组件、Vite 多入口构建、体积预算验证，单组件极简实现。</p></li>

<li><p><a href="./vue-component-api-design"><strong>Vue 3 简单组件开发实战：从 Button 组件看 API 设计（简单组件篇）</strong></a>
—— Props 定义、变体系统、尺寸取舍、插槽设计、状态管理、无障碍支持及与主流 UI 库对比。</p></li>

<li><p><a href="./complex-component-api-design"><strong>Vue 3 复杂组件开发实战：Select 与 Pagination 的 API 设计（复杂组件篇）</strong></a>
—— 数据格式适配、类型回溯、可搜索/多选、ARIA 键盘导航、组合式函数抽离及 SSR 适配，揭示工业级细节。</p></li>

<li><p><a href="./component-library-publishing"><strong>从代码到 npm：Vue 3 组件库发布实战与避坑指南（发布篇）</strong></a>
—— nrm 源管理、2FA 配置、WebAuthn 网络代理避坑、本地链接测试、自动化脚本及工业级发布检查清单。</p></li>
</ol>

<h2 id="一-引言">一、引言</h2>

<p>组件库开发完成后，最后一步也是至关重要的一步：<strong>发布到 npm</strong>。这个过程看似简单，实则暗藏不少现代工程包袱：包名冲突、2FA 强校验、安全密钥（WebAuthn）在特定网络下的卡死、源镜像频繁切换……以及——<strong>构建产物的正确性</strong>。</p>

<p>本文作为系列收官之作，记录了我从 <strong>v0.0.1</strong> 一路到 <strong>v1.5.0</strong> 的发布实战。</p>

<h2 id="二-发布前的准备">二、发布前的准备</h2>

<h3 id="2-1-构建与验证链路-v1-5-0-实际">2.1 构建与验证链路（v1.5.0 实际）</h3>

<p>v1.5.0 的 <code>build</code> 脚本是一条自动化验证流水线：</p>

<pre><code class="language-bash"># package.json scripts
&quot;build&quot;: &quot;pnpm run clean &amp;&amp; vite build &amp;&amp; pnpm run build:types &amp;&amp; pnpm run clean:dts &amp;&amp; pnpm run copy:reset &amp;&amp; pnpm run verify:build &amp;&amp; pnpm run check:size&quot;,
&quot;prepublishOnly&quot;: &quot;pnpm build &amp;&amp; pnpm test&quot;
</code></pre>

<p>每一步的职责：</p>

<table>
<thead>
<tr>
<th>脚本</th>
<th>作用</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>vite build</code></td>
<td>打包 JS + CSS 产物（27 组件多入口）</td>
</tr>

<tr>
<td><code>build:types</code></td>
<td>vue-tsc 生成 <code>.d.ts</code> 类型声明</td>
</tr>

<tr>
<td><code>clean:dts</code></td>
<td>清理产物中的冗余类型文件</td>
</tr>

<tr>
<td><code>copy:reset</code></td>
<td>复制 <code>reset.css</code> 到 dist</td>
</tr>

<tr>
<td><code>verify:build</code></td>
<td>验证<strong>每个组件入口</strong>的 .js / .d.ts / style.css / reset.css 存在 + 导出名正确</td>
</tr>

<tr>
<td><code>check:size</code></td>
<td>体积预算检查（Min+Gzip ≤ 25KB）</td>
</tr>

<tr>
<td><code>prepublishOnly</code></td>
<td>发布前强制 build + test</td>
</tr>
</tbody>
</table>
<p><code>verify-build.js</code> 的核心逻辑——防止漏发布某个组件入口：</p>

<pre><code class="language-javascript">// 每个组件：dist/&lt;kebab&gt;.js 存在 + dist/exports/&lt;Name&gt;.d.ts 存在 + 导出名正确
for (const [componentName, kebabName] of Object.entries(componentEntries)) {
  const jsPath = join(distDir, `${kebabName}.js`)
  const dtsPath = join(distDir, 'exports', `${componentName}.d.ts`)
  if (!existsSync(jsPath)) {
    errors.push(`缺少组件产物: ${kebabName}.js`)
  }
  // 任一错误 → process.exit(1) 阻止发布
}
</code></pre>

<h3 id="2-2-分发契约-package-json">2.2 分发契约（package.json）</h3>

<pre><code class="language-json">{
  &quot;name&quot;: &quot;moongate-vue&quot;,
  &quot;version&quot;: &quot;1.5.0&quot;,
  &quot;type&quot;: &quot;module&quot;,
  &quot;main&quot;: &quot;./dist/index.js&quot;,
  &quot;module&quot;: &quot;./dist/index.js&quot;,
  &quot;types&quot;: &quot;./dist/index.d.ts&quot;,
  &quot;exports&quot;: {
    &quot;.&quot;: {
      &quot;types&quot;: &quot;./dist/index.d.ts&quot;,
      &quot;import&quot;: &quot;./dist/index.js&quot;,
      &quot;default&quot;: &quot;./dist/index.js&quot;
    },
    &quot;./style.css&quot;: &quot;./dist/style.css&quot;,
    &quot;./reset.css&quot;: &quot;./dist/reset.css&quot;,
    &quot;./button&quot;: {
      &quot;types&quot;: &quot;./dist/exports/Button.d.ts&quot;,
      &quot;import&quot;: &quot;./dist/button.js&quot;,
      &quot;default&quot;: &quot;./dist/button.js&quot;
    }
    // ... 27 个组件入口
  },
  &quot;files&quot;: [&quot;dist&quot;],
  &quot;sideEffects&quot;: [&quot;*.css&quot;],
  &quot;peerDependencies&quot;: {
    &quot;vue&quot;: &quot;^3.5.0&quot;
  }
}
</code></pre>

<p><strong>关键差异（vs 初版）</strong>：</p>

<ul>
<li><strong>纯 ES Module</strong>：只有 <code>.js</code>，没有 <code>.cjs</code>——这是刻意选择（现代打包工具均支持 ESM，且 <code>exports</code> 中有 <code>import</code> 条件即可）</li>
<li><strong>27 个按需导出入口</strong>：用户可 <code>import Button from 'moongate-vue/button'</code> 只加载所需组件</li>
<li><strong>peerDependencies 升到 <code>^3.5.0</code></strong>：因为用了 <code>useId()</code>（Vue 3.5+ API）</li>
<li><code>sideEffects: [&quot;*.css&quot;]</code>：防止打包器 tree-shake 误删 CSS</li>
</ul>

<blockquote>
<p><strong>💡 包体积防御提示</strong>：<code>files</code> 字段是&rdquo;白名单&rdquo;。配置 <code>[&quot;dist&quot;]</code> 后，npm 只上传 <code>dist</code> 目录。<code>package.json</code>、<code>README.md</code>、<code>LICENSE</code> 会被 npm 强制包含。</p>
</blockquote>

<h3 id="2-3-使用-nrm-管理-npm-源">2.3 使用 nrm 管理 npm 源</h3>

<p>发布到 npm 必须使用官方源。如果你之前为了加速下载切换到了国内镜像，推荐使用 <code>nrm</code>。</p>

<pre><code class="language-bash">npm install -g nrm
nrm ls
nrm use npm        # 发布时切换到官方源
nrm current
</code></pre>

<blockquote>
<p><strong>提示</strong>：国内淘宝 npm 镜像已迁移至 <code>https://registry.npmmirror.com</code>。</p>
</blockquote>

<h3 id="2-4-本地集成测试">2.4 本地集成测试</h3>

<p>在发布到 npm 之前，最好在真实项目中先测试一遍：</p>

<pre><code class="language-bash"># 1. 在组件库目录：构建 + 全局链接
pnpm build
pnpm link --global

# 2. 在测试项目目录：链接本地组件库
pnpm link /home/dark/projects/moongate-vue
</code></pre>

<p>测试清单：</p>

<ul>
<li>[ ] 组件能正常渲染</li>
<li>[ ] 样式文件导入生效（<code>import 'moongate-vue/style.css'</code>）</li>
<li>[ ] 按需入口生效（<code>import Button from 'moongate-vue/button'</code>）</li>
<li>[ ] TypeScript 类型提示正常</li>
<li>[ ] HMR 热更新工作正常</li>
</ul>

<p><strong>注意</strong>：测试完成后，删除链接需：<code>pnpm remove moongate-vue</code> + 手动清理 <code>link:</code> 条目。</p>

<hr>

<h2 id="三-攻克双重认证-2fa-泥潭">三、攻克双重认证（2FA）泥潭</h2>

<p>npm 强制要求发布时开启双重认证。npm 已全面拥抱 <strong>安全密钥 (WebAuthn)</strong> 模式。</p>

<h3 id="3-1-浏览器选择与网络环境的-隐藏陷阱">3.1 浏览器选择与网络环境的&rdquo;隐藏陷阱&rdquo;</h3>

<blockquote>
<p><strong>⚠️ 工业级避坑警告</strong>：npm 的 WebAuthn 验证会尝试与 Google 验证服务联动。在国内网络环境下，使用 Chrome/Edge 弹出密钥窗口时<strong>极易由于网络超时而无响应或报错</strong>。</p>
</blockquote>

<table>
<thead>
<tr>
<th>浏览器</th>
<th>是否需要全局代理</th>
<th>成功率</th>
<th>建议</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>Chrome</strong></td>
<td>✅ 必须开启</td>
<td>极高</td>
<td><strong>首选</strong></td>
</tr>

<tr>
<td><strong>Edge</strong></td>
<td>❌ 不需要</td>
<td>高</td>
<td>次选</td>
</tr>

<tr>
<td><strong>Firefox</strong></td>
<td>❌ 不需要</td>
<td>极低</td>
<td><strong>不建议</strong></td>
</tr>
</tbody>
</table>

<h3 id="3-2-ci-cd-备选-granular-access-token">3.2 CI/CD 备选：Granular Access Token</h3>

<pre><code class="language-bash">//registry.npmjs.org/:_authToken=你的_granular_token_值
</code></pre>

<p>权限勾选 <strong>Read and Write</strong>，并勾选 <strong>&ldquo;Bypass two-factor authentication for automation&rdquo;</strong>。</p>

<hr>

<h2 id="四-标准化发布流程">四、标准化发布流程</h2>

<h3 id="4-1-手动发布步骤">4.1 手动发布步骤</h3>

<ol>
<li><code>nrm use npm</code></li>
<li><code>npm login</code>（浏览器完成 2FA）</li>
<li><code>npm version patch|minor|major</code>（注意 <code>--no-git-tag-version</code> 可选）</li>
<li><code>npm publish --access public</code></li>
</ol>

<blockquote>
<p>作用域包必须加 <code>--access public</code>。</p>
</blockquote>

<h3 id="4-2-分步式自动化脚本">4.2 分步式自动化脚本</h3>

<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;release:pre&quot;: &quot;nrm use npm &amp;&amp; npm run build &amp;&amp; npm test&quot;,
    &quot;release:version&quot;: &quot;npm version patch --no-git-tag-version&quot;,
    &quot;release:tag&quot;: &quot;git add package.json &amp;&amp; git commit -m \&quot;chore: release v$(node -p 'require(\&quot;./package.json\&quot;).version')\&quot; &amp;&amp; git tag v$(node -p 'require(\&quot;./package.json\&quot;).version')&quot;,
    &quot;release:publish&quot;: &quot;npm publish --access public&quot;,
    &quot;release&quot;: &quot;npm run release:pre &amp;&amp; npm run release:version &amp;&amp; npm run release:tag &amp;&amp; npm run release:publish&quot;
  }
}
</code></pre>

<h2 id="五-发布前最终检查清单">五、发布前最终检查清单</h2>

<ul>
<li>[ ] <strong>构建无误</strong>：<code>pnpm run build</code> 未报错（含 verify-build + check:size）</li>
<li>[ ] <strong>产物完整</strong>：<code>dist/</code> 有 index.js、27 个组件 .js、style.css、reset.css、index.d.ts</li>
<li>[ ] <strong>体积达标</strong>：Min+Gzip ≤ 25KB（tree-shake-check.js 输出确认）</li>
<li>[ ] <strong>测试通过</strong>：<code>pnpm test</code>（450 个测试全绿）</li>
<li>[ ] <strong>版本干净</strong>：当前版本号从未在 npm 上存在过</li>
<li>[ ] <strong>本地沙盒</strong>：样式 + 按需入口 + 类型均正常</li>
<li>[ ] <strong>peerDependencies 正确</strong>：vue ^3.5.0</li>
</ul>

<hr>

<h2 id="六-faq">六、FAQ</h2>

<h3 id="q1-发布返回-403-404">Q1: 发布返回 403/404？</h3>

<p>403 = 包名被占用 或 未登录。404 = 忘了 <code>nrm use npm</code> 发布到只读镜像。</p>

<h3 id="q2-2fa-弹不出安全密钥窗口">Q2: 2FA 弹不出安全密钥窗口？</h3>

<p>确认代理开启全局/TUN 模式。仍失败则用 Granular Access Token。</p>

<h3 id="q3-用户安装后样式白屏">Q3: 用户安装后样式白屏？</h3>

<p>确认 <code>sideEffects: [&quot;*.css&quot;]</code> 已配置，且用户显式 <code>import 'moongate-vue/style.css'</code>。</p>

<h3 id="q4-用户能-import-moongate-vue-但-import-moongate-vue-button-失败">Q4: 用户能 import &lsquo;moongate-vue&rsquo; 但 import &lsquo;moongate-vue/button&rsquo; 失败？</h3>

<p>检查 <code>exports</code> 中是否声明了 <code>/button</code> 子路径。v1.5.0 已为 27 个组件全部配置。</p>

<hr>

<h2 id="七-结语">七、结语</h2>

<p>从第一篇的<strong>设计令牌</strong>，到薄封装<strong>架构</strong>、简单/复杂组件的 <strong>API 设计</strong>，再到今天的 <strong>npm 工业级分发</strong>——五篇文章，见证了一个组件库从零到 v1.5.0 的完整工程闭环。</p>

<p>v1.5.0 的发布不再是简单的 <code>npm publish</code>，而是一条由 <strong>verify-build.js + tree-shake-check.js + 450 测试</strong> 共同守护的自动化流水线。<strong>构建产物的正确性、体积的克制、API 的稳定</strong>，是组件库生命线。</p>

<p>愿你的组件库也能跨越泥潭，抵达更远的远方。🚀</p>
]]></content:encoded>
      <description><![CDATA[记录 moongate-vue 组件库从构建到 npm v1.5.0 发布的完整流程，涵盖 nrm 源管理、2FA 配置、WebAuthn 网络代理、本地链接测试、构建验证、体积预算、自动化脚本及工业级发布检查清单。]]></description>
      <category><![CDATA[CI/CD]]></category>
      <category><![CDATA[Security]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P4</dc:subject>
      <dc:relation><![CDATA[series:moongate-vue]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Vue 3 复杂组件开发实战：Select 与 Pagination 的 API 设计与状态管理]]></title>
      <link>https://moongate.top/docs/complex-component-api-design</link>
      <guid isPermaLink="true">62e1afa3-c02e-4bf8-bc52-36f3b13032e9</guid>
      <pubDate>Tue, 19 May 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>从数据格式适配到可搜索/多选，深入复杂组件的设计要点与逻辑复用</p>
</blockquote>

<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共五篇，覆盖从设计令牌到 npm 发布的 Vue 3 组件库开发全流程：</p>

<ol>
<li><p><a href="./design-tokens-vs-atomic-css"><strong>设计令牌 vs 原子化 CSS：失败整合与融合之道（理念篇）</strong></a>
—— 用 UnoCSS 映射设计令牌的失败经历，量化对比后得出设计令牌优先的结论。</p></li>

<li><p><a href="./css-first-component-library"><strong>CSS 优先 + 组件薄封装：一个 25KB 组件库的极简实践（架构篇）</strong></a>
—— 四层 CSS 架构、极简 Vue 组件、Vite 多入口构建、体积预算验证，单组件极简实现。</p></li>

<li><p><a href="./vue-component-api-design"><strong>Vue 3 简单组件开发实战：从 Button 组件看 API 设计（简单组件篇）</strong></a>
—— Props 定义、变体系统、尺寸取舍、插槽设计、状态管理、无障碍支持及与主流 UI 库对比。</p></li>

<li><p><a href="./complex-component-api-design"><strong>Vue 3 复杂组件开发实战：Select 与 Pagination 的 API 设计（复杂组件篇）</strong></a>
—— 数据格式适配、类型回溯、可搜索/多选、ARIA 键盘导航、组合式函数抽离及 SSR 适配，揭示工业级细节。</p></li>

<li><p><a href="./component-library-publishing"><strong>从代码到 npm：Vue 3 组件库发布实战与避坑指南（发布篇）</strong></a>
—— nrm 源管理、2FA 配置、WebAuthn 网络代理避坑、本地链接测试、自动化脚本及工业级发布检查清单。</p></li>
</ol>

<h2 id="一-引言">一、引言</h2>

<p>如果说写 Button 组件是在享受写 CSS 变量的&rdquo;涂料之美&rdquo;，那么写 Select 和 Pagination 就是在应对原生 HTML 历史包袱的&rdquo;泥潭摔跤&rdquo;。简单组件是单向的数据消费者，而复杂组件则是<strong>数据适配器</strong>（兼容多格式）与<strong>状态协调器</strong>（可搜索、多选、键盘导航、SSR 安全）。</p>

<p>本文以 <strong>Select</strong> 和 <strong>Pagination</strong> 为例，展示 v1.5.0 实际实现的复杂组件开发思路。</p>

<h2 id="二-select-下拉选择框-数据适配器">二、Select 下拉选择框：数据适配器</h2>

<p>Select 组件需要接收一组选项，并允许用户选择其中一个。真实世界的 API 可能返回对象数组、字符串数组甚至数字数组，因此组件必须具备强大的数据适配能力。</p>

<h3 id="2-1-需求分析">2.1 需求分析</h3>

<ul>
<li>支持对象数组 <code>{ label, value }</code>（默认）</li>
<li>支持自定义字段名（<code>labelKey</code> / <code>valueKey</code>）</li>
<li>支持字符串数组 <code>['选项A', '选项B']</code></li>
<li>支持数字数组 <code>[1, 2, 3]</code></li>
<li>提供占位符（不可选中的默认选项）</li>
<li>支持禁用选项（<code>disabled: true</code>）</li>
<li><strong>可搜索模式</strong>（filterable）：输入过滤 + 下拉面板</li>
<li><strong>多选模式</strong>（multiple + filterable）：标签展示</li>
<li><strong>必须解决原生 <code>&lt;select&gt;</code> 返回字符串的类型陷阱</strong></li>
<li><strong>完整 ARIA 键盘导航</strong>（listbox + option + aria-activedescendant）</li>
</ul>

<h3 id="2-2-双模式架构-原生模式-可搜索模式">2.2 双模式架构：原生模式 + 可搜索模式</h3>

<p>v1.5.0 的 Select 支持<strong>双模式</strong>：</p>

<ul>
<li><strong>原生模式</strong>（默认）：渲染原生 <code>&lt;select&gt;</code>，零 JS 开销</li>
<li><strong>可搜索模式</strong>（<code>filterable=true</code>）：渲染自定义输入框 + 下拉面板，支持搜索/多选/键盘导航</li>
</ul>

<pre><code class="language-vue">&lt;!-- 原生模式：性能最优 --&gt;
&lt;Select v-model=&quot;category&quot; :options=&quot;categories&quot; /&gt;

&lt;!-- 可搜索模式：过滤 + 下拉 --&gt;
&lt;Select v-model=&quot;fruit&quot; :options=&quot;fruits&quot; filterable /&gt;

&lt;!-- 可搜索 + 多选 --&gt;
&lt;Select v-model=&quot;tags&quot; :options=&quot;tags&quot; filterable multiple /&gt;
</code></pre>

<h3 id="2-3-api-设计与类型防腐">2.3 API 设计与类型防腐</h3>

<p>为了避免 <code>any</code> 造成类型污染，我们采用联合类型收窄：</p>

<pre><code class="language-typescript">export type SelectValue = string | number
export type SelectOption = string | number | Record&lt;string, any&gt;

interface Props {
  options?: SelectOption[]
  placeholder?: string
  size?: Size
  disabled?: boolean
  error?: boolean
  labelKey?: string // 默认 'label'
  valueKey?: string // 默认 'value'
  filterable?: boolean // 可搜索模式
  emptyText?: string
  maxHeight?: number // 下拉面板最大高度
  multiple?: boolean // 多选（需 filterable）
}
</code></pre>

<p><strong>类型回溯</strong>解决原生 select 总是返回字符串的问题：</p>

<pre><code class="language-typescript">const handleNativeChange = (event: Event) =&gt; {
  const target = event.target as HTMLSelectElement
  const rawValue = target.value

  // 在原始 options 中找回原始类型（数字或对象值）
  const originalItem = props.options?.find(
    (item) =&gt; String(getValue(item)) === rawValue,
  )
  const finalValue = originalItem !== undefined ? getValue(originalItem) : rawValue

  modelValue.value = finalValue
  emit('change', finalValue)
}
</code></pre>

<p>这个逻辑保证 <code>v-model</code> 绑定的数字值不会意外变成字符串。</p>

<h3 id="2-4-属性透传拆分">2.4 属性透传拆分</h3>

<p>这是可搜索模式的关键细节——<strong>哪些属性透传到原生 input，哪些保留在外层 wrapper</strong>：</p>

<pre><code class="language-typescript">const attrs = useAttrs()

/** 透传到原生表单元素的 form/aria 属性 */
const formAttrs = computed(() =&gt; {
  const result: Record&lt;string, unknown&gt; = {}
  for (const [key, value] of Object.entries(attrs)) {
    if (key.startsWith('aria-') || ['name', 'id', 'role', 'tabindex'].includes(key)) {
      result[key] = value
    }
  }
  return result
})

/** 保留在外层 wrapper 的其余属性（class/style/事件等） */
const wrapperAttrs = computed(() =&gt; {
  const result: Record&lt;string, unknown&gt; = {}
  for (const [key, value] of Object.entries(attrs)) {
    if (!(key in formAttrs.value)) {
      result[key] = value
    }
  }
  return result
})
</code></pre>

<p>为什么必须拆分？如果 <code>aria-label</code> 留在外层 wrapper 而未透传到实际 <code>&lt;input&gt;</code>，屏幕阅读器会无法识别输入框的可访问名称——在 <code>a11y.test.ts</code> 的 axe-core 检查中会报 <code>aria-input-field-name</code> 违规。</p>

<h3 id="2-5-aria-键盘导航-wai-aria-combobox-模式">2.5 ARIA 键盘导航（WAI-ARIA Combobox 模式）</h3>

<p>可搜索模式的键盘导航遵循 WAI-ARIA Combobox 模式：</p>

<pre><code class="language-vue">&lt;!-- 下拉面板 --&gt;
&lt;div
  v-if=&quot;isOpen&quot;
  ref=&quot;dropdownRef&quot;
  class=&quot;mg-select-dropdown&quot;
  role=&quot;listbox&quot;
  :aria-label=&quot;listboxAriaLabel&quot;
  :aria-activedescendant=&quot;focusedIndex &gt;= 0 ? getOptionId(focusedIndex) : undefined&quot;
&gt;
  &lt;!-- 选项 --&gt;
  &lt;div
    v-for=&quot;(item, index) in filteredOptions&quot;
    :id=&quot;getOptionId(index)&quot;
    role=&quot;option&quot;
    :aria-selected=&quot;isSelected(item)&quot;
    :class=&quot;{ 'mg-select-option-focused': focusedIndex === index }&quot;
    @click=&quot;selectOption(item)&quot;
    @mouseenter=&quot;focusedIndex = index&quot;
  &gt;
</code></pre>

<p>支持的操作：
- <code>ArrowDown</code> / <code>ArrowUp</code>：移动高亮（<code>focusedIndex</code>），并 <code>scrollIntoView</code> 保持可视
- <code>Enter</code>：选中当前高亮选项
- <code>Esc</code>：关闭下拉
- 每个选项有唯一 <code>id</code>（基于 <code>useId()</code>），供 <code>aria-activedescendant</code> 引用</p>

<h3 id="2-6-多选模式">2.6 多选模式</h3>

<p>多选（<code>multiple + filterable</code>）将 <code>modelValue</code> 变为数组：</p>

<pre><code class="language-typescript">const modelValue = defineModel&lt;SelectValue | SelectValue[]&gt;({ default: '' })

// 多选时：切换选中
const selectOption = (item: SelectOption) =&gt; {
  if (isOptionDisabled(item)) return
  const value = getValue(item)

  if (props.multiple) {
    const current = multipleValues.value
    const isAlreadySelected = current.some((v) =&gt; String(v) === String(value))
    const next = isAlreadySelected
      ? current.filter((v) =&gt; String(v) !== String(value))
      : [...current, value]

    modelValue.value = next
    // 多选保持下拉打开，方便连续多选
    searchText.value = ''
    focusedIndex.value = -1
    nextTick(() =&gt; inputRef.value?.focus())
    return
  }

  // 单选：选择后关闭
  modelValue.value = value
  closeDropdown()
}
</code></pre>

<p>多选时：
- 已选项渲染为标签（tag），每个标签有 <code>aria-label=&quot;移除 {label}&quot;</code> 的删除按钮
- 选择后<strong>下拉保持打开</strong>（方便连续多选）
- 输入框只显示搜索文本，已选标签在外部</p>

<h2 id="三-pagination-分页组件-状态同步器">三、Pagination 分页组件：状态同步器</h2>

<h3 id="3-1-api-设计-v1-5-0-实际">3.1 API 设计（v1.5.0 实际）</h3>

<pre><code class="language-typescript">interface Props {
  totalPages: number       // 总页数（必传）
  modelValue: number       // 当前页码（v-model）
  size?: &quot;sm&quot; | &quot;md&quot; | &quot;lg&quot;
  showQuickJump?: boolean  // 首尾页快速跳转按钮（默认 true）
  prevText?: string        // 上一页文案（走全局 i18n）
  nextText?: string
  firstText?: string
  lastText?: string
}
</code></pre>

<p>Pagination 使用 <code>defineModel&lt;number&gt;</code> 绑定当前页：</p>

<pre><code class="language-typescript">const currentPage = defineModel&lt;number&gt;({ required: true })
</code></pre>

<h3 id="3-2-快速跳转-页码编辑">3.2 快速跳转 + 页码编辑</h3>

<pre><code class="language-vue">&lt;!-- 显示模式：可点击的数字 → 进入编辑 --&gt;
&lt;span v-else class=&quot;mg-pagination-current&quot; @click=&quot;startEdit&quot;&gt;
  {{ currentPage }}
&lt;/span&gt;

&lt;!-- 编辑模式：输入框 --&gt;
&lt;input
  v-if=&quot;isEditing&quot;
  v-model=&quot;inputPage&quot;
  type=&quot;number&quot;
  :min=&quot;1&quot;
  :max=&quot;totalPages&quot;
  @blur=&quot;commitJump&quot;
  @keyup.enter=&quot;commitJump&quot;
/&gt;
</code></pre>

<p><code>commitJump</code> 的极端边界防御：</p>

<pre><code class="language-typescript">const commitJump = () =&gt; {
  isEditing.value = false
  const newPage = parseInt(String(inputPage.value), 10)

  // 非法输入：放弃并恢复
  if (isNaN(newPage)) {
    inputPage.value = currentPage.value
    return
  }

  goToPage(newPage) // clamp 到 [1, totalPages]
}

const goToPage = (page: number) =&gt; {
  let newPage = page
  if (newPage &lt; 1) newPage = 1
  if (newPage &gt; props.totalPages) newPage = props.totalPages
  if (newPage === currentPage.value) return
  currentPage.value = newPage
  emit('change', newPage)
}
</code></pre>

<h3 id="3-3-全局文案-i18n">3.3 全局文案（i18n）</h3>

<p>v1.5.0 加入了全局文案系统。Pagination 的所有 aria-label 和按钮文字都走配置链：</p>

<pre><code class="language-typescript">const texts = useTexts() // 响应式全局文案

const prevTextValue = computed(() =&gt; props.prevText ?? texts.value.paginationPrev)
const pageInfoLabel = computed(() =&gt;
  formatTemplate(texts.value.paginationPageInfo, {
    current: currentPage.value,
    total: props.totalPages,
  }),
)
</code></pre>

<p>优先级：<strong>组件 prop &gt; setConfig texts &gt; 语言内置文案</strong>。文案支持 <code>{current}</code>/<code>{total}</code> 模板占位符。</p>

<h2 id="四-组合式函数抽离">四、组合式函数抽离</h2>

<p>复杂组件往往需要抽离共享逻辑。v1.5.0 的核心 composables：</p>

<h3 id="4-1-useformfield-input-textarea-共享">4.1 useFormField（Input/Textarea 共享）</h3>

<p>处理 <code>v-model</code> 更新 + 原生事件透传：</p>

<pre><code class="language-typescript">export function useFormField(modelValue, emit) {
  const handleInput = (event: Event) =&gt; {
    modelValue.value = (event.target as HTMLInputElement).value
    emit('input', event)
  }
  // change/focus/blur 原生事件透传
  return { handleInput, handleChange, handleBlur, handleFocus }
}
</code></pre>

<h3 id="4-2-usefloating-popover-tooltip-共享">4.2 useFloating（Popover/Tooltip 共享）</h3>

<p>自研悬浮层定位引擎：视口翻转 + 边界修正 + ResizeObserver：</p>

<pre><code class="language-typescript">export function useFloating(options: UseFloatingOptions) {
  // 延迟显示/隐藏
  // 位置计算（按方向定位 + 视口翻转）
  // 滚动/窗口尺寸变化时重新定位
  // ResizeObserver 仅监听悬浮层自身尺寸
  // SSR 安全（isBrowser 守卫）
  return { triggerRef, floatingRef, visible, currentPlacement, floatStyle, show, hide, ... }
}
</code></pre>

<h3 id="4-3-useoverlaybehavior-modal-drawer-共享-位于-usescrolllock-ts">4.3 useOverlayBehavior（Modal/Drawer 共享，位于 useScrollLock.ts）</h3>

<p>滚动锁定 + ESC 关闭 + 焦点陷阱：</p>

<pre><code class="language-typescript">// composables/useScrollLock.ts
export function useOverlayBehavior(isOpen, overlayRef, onClose, options) {
  // body 滚动锁定（模块级 lockCount 计数器，多实例安全）
  // ESC 键关闭
  // Tab 焦点陷阱
}
</code></pre>

<p>该函数与滚动锁定逻辑（<code>lockBodyScroll</code>/<code>unlockBodyScroll</code>）一同定义在 <strong><code>useScrollLock.ts</code></strong> 中——滚动锁、ESC 关闭、焦点陷阱三者在语义上同属&rdquo;浮层行为&rdquo;这一关注点，因此放在同一个文件内。</p>

<p><strong>模块级锁计数</strong>解决多 Modal/Drawer 同时打开的滚动锁冲突——只有最后一个关闭时才恢复 body 滚动。</p>

<h2 id="五-ssr-适配-useid-与-isbrowser">五、SSR 适配：useId 与 isBrowser</h2>

<p>v1.5.0 的 SSR 适配比初版更完善，核心是两条：</p>

<h3 id="5-1-useid-保证-hydration-安全">5.1 useId() 保证 hydration 安全</h3>

<p>所有需要 <code>id</code> 的组件（Modal/Drawer/Select/Tabs）使用 Vue 3 的 <code>useId()</code>：</p>

<pre><code class="language-typescript">const selectBaseId = useId()
const getOptionId = (index: number): string =&gt; `${selectBaseId}-option-${index}`
</code></pre>

<p><code>useId()</code> 在服务端与客户端生成一致的 ID，避免 hydration mismatch。</p>

<h3 id="5-2-isbrowser-守卫">5.2 isBrowser 守卫</h3>

<p>所有 DOM 操作添加浏览器环境守卫：</p>

<pre><code class="language-typescript">const isBrowser = typeof window !== 'undefined' &amp;&amp; typeof document !== 'undefined'

// 在 watch/onMounted/顶层代码中：
if (!isBrowser) return
</code></pre>

<p>配合 <code>useScrollLock.ts</code> 的模块级计数器，<code>lockBodyScroll()</code> 在非浏览器环境下直接跳过 DOM 操作。</p>

<h3 id="5-3-createoverlay-命令式组件-ssr-安全">5.3 createOverlay：命令式组件 SSR 安全</h3>

<pre><code class="language-typescript">// composables/createOverlay.ts
export function createOverlay(component, props, containerClass) {
  if (!isBrowser) return null // SSR 返回 null
  // ...
}
</code></pre>

<h2 id="六-数据与状态流向">六、数据与状态流向</h2>

<pre><code class="language-text">┌─────────────────────────────────────────────────────────┐
│ 复杂组件数据流 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 外部数据 │ -&gt; │ 数据适配器 │ -&gt; │ 内部状态 │ │
│ │ (options) │ │ (getLabel/ │ │ (selected/ │ │
│ └─────────────┘ │ getValue) │ │ searchText) │ │
│ └─────────────┘ └──────┬──────┘ │
│ ↓ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 全局环境 │ &lt;- │ 状态协调器 │ &lt;- │ 用户交互 │ │
│ │ (i18n/SSR) │ │ (watch/event)│ │ (click/ │ │
│ └─────────────┘ └─────────────┘ │ keyboard) │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────┘
</code></pre>

<h2 id="七-测试策略">七、测试策略</h2>

<p>v1.5.0 的 Select 有 <strong>40+ 测试</strong>，Pagination 有 <strong>14 测试</strong>，覆盖：</p>

<table>
<thead>
<tr>
<th>关注点</th>
<th>测试内容</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>类型回溯</strong></td>
<td>原生模式数字数组 <code>[10,20,30]</code> 选中后 modelValue 仍为 number</td>
</tr>

<tr>
<td><strong>ARIA 导航</strong></td>
<td><code>aria-activedescendant</code> 指向高亮选项、每个 option 有唯一 id</td>
</tr>

<tr>
<td><strong>键盘操作</strong></td>
<td>ArrowDown/Up 高亮、Enter 选中、Esc 关闭、边界不越界</td>
</tr>

<tr>
<td><strong>多选</strong></td>
<td>标签渲染、切换选中、tag 删除、Enter 连续多选</td>
</tr>

<tr>
<td><strong>边界值</strong></td>
<td>搜索空结果、外部 modelValue 变化、blur 时下拉保持打开</td>
</tr>

<tr>
<td><strong>无障碍</strong></td>
<td>axe-core 对 Select（原生+可搜索）无违规</td>
</tr>
</tbody>
</table>
<p>以及 <strong>SSR 检查</strong>：<code>renderToString</code> 确认组件在服务端不崩溃且浮层默认隐藏。</p>

<h2 id="八-总结">八、总结</h2>

<table>
<thead>
<tr>
<th>关注点</th>
<th>简单组件（Button）</th>
<th>复杂组件（Select / Pagination）</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>Props 数量</strong></td>
<td>较少（11）</td>
<td>较多（10-15）</td>
</tr>

<tr>
<td><strong>数据格式</strong></td>
<td>固定（字符串）</td>
<td>灵活（支持多种数组，可配置字段，类型防腐）</td>
</tr>

<tr>
<td><strong>状态管理</strong></td>
<td>无内部状态</td>
<td>可搜索文本、多选数组、编辑状态、下拉显隐</td>
</tr>

<tr>
<td><strong>无障碍</strong></td>
<td>原生语义</td>
<td>WAI-ARIA Combobox 模式（listbox/option/activedescendant）</td>
</tr>

<tr>
<td><strong>逻辑复用</strong></td>
<td>不需要</td>
<td>组合式函数（useFormField/useFloating/useOverlayBehavior）</td>
</tr>

<tr>
<td><strong>SSR 适配</strong></td>
<td>自动</td>
<td>useId hydration 安全 + isBrowser 守卫</td>
</tr>

<tr>
<td><strong>i18n</strong></td>
<td>少数文案</td>
<td>全局配置链（prop &gt; setConfig &gt; 内置）</td>
</tr>

<tr>
<td><strong>测试策略</strong></td>
<td>快照、事件触发</td>
<td>状态组合、边界值、键盘模拟、类型回溯、axe-core</td>
</tr>
</tbody>
</table>
<p>一个优秀的复杂组件，对内要像吸尘器一样容纳各种奇葩的后端数据格式（通过 Key 映射和类型回溯），对外要像绅士一样克制地与全局环境（i18n、SSR、键盘）发生耦合。<strong>高内聚、低耦合</strong>，在这两类组件身上体现得淋漓尽致。</p>
]]></content:encoded>
      <description><![CDATA[以 Select 和 Pagination 为例，深入探讨 Vue 3 复杂组件的 API 设计、数据格式适配、类型回溯、可搜索/多选、ARIA 键盘导航、组合式函数抽离及 SSR 适配，揭示工业级组件背后的设计权衡与实现细节。]]></description>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[Design System]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:moongate-vue]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Vue 3 简单组件开发实战：从 Button 组件看 API 设计]]></title>
      <link>https://moongate.top/docs/vue-component-api-design</link>
      <guid isPermaLink="true">e6526a2f-4d33-4817-9ef0-1cbfb71ef3e7</guid>
      <pubDate>Thu, 07 May 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>极简不是简陋，克制不是缺失——从 Nuxt UI v4 汲取灵感，如何设计一个好用的组件 API</p>
</blockquote>

<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共五篇，覆盖从设计令牌到 npm 发布的 Vue 3 组件库开发全流程：</p>

<ol>
<li><p><a href="./design-tokens-vs-atomic-css"><strong>设计令牌 vs 原子化 CSS：失败整合与融合之道（理念篇）</strong></a>
—— 用 UnoCSS 映射设计令牌的失败经历，量化对比后得出设计令牌优先的结论。</p></li>

<li><p><a href="./css-first-component-library"><strong>CSS 优先 + 组件薄封装：一个 25KB 组件库的极简实践（架构篇）</strong></a>
—— 四层 CSS 架构、极简 Vue 组件、Vite 多入口构建、体积预算验证，单组件极简实现。</p></li>

<li><p><a href="./vue-component-api-design"><strong>Vue 3 简单组件开发实战：从 Button 组件看 API 设计（简单组件篇）</strong></a>
—— Props 定义、变体系统、尺寸取舍、插槽设计、状态管理、无障碍支持及与主流 UI 库对比。</p></li>

<li><p><a href="./complex-component-api-design"><strong>Vue 3 复杂组件开发实战：Select 与 Pagination 的 API 设计（复杂组件篇）</strong></a>
—— 数据格式适配、类型回溯、可搜索/多选、ARIA 键盘导航、组合式函数抽离及 SSR 适配，揭示工业级细节。</p></li>

<li><p><a href="./component-library-publishing"><strong>从代码到 npm：Vue 3 组件库发布实战与避坑指南（发布篇）</strong></a>
—— nrm 源管理、2FA 配置、WebAuthn 网络代理避坑、本地链接测试、自动化脚本及工业级发布检查清单。</p></li>
</ol>

<h2 id="一-背景与参考">一、背景与参考</h2>

<p>在之前的文章中，我们讨论了设计令牌优先于原子化 CSS 的理念，以及 CSS 优先 + 组件薄封装的架构。但有一个问题始终没有深入：<strong>具体到单个组件，API 到底该怎么设计？</strong></p>

<p>设计初期，我深度参考了 Nuxt UI v4 的设计思路。Nuxt UI v4 将对复杂样式和交互的封装收束为 <code>variant</code>/<code>color</code>/<code>size</code> 几个核心维度，这正是我想要的——把复杂逻辑<strong>内聚于组件内部</strong>，对外只暴露最精简的 API。</p>

<p>下文以 Button 组件为例，一步步展示我的设计取舍和思考过程。<strong>本文基于 v1.5.0 的实际实现</strong>——经过多个版本迭代，Button 的 API 已经比初版更完善。</p>

<h2 id="二-从需求出发-button-需要什么">二、从需求出发：Button 需要什么？</h2>

<p>一个按钮组件最基本的功能：</p>

<ul>
<li>显示文字</li>
<li>点击触发事件</li>
<li>禁用状态</li>
<li>不同样式（主要、次要、危险等）</li>
</ul>

<p>但只有这些够吗？我们看看实际使用场景：</p>

<pre><code class="language-vue">&lt;!-- 带图标的按钮 --&gt;
&lt;Button&gt;
  &lt;template #icon&gt;🔍&lt;/template&gt;
  搜索
&lt;/Button&gt;

&lt;!-- 加载状态 --&gt;
&lt;Button loading&gt;提交中&lt;/Button&gt;

&lt;!-- 块级按钮（占满宽度） --&gt;
&lt;Button block&gt;全宽按钮&lt;/Button&gt;

&lt;!-- 不同尺寸 --&gt;
&lt;Button size=&quot;sm&quot;&gt;小号&lt;/Button&gt;
</code></pre>

<p>经过分析，Button 组件在 v1.5.0 支持的需求：</p>

<table>
<thead>
<tr>
<th>需求</th>
<th>实现方式</th>
</tr>
</thead>

<tbody>
<tr>
<td>文字内容</td>
<td>默认插槽 或 <code>label</code> prop</td>
</tr>

<tr>
<td>点击事件</td>
<td><code>click</code> 事件</td>
</tr>

<tr>
<td>禁用状态</td>
<td><code>disabled</code> prop</td>
</tr>

<tr>
<td>加载状态</td>
<td><code>loading</code> prop</td>
</tr>

<tr>
<td>加载时保留文字</td>
<td><code>showLabelWhileLoading</code> prop</td>
</tr>

<tr>
<td>加载时自定义文字</td>
<td><code>loadingLabel</code> prop + <code>#loading-label</code> 插槽</td>
</tr>

<tr>
<td>不同样式</td>
<td><code>variant</code> + <code>color</code></td>
</tr>

<tr>
<td>不同尺寸</td>
<td><code>size</code> prop</td>
</tr>

<tr>
<td>块级宽度</td>
<td><code>block</code> prop</td>
</tr>

<tr>
<td>图标</td>
<td><code>icon</code> prop 或 <code>#icon</code> 插槽</td>
</tr>

<tr>
<td>原生按钮类型</td>
<td><code>type</code> prop（button/submit/reset）</td>
</tr>
</tbody>
</table>

<h2 id="三-props-设计-类型-默认值-优先级">三、Props 设计：类型、默认值、优先级</h2>

<h3 id="3-1-基础-props">3.1 基础 Props</h3>

<pre><code class="language-typescript">interface Props {
  label?: string // 按钮文字
  disabled?: boolean // 是否禁用
  loading?: boolean // 是否加载中
  block?: boolean // 是否为块级
  type?: &quot;button&quot; | &quot;submit&quot; | &quot;reset&quot; // 原生按钮类型
  showLabelWhileLoading?: boolean // 加载时是否保留文字
  loadingLabel?: string // 加载时的自定义文字
}
</code></pre>

<p>默认值设计：</p>

<pre><code class="language-typescript">const props = withDefaults(defineProps&lt;Props&gt;(), {
  label: &quot;&quot;,
  disabled: false,
  loading: false,
  block: false,
  type: &quot;button&quot;, // 默认 button，防止表单意外提交
  showLabelWhileLoading: false,
})
</code></pre>

<p><strong>为什么默认 <code>type=&quot;button&quot;</code></strong>？这是从实际项目踩坑中学到的重要决策。如果使用原生 <code>&lt;button&gt;</code> 的默认类型 <code>submit</code>，当按钮放在 <code>&lt;form&gt;</code> 里时，点击会意外提交表单。将其显式默认为 <code>button</code> 可避免绝大多数不期望的表单行为。</p>

<h3 id="3-2-变体系统-variant-color">3.2 变体系统：variant + color</h3>

<p>常见的按钮类型有：主要按钮、次要按钮、边框按钮、幽灵按钮。受 Nuxt UI v4 的 <code>variant</code> + <code>color</code> 设计启发，我选择将&rdquo;视觉模式&rdquo;与&rdquo;语义颜色&rdquo;完全解耦。</p>

<pre><code class="language-typescript">type Variant = &quot;filled&quot; | &quot;outline&quot;
type Color = &quot;primary&quot; | &quot;success&quot; | &quot;warning&quot; | &quot;error&quot;
</code></pre>

<p>为什么只保留 <code>filled</code> 和 <code>outline</code>，删除了 <code>ghost</code>？</p>

<table>
<thead>
<tr>
<th>变体</th>
<th>使用频率</th>
<th>是否保留</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>filled</code></td>
<td>🔥🔥🔥🔥🔥 极高</td>
<td>✅ 保留</td>
</tr>

<tr>
<td><code>outline</code></td>
<td>🔥🔥🔥🔥 高</td>
<td>✅ 保留</td>
</tr>

<tr>
<td><code>ghost</code></td>
<td>🔥 低</td>
<td>❌ 删除（可用 <code>outline</code> 替代）</td>
</tr>
</tbody>
</table>
<p>同样，颜色只保留 4 种：</p>

<table>
<thead>
<tr>
<th>颜色</th>
<th>使用频率</th>
<th>是否保留</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>primary</code></td>
<td>🔥🔥🔥🔥🔥 极高</td>
<td>✅ 保留</td>
</tr>

<tr>
<td><code>success</code></td>
<td>🔥🔥🔥 中</td>
<td>✅ 保留</td>
</tr>

<tr>
<td><code>warning</code></td>
<td>🔥 低</td>
<td>✅ 保留</td>
</tr>

<tr>
<td><code>error</code></td>
<td>🔥🔥 中</td>
<td>✅ 保留</td>
</tr>

<tr>
<td><code>neutral</code></td>
<td>🔥 低</td>
<td>❌ 删除（可用 <code>outline</code> 替代）</td>
</tr>
</tbody>
</table>

<h3 id="3-3-尺寸设计">3.3 尺寸设计</h3>

<pre><code class="language-typescript">type Size = &quot;sm&quot; | &quot;md&quot; | &quot;lg&quot;
</code></pre>

<p>尺寸选项与 Nuxt UI v4 提供的五种尺寸（xs, sm, md, lg, xl）相比做了精简。我删除了 <code>xs</code> 和 <code>xl</code>，因为极小尺寸可以用 Badge 或其他非按钮组件替代，而个人博客里几乎碰不到超大尺寸的场景。</p>

<p>默认尺寸的选择：主流 UI 库（Naive UI、PrimeVue 等）的默认按钮高度约 32-34px，对应我们的 <code>sm</code>，因此默认尺寸设为 <code>sm</code>。</p>

<pre><code class="language-typescript">const props = withDefaults(defineProps&lt;Props&gt;(), {
  size: &quot;sm&quot;, // 默认小号
})
</code></pre>

<h3 id="3-4-图标设计-prop-与插槽共存">3.4 图标设计：prop 与插槽共存</h3>

<p>初版设计时，我只提供 <code>#icon</code> 插槽，不提供 <code>icon</code> prop——理由是&rdquo;保持单一职责&rdquo;。但实际使用中发现两个问题：</p>

<ol>
<li><strong>简单图标（如 <code>✓</code>、emoji）用插槽太啰嗦</strong>：<code>&lt;template #icon&gt;✓&lt;/template&gt;</code> 比 <code>icon=&quot;✓&quot;</code> 多了 20 个字符</li>
<li><strong>图标库组件（如 lucide-vue-next 的 IconHome）用插槽不够直观</strong>：需要用 <code>&lt;component :is=&quot;IconHome&quot; /&gt;</code> 包一层</li>
</ol>

<p>经过多版本迭代，最终同时支持两者，并建立明确的优先级：</p>

<pre><code class="language-typescript">interface Props {
  icon?: string | Component // 图标：字符串或 Vue 组件
}
</code></pre>

<pre><code class="language-vue">&lt;!-- 使用插槽（优先级更高，更灵活） --&gt;
&lt;Button&gt;
  &lt;template #icon&gt;🔍&lt;/template&gt;
  搜索
&lt;/Button&gt;

&lt;!-- 使用字符串 prop --&gt;
&lt;Button icon=&quot;✓&quot; label=&quot;确认&quot; /&gt;

&lt;!-- 使用 Vue 组件 prop --&gt;
&lt;Button :icon=&quot;IconHome&quot; label=&quot;首页&quot; /&gt;
</code></pre>

<p>模板中的实现逻辑：</p>

<pre><code class="language-vue">&lt;span v-if=&quot;hasIconSlot || icon&quot; class=&quot;mg-button-icon&quot;&gt;
  &lt;!-- 插槽优先于 prop --&gt;
  &lt;slot name=&quot;icon&quot;&gt;
    &lt;!-- prop 是 Vue 组件时渲染组件 --&gt;
    &lt;component :is=&quot;icon&quot; v-if=&quot;typeof icon !== 'string'&quot; /&gt;
    &lt;!-- prop 是字符串时直接渲染文本 --&gt;
    &lt;span v-else-if=&quot;icon&quot;&gt;{{ icon }}&lt;/span&gt;
  &lt;/slot&gt;
&lt;/span&gt;
</code></pre>

<p><strong>设计原则：插槽优先于 prop</strong>。<code>hasIconSlot</code> 检测是否传入 <code>#icon</code> 插槽，如果有则完全忽略 <code>icon</code> prop。这保证了灵活性——当用户需要自定义图标布局时，插槽总是能覆盖 prop 的默认行为。</p>

<blockquote>
<p>设计考量：Nuxt UI v4 同样支持 <code>icon</code> prop 和 <code>leading-icon</code>/<code>trailing-icon</code> 等多个图标相关属性。我合并为单个 <code>icon</code> prop（左侧图标——这是个人博客场景 90% 的需求），保留 <code>#icon</code> 插槽用于完全控制。</p>
</blockquote>

<h2 id="四-插槽设计-默认插槽-vs-label-prop">四、插槽设计：默认插槽 vs label prop</h2>

<p>为了支持快速写法和自定义内容，同时提供 <code>label</code> prop 和默认插槽：</p>

<pre><code class="language-vue">&lt;span v-if=&quot;hasLabel&quot; class=&quot;mg-button-label&quot;&gt;
  &lt;slot&gt;{{ label }}&lt;/slot&gt;
&lt;/span&gt;
</code></pre>

<p><code>hasLabel</code> 的判断逻辑有一个容易忽视的细节：</p>

<pre><code class="language-typescript">const hasLabel = computed(() =&gt; props.label !== &quot;&quot; || !!slots.default)
</code></pre>

<p>注意这里是 <code>props.label !== &quot;&quot;</code> 而不是 <code>!!props.label</code>。为什么？</p>

<ul>
<li><code>withDefaults</code> 会将 <code>undefined</code> 解析为默认值 <code>&quot;&quot;</code></li>
<li>当外部显式传入 <code>label=&quot;&quot;</code> 时，<strong>我们不渲染空 label 容器</strong>——这是「纯图标按钮」的经典场景，空容器会导致图标无法垂直居中</li>
<li>但如果有默认插槽（无论插槽内容是否为空），仍然渲染容器</li>
</ul>

<pre><code class="language-vue">&lt;!-- 纯图标按钮：label 为空，不渲染空 label 容器 --&gt;
&lt;Button icon=&quot;🔍&quot; /&gt;

&lt;!-- 带插槽内容：即使 label 为空也渲染 --&gt;
&lt;Button&gt;&lt;template #icon&gt;🔍&lt;/template&gt;搜索&lt;/Button&gt;
</code></pre>

<p>这解决了<strong>纯图标按钮居中问题</strong>——空的 <code>.mg-button-label</code> 会占据空间导致图标不居中。</p>

<h2 id="五-状态处理">五、状态处理</h2>

<h3 id="5-1-禁用状态与加载状态">5.1 禁用状态与加载状态</h3>

<p><code>disabled</code> 和 <code>loading</code> 都会禁用按钮，但<strong>只有 <code>disabled</code> 时 <code>click</code> 事件才完全阻止</strong>（原生 disabled 属性）；<code>loading</code> 状态我们还希望保留正确的语义——用户知道按钮在&rdquo;处理中&rdquo;。</p>

<pre><code class="language-typescript">const handleClick = (event: MouseEvent) =&gt; {
  if (props.disabled || props.loading) return
  emit(&quot;click&quot;, event)
}
</code></pre>

<pre><code class="language-vue">&lt;button :disabled=&quot;disabled || loading&quot; @click=&quot;handleClick&quot;&gt;
</code></pre>

<h3 id="5-2-加载状态增强">5.2 加载状态增强</h3>

<p>初版的 <code>loading</code> 只显示旋转动画，隐藏图标和文字。v1.5.0 增加了两个增强：</p>

<ul>
<li><code>showLabelWhileLoading</code>：加载时是否保留文字</li>
<li><code>loadingLabel</code>：加载时的自定义文字（默认复用 <code>label</code>）</li>
<li><code>#loading-label</code> 插槽：完全自定义加载文字（优先级最高）</li>
</ul>

<pre><code class="language-vue">&lt;template v-if=&quot;loading&quot;&gt;
  &lt;span class=&quot;mg-button-loading-icon&quot; /&gt;
  &lt;!-- 根据开关决定是否显示 label --&gt;
  &lt;span v-if=&quot;showLabelWhileLoading&quot; class=&quot;mg-button-label&quot;&gt;
    &lt;!-- 插槽 &gt; loadingLabel prop &gt; label --&gt;
    &lt;slot name=&quot;loading-label&quot;&gt;{{ loadingLabel || label }}&lt;/slot&gt;
  &lt;/span&gt;
&lt;/template&gt;
</code></pre>

<p>使用场景：</p>

<pre><code class="language-vue">&lt;!-- 默认：只显示加载动画 --&gt;
&lt;Button loading label=&quot;保存&quot; /&gt;

&lt;!-- 保留文字：提示用户&quot;正在保存&quot; --&gt;
&lt;Button loading :show-label-while-loading=&quot;true&quot; label=&quot;保存&quot; /&gt;

&lt;!-- 自定义加载文字 --&gt;
&lt;Button
  loading
  :show-label-while-loading=&quot;true&quot;
  loading-label=&quot;提交中...&quot;
  label=&quot;提交&quot;
/&gt;

&lt;!-- 用插槽完全自定义 --&gt;
&lt;Button loading :show-label-while-loading=&quot;true&quot;&gt;
  &lt;template #loading-label&gt;⏳ 拼命保存中...&lt;/template&gt;
&lt;/Button&gt;
</code></pre>

<p>纯 CSS 实现加载动画：</p>

<pre><code class="language-css">.mg-button-loading-icon {
  width: 1rem;
  height: 1rem;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  animation: mg-button-spin 0.6s linear infinite;
}
@keyframes mg-button-spin {
  to {
    transform: rotate(360deg);
  }
}
</code></pre>

<h2 id="六-css-样式设计">六、CSS 样式设计</h2>

<h3 id="6-1-基础样式">6.1 基础样式</h3>

<p>按钮使用 <code>inline-flex</code> 布局，内容水平和垂直居中，直角边框（<code>--ui-radius-none</code>），内边距和字体大小使用设计令牌。</p>

<pre><code class="language-css">.mg-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--ui-spacing-sm);
  font-weight: 500;
  transition: all var(--ui-motion-duration-neural) ease;
  cursor: pointer;
  border-radius: var(--ui-radius-none);
  border: none;
  background: transparent;
  white-space: nowrap;
  padding: var(--ui-spacing-sm) var(--ui-spacing-md);
  font-size: var(--ui-typography-size-body);
}
</code></pre>

<h3 id="6-2-尺寸变体">6.2 尺寸变体</h3>

<table>
<thead>
<tr>
<th>尺寸</th>
<th>内边距</th>
<th>字体大小</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>sm</code></td>
<td><code>sm</code> / <code>md</code></td>
<td><code>--ui-typography-size-code</code> (13px)</td>
</tr>

<tr>
<td><code>md</code></td>
<td><code>md</code> / <code>lg</code></td>
<td><code>--ui-typography-size-body</code> (15px)</td>
</tr>

<tr>
<td><code>lg</code></td>
<td><code>lg</code> / <code>xl</code></td>
<td><code>1.125rem</code> (18px)</td>
</tr>
</tbody>
</table>

<h3 id="6-3-颜色变体-hover-active-状态">6.3 颜色变体：hover/active 状态</h3>

<p>v1.5.0 的颜色变体不仅定义了基础色，还完善了 hover/active 反馈。使用 <code>color-mix()</code> 在令牌颜色基础上混合黑色实现：</p>

<pre><code class="language-css">.mg-button-filled-primary {
  background-color: var(--ui-primary);
  color: white;
}
.mg-button-filled-primary:hover:not(:disabled) {
  background-color: color-mix(in srgb, var(--ui-primary), black 10%);
}
.mg-button-filled-primary:active:not(:disabled) {
  background-color: color-mix(in srgb, var(--ui-primary), black 20%);
}

/* outline 变体：透明背景 + 边框 */
.mg-button-outline-primary {
  background-color: transparent;
  color: var(--ui-primary);
  border: 1px solid var(--ui-primary);
}
.mg-button-outline-primary:hover:not(:disabled) {
  background-color: color-mix(in srgb, var(--ui-primary), transparent 90%);
}
</code></pre>

<p>使用 <code>:not(:disabled)</code> 确保禁用状态不触发 hover 效果。</p>

<h3 id="6-4-图标与文字容器">6.4 图标与文字容器</h3>

<p>图标容器使用 <code>inline-flex</code> 并设置 <code>line-height: 0</code> 来消除行高影响，内部的 SVG 或 iconify 图标强制块级并设置宽高为 <code>1em</code>。</p>

<pre><code class="language-css">.mg-button-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  line-height: 0;
}
.mg-button-icon svg,
.mg-button-icon .iconify {
  display: block;
  width: 1em;
  height: 1em;
}
</code></pre>

<pre><code class="language-css">/* 空标签隐藏 - 修复只有图标时的居中问题 */
.mg-button-label:empty {
  display: none;
}
</code></pre>

<p>CSS 层的 <code>:empty</code> 伪类 + 组件层的 <code>hasLabel</code> 判断共同保证了纯图标按钮的正确居中。</p>

<h2 id="七-无障碍设计">七、无障碍设计</h2>

<p>当前 Button 不添加 <code>aria-busy</code> 和 <code>aria-disabled</code> 属性。无障碍策略的核心是依赖原生语义：</p>

<ul>
<li><strong><code>disabled</code> 原生属性</strong>已经可以让屏幕阅读器正确读出禁用状态</li>
<li><strong>加载状态</strong>通过视觉反馈（旋转动画）表达，比额外的 ARIA 属性更直观</li>
<li>移除多余的 ARIA 属性，避免和原生语义重复</li>
</ul>

<p>这不是&rdquo;极简 ≠ 简陋&rdquo;的妥协，而是<strong>避免 ARIA 过度使用</strong>——原生 HTML 语义（<code>&lt;button disabled&gt;</code>）本身就是最可靠的无障碍。项目真正的无障碍保障来自测试层：<code>a11y.test.ts</code> 使用 axe-core 对 12 个核心组件做自动化规范检查（<code>expectNoViolations</code>）。</p>

<h2 id="八-属性透传">八、属性透传</h2>

<p>使用 <code>v-bind=&quot;$attrs&quot;</code> 透传原生属性：</p>

<pre><code class="language-vue">&lt;button v-bind=&quot;$attrs&quot; class=&quot;mg-button&quot; ...&gt;
</code></pre>

<p>用户可以直接传入 <code>id</code>、<code>name</code>、<code>data-*</code>、<code>aria-*</code> 等属性：</p>

<pre><code class="language-vue">&lt;Button id=&quot;submit-btn&quot; name=&quot;submit&quot; data-testid=&quot;submit&quot;&gt;
  提交
&lt;/Button&gt;
</code></pre>

<p>配合 <code>defineOptions({ inheritAttrs: false })</code>，外部 class 会通过 <code>$attrs</code> 暴露给用户自行处理，同时组件内部的 class 绑定不会冲突。</p>

<h2 id="九-最终代码-v1-5-0-实际实现">九、最终代码（v1.5.0 实际实现）</h2>

<pre><code class="language-vue">&lt;template&gt;
  &lt;button
    v-bind=&quot;$attrs&quot;
    :type=&quot;type&quot;
    class=&quot;mg-button&quot;
    :class=&quot;[
      `mg-button-${variant}-${color}`,
      `mg-button-${size}`,
      { 'mg-button-block': block, 'mg-button-loading': loading },
    ]&quot;
    :disabled=&quot;disabled || loading&quot;
    @click=&quot;handleClick&quot;
  &gt;
    &lt;!-- 加载状态 --&gt;
    &lt;template v-if=&quot;loading&quot;&gt;
      &lt;span class=&quot;mg-button-loading-icon&quot; /&gt;
      &lt;span v-if=&quot;showLabelWhileLoading&quot; class=&quot;mg-button-label&quot;&gt;
        &lt;slot name=&quot;loading-label&quot;&gt;{{ loadingLabel || label }}&lt;/slot&gt;
      &lt;/span&gt;
    &lt;/template&gt;

    &lt;!-- 正常状态 --&gt;
    &lt;template v-else&gt;
      &lt;span v-if=&quot;hasIconSlot || icon&quot; class=&quot;mg-button-icon&quot;&gt;
        &lt;slot name=&quot;icon&quot;&gt;
          &lt;component :is=&quot;icon&quot; v-if=&quot;typeof icon !== 'string'&quot; /&gt;
          &lt;span v-else-if=&quot;icon&quot;&gt;{{ icon }}&lt;/span&gt;
        &lt;/slot&gt;
      &lt;/span&gt;
      &lt;span v-if=&quot;hasLabel&quot; class=&quot;mg-button-label&quot;&gt;
        &lt;slot&gt;{{ label }}&lt;/slot&gt;
      &lt;/span&gt;
    &lt;/template&gt;
  &lt;/button&gt;
&lt;/template&gt;

&lt;script setup lang=&quot;ts&quot;&gt;
import { useSlots, computed } from &quot;vue&quot;
import type { Component } from &quot;vue&quot;
import type { Size, AddonColor } from &quot;../types/components&quot;

defineOptions({ name: &quot;Button&quot;, inheritAttrs: false })

type Variant = &quot;filled&quot; | &quot;outline&quot;
type ButtonType = &quot;button&quot; | &quot;submit&quot; | &quot;reset&quot;

interface Props {
  label?: string
  variant?: Variant
  color?: AddonColor
  size?: Size
  type?: ButtonType
  disabled?: boolean
  loading?: boolean
  showLabelWhileLoading?: boolean
  loadingLabel?: string
  block?: boolean
  icon?: string | Component
}

const props = withDefaults(defineProps&lt;Props&gt;(), {
  label: &quot;&quot;,
  variant: &quot;filled&quot;,
  color: &quot;primary&quot;,
  size: &quot;sm&quot;,
  type: &quot;button&quot;,
  disabled: false,
  loading: false,
  showLabelWhileLoading: false,
  block: false,
})

defineSlots&lt;{
  default: () =&gt; any
  icon: () =&gt; any
  &quot;loading-label&quot;: () =&gt; any
}&gt;()

const slots = useSlots()
const hasIconSlot = computed(() =&gt; !!slots.icon)
const hasLabel = computed(() =&gt; props.label !== &quot;&quot; || !!slots.default)

const emit = defineEmits&lt;{ click: [event: MouseEvent] }&gt;()

const handleClick = (event: MouseEvent) =&gt; {
  if (props.disabled || props.loading) return
  emit(&quot;click&quot;, event)
}
&lt;/script&gt;
</code></pre>

<h2 id="十-与其他主流-ui-库的-api-对比">十、与其他主流 UI 库的 API 对比</h2>

<table>
<thead>
<tr>
<th align="left">API 特性</th>
<th align="left"><strong>Moongate UI (本文)</strong></th>
<th align="left"><strong>✨ Nuxt UI v4</strong></th>
<th align="left">Naive UI (NButton)</th>
<th align="left">PrimeVue (Button)</th>
</tr>
</thead>

<tbody>
<tr>
<td align="left"><strong>核心风格</strong></td>
<td align="left"><code>variant</code> (filled/outline)</td>
<td align="left"><code>variant</code> + <code>color</code> (solid/outline/soft/subtle/ghost/link)</td>
<td align="left"><code>type</code> (primary/success/warning/error/info)</td>
<td align="left"><code>severity</code> + <code>variant</code></td>
</tr>

<tr>
<td align="left"><strong>尺寸</strong></td>
<td align="left"><code>size</code> (sm/md/lg)</td>
<td align="left"><code>size</code> (xs/sm/md/lg/xl)</td>
<td align="left"><code>size</code> (small/medium/large)</td>
<td align="left"><code>size</code> (small/medium/large)</td>
</tr>

<tr>
<td align="left"><strong>禁用</strong></td>
<td align="left"><code>disabled</code></td>
<td align="left"><code>disabled</code></td>
<td align="left"><code>disabled</code></td>
<td align="left"><code>disabled</code></td>
</tr>

<tr>
<td align="left"><strong>加载</strong></td>
<td align="left"><code>loading</code></td>
<td align="left"><code>loading</code> / <code>loadingAuto</code></td>
<td align="left"><code>loading</code></td>
<td align="left"><code>loading</code></td>
</tr>

<tr>
<td align="left"><strong>块级</strong></td>
<td align="left"><code>block</code></td>
<td align="left"><code>block</code></td>
<td align="left"><code>block</code></td>
<td align="left"><code>fluid</code></td>
</tr>

<tr>
<td align="left"><strong>图标</strong></td>
<td align="left"><code>icon</code> prop + <code>#icon</code> 插槽</td>
<td align="left"><code>icon</code> / <code>leading-icon</code> / <code>trailing-icon</code> + 插槽</td>
<td align="left">无</td>
<td align="left"><code>icon</code> + <code>iconPos</code></td>
</tr>

<tr>
<td align="left"><strong>加载文字</strong></td>
<td align="left"><code>showLabelWhileLoading</code> + <code>loadingLabel</code></td>
<td align="left"><code>loading</code> / <code>loadingAuto</code></td>
<td align="left">无</td>
<td align="left"><code>loading</code></td>
</tr>

<tr>
<td align="left"><strong>原生类型</strong></td>
<td align="left"><code>type</code> (button/submit/reset)</td>
<td align="left">透传</td>
<td align="left">透传</td>
<td align="left">透传</td>
</tr>

<tr>
<td align="left"><strong>额外样式</strong></td>
<td align="left">无</td>
<td align="left"><code>square</code></td>
<td align="left"><code>dashed</code>, <code>circle</code>, <code>round</code></td>
<td align="left"><code>rounded</code>, <code>raised</code>, <code>outlined</code></td>
</tr>

<tr>
<td align="left"><strong>Vue Router 集成</strong></td>
<td align="left">不支持 (由用户包装)</td>
<td align="left">原生支持 (<code>to</code>/<code>href</code>)</td>
<td align="left">不支持</td>
<td align="left">通过 <code>as</code> 属性间接支持</td>
</tr>
</tbody>
</table>

<blockquote>
<p>说明：表格中 <strong>Nuxt UI</strong>、<strong>Naive UI</strong> 和 <strong>PrimeVue</strong> 的 API 信息均来自其官方文档 (2026 年版本)。</p>
</blockquote>

<p>通过这张表，可以清晰地看到 Moongate 的设计取舍：</p>

<ul>
<li><strong><code>variant</code> 与 <code>color</code> 的解耦</strong>：关注&rdquo;视觉模式&rdquo;与&rdquo;语义颜色&rdquo;分离</li>
<li><strong>尺寸的精简</strong>：3 种尺寸足够覆盖个人博客场景</li>
<li><strong>加载状态的精细化</strong>：<code>showLabelWhileLoading</code>/<code>loadingLabel</code> 是对&rdquo;加载时文案&rdquo;需求的响应——这是其他库经常忽略的细节</li>
<li><strong>图标支持的灵活性</strong>：prop 快速写法 + 插槽完全控制</li>
</ul>

<h2 id="十一-关于加载状态宽度变化的讨论">十一、关于加载状态宽度变化的讨论</h2>

<p>组件开发中有一个经典陷阱——<strong>按钮加载时宽度变化导致布局偏移（CLS）</strong>。</p>

<p>常见的解法是 <code>min-width</code> 预留 + 加载图标绝对定位。但在 v1.5.0 的实际实现中，我<strong>没有采用这种方式</strong>，原因是：</p>

<ol>
<li><strong><code>min-width: 88px</code> 是硬编码值</strong>，会破坏&rdquo;小型组件响应不同内容宽度&rdquo;的灵活性</li>
<li>绝对定位的加载图标在 <code>loading=&quot;true&quot;</code> 时脱离文档流，如果按钮内有其他内容（如加载文字），布局仍可能变化</li>
<li>更好的性能优化是<strong>保证加载状态的文案长度接近正常状态</strong>（<code>loadingLabel</code> 帮助用户保持文案一致）</li>
</ol>

<p>如果你的场景确实对 CLS 要求严格（如电商下单按钮），可以在业务层手动添加：</p>

<pre><code class="language-css">.my-order-button {
  min-width: 120px; /* 根据你实际按钮宽度预留 */
}
</code></pre>

<p>组件库层面保持灵活，用户按需优化。</p>

<h2 id="十二-设计决策总结">十二、设计决策总结</h2>

<table>
<thead>
<tr>
<th>决策</th>
<th>原因</th>
</tr>
</thead>

<tbody>
<tr>
<td>删除 <code>xs</code> 尺寸</td>
<td>使用频率低，简化 API</td>
</tr>

<tr>
<td>删除 <code>ghost</code> 变体</td>
<td>可用 <code>outline</code> 替代</td>
</tr>

<tr>
<td>删除 <code>neutral</code> 颜色</td>
<td>可用 <code>outline</code> + 默认色替代</td>
</tr>

<tr>
<td>默认尺寸为 <code>sm</code></td>
<td>主流 UI 库默认按钮约 32px</td>
</tr>

<tr>
<td>默认 <code>type=&quot;button&quot;</code></td>
<td>防止表单意外提交</td>
</tr>

<tr>
<td><code>icon</code> prop + <code>#icon</code> 插槽共存</td>
<td>快速写法 + 完全控制，插槽优先</td>
</tr>

<tr>
<td><code>label</code> prop + 默认插槽</td>
<td>两种写法都支持</td>
</tr>

<tr>
<td><code>hasLabel = label !== ''</code></td>
<td>纯图标按钮不渲染空 label 容器</td>
</tr>

<tr>
<td>loading 增强</td>
<td><code>showLabelWhileLoading</code> + <code>loadingLabel</code></td>
</tr>

<tr>
<td><code>v-bind=&quot;$attrs&quot;</code></td>
<td>透传原生属性，保持灵活性</td>
</tr>

<tr>
<td>不添加 aria-busy/aria-disabled</td>
<td>依赖原生 disabled 语义 + axe-core 测试保障</td>
</tr>

<tr>
<td>不做 CLS 魔法</td>
<td>保持灵活性，用户按需优化</td>
</tr>
</tbody>
</table>

<h2 id="十三-测试保障">十三、测试保障</h2>

<p>v1.5.0 的 Button 有 <strong>20 个组件测试</strong>，覆盖：</p>

<ul>
<li>默认 props 渲染</li>
<li>variant/color/size 变体 class</li>
<li>loading 状态（自动禁用、隐藏文字、showLabelWhileLoading、loadingLabel、插槽）</li>
<li>纯图标按钮（label 为空不渲染容器）</li>
<li><code>icon</code> prop（字符串渲染文本、Component 渲染组件、插槽优先于 prop）</li>
<li>disabled/loading 时 click 不触发</li>
<li>属性透传（id、data-*）</li>
<li><code>type</code> prop 透传</li>
</ul>

<p>以及 <strong>axe-core 可访问性检查</strong>（Button 无违规）和 <strong>SSR 渲染检查</strong>。</p>

<hr>

<p>本篇以 Button 为例梳理了简单组件的设计要点。下一篇将深入复杂组件，涵盖数据适配、内部状态、逻辑复用等更高级的话题。</p>
]]></content:encoded>
      <description><![CDATA[以 Button 这一简单组件为例，深入探讨 Vue 3 组件库的 API 设计哲学，涵盖 Props 定义、变体系统、尺寸取舍、插槽设计、状态管理、无障碍支持及与主流 UI 库的对比，揭示极简 API 背后的设计权衡。]]></description>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[Design System]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:moongate-vue]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[CSS 优先 + 组件薄封装：一个 25KB 组件库的极简实践]]></title>
      <link>https://moongate.top/docs/css-first-component-library</link>
      <guid isPermaLink="true">7b75c994-ce43-4f78-89e1-817c295f3f00</guid>
      <pubDate>Sun, 19 Apr 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共五篇，覆盖从设计令牌到 npm 发布的 Vue 3 组件库开发全流程：</p>

<ol>
<li><p><a href="./design-tokens-vs-atomic-css"><strong>设计令牌 vs 原子化 CSS：失败整合与融合之道（理念篇）</strong></a>
—— 用 UnoCSS 映射设计令牌的失败经历，量化对比后得出设计令牌优先的结论。</p></li>

<li><p><a href="./css-first-component-library"><strong>CSS 优先 + 组件薄封装：一个 25KB 组件库的极简实践（架构篇）</strong></a>
—— 四层 CSS 架构、极简 Vue 组件、Vite 多入口构建、体积预算验证，单组件极简实现。</p></li>

<li><p><a href="./vue-component-api-design"><strong>Vue 3 简单组件开发实战：从 Button 组件看 API 设计（简单组件篇）</strong></a>
—— Props 定义、变体系统、尺寸取舍、插槽设计、状态管理、无障碍支持及与主流 UI 库对比。</p></li>

<li><p><a href="./complex-component-api-design"><strong>Vue 3 复杂组件开发实战：Select 与 Pagination 的 API 设计（复杂组件篇）</strong></a>
—— 数据格式适配、类型回溯、可搜索/多选、ARIA 键盘导航、组合式函数抽离及 SSR 适配，揭示工业级细节。</p></li>

<li><p><a href="./component-library-publishing"><strong>从代码到 npm：Vue 3 组件库发布实战与避坑指南（发布篇）</strong></a>
—— nrm 源管理、2FA 配置、WebAuthn 网络代理避坑、本地链接测试、自动化脚本及工业级发布检查清单。</p></li>
</ol>

<h2 id="回顾-第一篇文章的结论">回顾：第一篇文章的结论</h2>

<p>在上一篇文章<a href="./design-tokens-vs-atomic-css.md">《design-tokens-vs-atomic-css》</a>中，我分享了尝试用 UnoCSS 映射已有设计令牌的失败经历。核心结论是：</p>

<ul>
<li><strong>设计令牌是地基，原子化只是涂料</strong></li>
<li>强行映射只会增加维护成本，得不偿失</li>
<li>对于已有成熟设计令牌的项目，原子化 CSS 不是必需品</li>
</ul>

<p>那么，<strong>不用原子化 CSS，组件库应该怎么写？</strong></p>

<p>这篇文章给出答案——以及 v1.5.0 在初版方案之上的工程进化。</p>

<h2 id="最终架构-四层-css-架构">最终架构：四层 CSS 架构</h2>

<p>整个样式系统分为多个层级，职责清晰、层层依赖：</p>

<pre><code class="language-text">┌──────────────────────────────────────────────────────────────┐
│ 设计令牌层（自动生成） │
│ ┌─────────────────────┐ ┌──────────────────────────────┐ │
│ │ tokens/colors.css   │ │ tokens/layout.css            │ │
│ │ 颜色令牌（浅/深各   │ │ 间距/字体/动效/z-index 令牌  │ │
│ │ 68 个变量）         │ │ 【组件库的核心 API 层】      │ │
│ └──────────┬──────────┘ └─────────────┬────────────────┘ │
│            └──────────────┬───────────┘                   │
│                           ↓                              │
│ 组件样式层（手写）                                         │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ components/ 各组件独立样式文件                          │ │
│ │ （Button.css, Card.css, ... 共 20+ 文件）               │ │
│ │ 引用 var(--ui-*) 令牌                                   │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│                            ↓                              │
│ 工具层（手写）                                              │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ utilities/ 极简语义工具类（颜色/文本/契约变量）         │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│                            ↓                              │
│ 入口层（手写）                                              │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ index.css 导入令牌 + 组件样式 + 工具类                  │ │
│ └────────────────────────────────────────────────────────┘ │
│                                                            │
│ reset.css（可选，独立导出，不属于层级链）                    │
└──────────────────────────────────────────────────────────────┘
</code></pre>

<h3 id="各文件-文件夹职责">各文件/文件夹职责</h3>

<table>
<thead>
<tr>
<th>文件/文件夹</th>
<th>职责</th>
<th>生成方式</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>tokens/colors.css</code></td>
<td>浅色/深色模式颜色令牌（各 68 个变量）</td>
<td>主题脚本自动生成</td>
</tr>

<tr>
<td><code>tokens/layout.css</code></td>
<td>间距、字体、动效、断点、z-index 令牌</td>
<td>主题脚本自动生成</td>
</tr>

<tr>
<td><code>components/</code></td>
<td>各组件独立样式文件（Button.css 等）</td>
<td>手写</td>
</tr>

<tr>
<td><code>utilities/</code></td>
<td>极简工具类</td>
<td>手写</td>
</tr>

<tr>
<td><code>reset.css</code></td>
<td>可选全局重置（box-sizing），独立导出</td>
<td>手写，不属于层级链</td>
</tr>

<tr>
<td><code>index.css</code></td>
<td>总入口，导入令牌 + 组件 + 工具</td>
<td>手写</td>
</tr>
</tbody>
</table>

<h3 id="设计令牌即-api">设计令牌即 API</h3>

<p>在这种模式下，<code>colors.css</code> 不仅仅是样式，它更像是组件库的 <strong>Configuration API</strong>。用户通过修改这些 CSS 变量（如 <code>--ui-primary</code>、<code>--ui-spacing-md</code>），就能在不触碰任何 JS 逻辑的情况下，完成整套 UI 的换肤。这是设计令牌最核心的价值——<strong>样式配置与代码逻辑彻底分离</strong>。</p>

<h3 id="工程红利-多框架复用">工程红利：多框架复用</h3>

<p>这种解耦意味着，如果明天我想把项目从 Vue 迁移到 React 或 Svelte，我只需要重写一遍 ~50 行的逻辑组件，而那套核心样式可以原地复用，无需任何改动。<strong>这是&rdquo;样式绑定逻辑&rdquo;的原子化方案永远无法做到的。</strong></p>

<h2 id="极简组件-button-vue-为例">极简组件：Button.vue 为例</h2>

<p>有了全局 CSS 类，Vue 组件只需要做三件事：</p>

<ol>
<li>组合正确的类名</li>
<li>处理交互逻辑（click、disabled、loading）</li>
<li>透传插槽</li>
</ol>

<p>以 v1.5.0 的实际代码为例（已精简注释）：</p>

<pre><code class="language-vue">&lt;script setup lang=&quot;ts&quot;&gt;
import { useSlots, computed } from &quot;vue&quot;
import type { Component } from &quot;vue&quot;
import type { Size, AddonColor } from &quot;../types/components&quot;

defineOptions({ name: &quot;Button&quot;, inheritAttrs: false })

type Variant = &quot;filled&quot; | &quot;outline&quot;
type ButtonType = &quot;button&quot; | &quot;submit&quot; | &quot;reset&quot;

interface Props {
  label?: string
  variant?: Variant
  color?: AddonColor
  size?: Size
  type?: ButtonType
  disabled?: boolean
  loading?: boolean
  showLabelWhileLoading?: boolean
  loadingLabel?: string
  block?: boolean
  icon?: string | Component
}

const props = withDefaults(defineProps&lt;Props&gt;(), {
  label: &quot;&quot;,
  variant: &quot;filled&quot;,
  color: &quot;primary&quot;,
  size: &quot;sm&quot;,
  type: &quot;button&quot;,
  disabled: false,
  loading: false,
  showLabelWhileLoading: false,
  block: false,
})

const slots = useSlots()
const hasIconSlot = computed(() =&gt; !!slots.icon)
const hasLabel = computed(() =&gt; props.label !== &quot;&quot; || !!slots.default)

const emit = defineEmits&lt;{ click: [event: MouseEvent] }&gt;()

const handleClick = (event: MouseEvent) =&gt; {
  if (props.disabled || props.loading) return
  emit(&quot;click&quot;, event)
}
&lt;/script&gt;

&lt;template&gt;
  &lt;button
    v-bind=&quot;$attrs&quot;
    :type=&quot;type&quot;
    class=&quot;mg-button&quot;
    :class=&quot;[
      `mg-button-${variant}-${color}`,
      `mg-button-${size}`,
      { 'mg-button-block': block, 'mg-button-loading': loading },
    ]&quot;
    :disabled=&quot;disabled || loading&quot;
    @click=&quot;handleClick&quot;
  &gt;
    &lt;template v-if=&quot;loading&quot;&gt;
      &lt;span class=&quot;mg-button-loading-icon&quot; /&gt;
      &lt;span v-if=&quot;showLabelWhileLoading&quot; class=&quot;mg-button-label&quot;&gt;
        &lt;slot name=&quot;loading-label&quot;&gt;{{ loadingLabel || label }}&lt;/slot&gt;
      &lt;/span&gt;
    &lt;/template&gt;

    &lt;template v-else&gt;
      &lt;span v-if=&quot;hasIconSlot || icon&quot; class=&quot;mg-button-icon&quot;&gt;
        &lt;slot name=&quot;icon&quot;&gt;
          &lt;component :is=&quot;icon&quot; v-if=&quot;typeof icon !== 'string'&quot; /&gt;
          &lt;span v-else-if=&quot;icon&quot;&gt;{{ icon }}&lt;/span&gt;
        &lt;/slot&gt;
      &lt;/span&gt;
      &lt;span v-if=&quot;hasLabel&quot; class=&quot;mg-button-label&quot;&gt;
        &lt;slot&gt;{{ label }}&lt;/slot&gt;
      &lt;/span&gt;
    &lt;/template&gt;
  &lt;/button&gt;
&lt;/template&gt;
</code></pre>

<p><strong>组件特点</strong>：</p>

<ul>
<li>无 <code>&lt;style&gt;</code> 块，样式全部来自全局 CSS</li>
<li>只有 ~110 行代码，极简清晰</li>
<li>类型安全（TypeScript），共享类型从 <code>src/types/components.ts</code> 导入</li>
<li>支持 11 种 props + 3 种插槽，覆盖日常场景</li>
<li><code>v-bind=&quot;$attrs&quot;</code> 透传原生属性</li>
</ul>

<h2 id="构建架构-vite-多入口-独立导出">构建架构：Vite 多入口 + 独立导出</h2>

<p>初版组件库只有一个主入口。但随着组件增多，需要支持<strong>按需引入</strong>——用户只想用 Button 时不应加载全部组件。</p>

<p>v1.5.0 采用 Vite library mode 的多入口构建：</p>

<pre><code class="language-ts">// vite.config.ts（简化）
import { componentNames } from &quot;./scripts/component-list.js&quot;

// 每个组件独立入口（src/exports/&lt;Name&gt;.ts → dist/&lt;kebab&gt;.js）
const componentEntries = Object.fromEntries(
  componentNames.map((name) =&gt; {
    const kebab = name.replace(/([a-z])([A-Z])/g, &quot;$1-$2&quot;).toLowerCase()
    return [`${kebab}`, resolve(__dirname, `src/exports/${name}.ts`)]
  }),
)

export default defineConfig({
  build: {
    lib: {
      entry: {
        index: resolve(__dirname, &quot;src/index.ts&quot;),
        ...componentEntries, // 27 个组件 + 主入口
      },
      formats: [&quot;es&quot;], // 纯 ES Module，无 CJS
    },
    rollupOptions: {
      external: [&quot;vue&quot;], // Vue 作为 peerDependency
      output: {
        assetFileNames: &quot;style.css&quot;, // CSS 统一输出
      },
    },
    cssCodeSplit: false,
  },
})
</code></pre>

<p>对应的 <code>package.json</code> 导出映射：</p>

<pre><code class="language-json">{
  &quot;main&quot;: &quot;./dist/index.js&quot;,
  &quot;module&quot;: &quot;./dist/index.js&quot;,
  &quot;types&quot;: &quot;./dist/index.d.ts&quot;,
  &quot;exports&quot;: {
    &quot;.&quot;: {
      &quot;types&quot;: &quot;./dist/index.d.ts&quot;,
      &quot;import&quot;: &quot;./dist/index.js&quot;,
      &quot;default&quot;: &quot;./dist/index.js&quot;
    },
    &quot;./style.css&quot;: &quot;./dist/style.css&quot;,
    &quot;./reset.css&quot;: &quot;./dist/reset.css&quot;,
    &quot;./button&quot;: {
      &quot;types&quot;: &quot;./dist/exports/Button.d.ts&quot;,
      &quot;import&quot;: &quot;./dist/button.js&quot;
    },
    &quot;./badge&quot;: { &quot;...&quot;: &quot;...&quot; }
  }
}
</code></pre>

<p>用户既可以使用全量引入 <code>import { Button } from 'moongate-vue'</code>，也可以按需 <code>import Button from 'moongate-vue/button'</code>。</p>

<h2 id="体积控制-25kb-预算-自动化验证">体积控制：25KB 预算 + 自动化验证</h2>

<p>体积是组件库的生命线。为了<strong>不让体积悄悄失控</strong>，我在 <code>pnpm build</code> 后自动执行 <code>scripts/tree-shake-check.js</code>：</p>

<ul>
<li>使用 Vite JS API 将 <code>src/index.ts</code> 打包为单个 ESM bundle（minify）</li>
<li>统计 JS + CSS 的 gzip 体积</li>
<li>如果超过 <strong>25KB 预算</strong>，build 会在 CI 中断言失败</li>
</ul>

<pre><code class="language-bash"># 构建后自动输出（简化示例）
📦 完整库 Min+Gzip：
  ✅ 完整库: 32.50 KB (gzipped 24.80 KB)
      ├─ JS:    22.00 KB (gzipped 9.20 KB)
      └─ CSS:   10.50 KB (gzipped 5.60 KB)

✅ 完整库 Min+Gzip 在 25KB 预算内
</code></pre>

<p>这个&rdquo;预算&rdquo;与文化有关：我用「25KB gzip 完整组件库」作为设计挑战来对抗组件库普遍臃肿的现状。与主流相比，Element Plus 完整引入约 100KB+ gzip，Naive UI 约 120KB+。<strong>25KB 是一个数量级的差距。</strong></p>

<h3 id="为什么能这么小">为什么能这么小？</h3>

<ol>
<li><strong>零运行时依赖</strong>：peerDependencies 只有 <code>vue</code>，没有 lodash、async-validator 等</li>
<li><strong>CSS 变量代替 JS 主题系统</strong>：主题切换不需要 JS 集成</li>
<li><strong>组件薄封装</strong>：逻辑极简，组合式函数复用</li>
<li><strong>极少的运行时 JS</strong>：组合式函数复用 + 无运行时依赖</li>
</ol>

<h2 id="微工具类-极简语义工具类">微工具类：极简语义工具类</h2>

<p><code>utilities/</code> 中保留了一套极简的<strong>语义工具类</strong>，直接引用设计令牌：</p>

<pre><code class="language-css">/* 语义颜色工具类 */
.text-primary {
  color: var(--ui-primary);
}
.text-muted {
  color: var(--ui-text-muted);
}
.bg-primary {
  background-color: var(--ui-primary);
}
.bg-muted {
  background-color: var(--ui-bg-muted);
}

/* 核心契约 */
:root {
  --ui-radius: 0px;
  --ui-glow-alpha: var(--ui-physics-glow-alpha-dawn);
}
</code></pre>

<p><strong>特点</strong>：</p>

<ul>
<li>只有最常用的 ~20 个类，按需添加</li>
<li>数值绑定设计令牌（<code>var(--ui-*)</code>），保持主题一致</li>
<li>通过 <code>--ui-radius</code>/<code>--ui-glow-alpha</code> 契约变量为全局提供样式锚点</li>
<li>附带 <code>.mg-lunar-halo</code>（月晕阴影效果）等设计系统特有的工具类</li>
<li>布局需求在组件内部通过 scoped 样式解决，工具层不承担布局职责</li>
</ul>

<h2 id="非侵入式样式">非侵入式样式</h2>

<p>组件库在 v1.5.0 明确了<strong>样式非侵入原则</strong>：</p>

<ul>
<li><code>style.css</code> 只包含组件样式，<strong>不会重置你的全局样式</strong></li>
<li>可选引入 <code>moongate-vue/reset.css</code> 统一 <code>box-sizing: border-box</code></li>
</ul>

<pre><code class="language-js">// 只引入组件样式
import &quot;moongate-vue/style.css&quot;

// 或额外引入全局重置（可选）
import &quot;moongate-vue/reset.css&quot;
</code></pre>

<h2 id="体积与维护性分析">体积与维护性分析</h2>

<h3 id="体积数据-v1-5-0-实测口径">体积数据（v1.5.0 实测口径）</h3>

<table>
<thead>
<tr>
<th>类型</th>
<th>原始大小</th>
<th>Gzip 压缩后</th>
</tr>
</thead>

<tbody>
<tr>
<td>CSS（令牌 + 组件样式）</td>
<td>~10.5 KB</td>
<td>~5.6 KB</td>
</tr>

<tr>
<td>JS（完整组件库）</td>
<td>~22 KB</td>
<td>~9.2 KB</td>
</tr>

<tr>
<td><strong>总计</strong></td>
<td><strong>~32.5 KB</strong></td>
<td><strong>~24.8 KB</strong></td>
</tr>
</tbody>
</table>
<p>（实际构建产物以 <code>pnpm build</code> 后的 <code>size-report.json</code> 为准）</p>

<h3 id="维护性对比">维护性对比</h3>

<table>
<thead>
<tr>
<th>维度</th>
<th>原子化方案（UnoCSS 映射）</th>
<th>本方案（CSS 变量 + 薄封装）</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>CSS 体积</strong></td>
<td>按需生成，极小</td>
<td>~5.6 KB (gzip)</td>
</tr>

<tr>
<td><strong>维护成本</strong></td>
<td>需同步映射配置</td>
<td>直接改 CSS</td>
</tr>

<tr>
<td><strong>心智负担</strong></td>
<td>记忆数百个类名及其映射逻辑</td>
<td>只需 ~20 个组件类名</td>
</tr>

<tr>
<td><strong>可读性</strong></td>
<td>模板臃肿，难以一眼看出组件层级</td>
<td>模板极简，类名语义化清晰</td>
</tr>

<tr>
<td><strong>首屏渲染</strong></td>
<td>需等待 JS 注入样式</td>
<td>纯 CSS，浏览器原生渲染</td>
</tr>

<tr>
<td><strong>运行环境</strong></td>
<td>需要 Node + PostCSS/Vite 插件 + 配置文件</td>
<td>只需浏览器支持 CSS Variables（98%+ 环境）</td>
</tr>

<tr>
<td><strong>多框架复用</strong></td>
<td>不可能</td>
<td>样式文件可跨框架</td>
</tr>

<tr>
<td><strong>按需引入</strong></td>
<td>-</td>
<td>27 个独立导出入口（v1.5.0）</td>
</tr>

<tr>
<td><strong>体积预算</strong></td>
<td>-</td>
<td>25KB gzip 强制验证（CI 中断）</td>
</tr>
</tbody>
</table>

<h2 id="总结">总结</h2>

<h3 id="适用场景">适用场景</h3>

<ul>
<li>✅ 已有成熟设计令牌的项目</li>
<li>✅ 追求极致体积（gzip &lt; 25KB）的组件库</li>
<li>✅ 需要按需引入的构建场景（Vite multi-entry）</li>
<li>✅ 不希望引入复杂工具链的场景</li>
</ul>

<h3 id="不适用场景">不适用场景</h3>

<ul>
<li>❌ 从零开始、没有设计令牌的项目</li>
<li>❌ 需要动态主题切换的大型设计系统（需 JS 主题引擎）</li>
<li>❌ 需要大量业务组件（DatePicker、Tree 等）</li>
</ul>

<h3 id="核心收获">核心收获</h3>

<p>初版的 10KB 承诺在 v1.5.0 经过多轮功能迭代（全局 i18n 文案、可搜索 Select、多选、无障碍增强、450 测试），依然保持 <strong>25KB gzip 预算内</strong>——这不是偶然，而是通过<strong>架构纪律</strong>（零依赖 + 薄封装）和<strong>自动化</strong>（tree-shake-check.js 体积门禁）共同守住的。</p>

<p><strong>这 25KB 不仅是体积的缩减，更是思维的减负。</strong></p>
]]></content:encoded>
      <description><![CDATA[设计令牌驱动的 Vue 3 组件库架构实录。四层 CSS 架构、极简 Vue 组件、Vite 多入口构建、体积预算自动化验证，展示如何保持组件库在 25KB (gzip) 内的工程实践。]]></description>
      <category><![CDATA[CSS]]></category>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[Design System]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:moongate-vue]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[当设计令牌遇上原子化CSS：一次整合失败的反思与融合之道]]></title>
      <link>https://moongate.top/docs/design-tokens-vs-atomic-css</link>
      <guid isPermaLink="true">65f7b804-7301-401d-8461-b04913b29333</guid>
      <pubDate>Sat, 18 Apr 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>从 UnoCSS 映射设计令牌的失败经历出发，量化对比两种方案的维护成本，给出务实的分工边界</p>
</blockquote>

<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共五篇，覆盖从设计令牌到 npm 发布的 Vue 3 组件库开发全流程：</p>

<ol>
<li><p><a href="./design-tokens-vs-atomic-css"><strong>设计令牌 vs 原子化 CSS：失败整合与融合之道（理念篇）</strong></a>
—— 用 UnoCSS 映射设计令牌的失败经历，量化对比后得出设计令牌优先的结论。</p></li>

<li><p><a href="./css-first-component-library"><strong>CSS 优先 + 组件薄封装：一个 25KB 组件库的极简实践（架构篇）</strong></a>
—— 四层 CSS 架构、极简 Vue 组件、Vite 多入口构建、体积预算验证，单组件极简实现。</p></li>

<li><p><a href="./vue-component-api-design"><strong>Vue 3 简单组件开发实战：从 Button 组件看 API 设计（简单组件篇）</strong></a>
—— Props 定义、变体系统、尺寸取舍、插槽设计、状态管理、无障碍支持及与主流 UI 库对比。</p></li>

<li><p><a href="./complex-component-api-design"><strong>Vue 3 复杂组件开发实战：Select 与 Pagination 的 API 设计（复杂组件篇）</strong></a>
—— 数据格式适配、类型回溯、可搜索/多选、ARIA 键盘导航、组合式函数抽离及 SSR 适配，揭示工业级细节。</p></li>

<li><p><a href="./component-library-publishing"><strong>从代码到 npm：Vue 3 组件库发布实战与避坑指南（发布篇）</strong></a>
—— nrm 源管理、2FA 配置、WebAuthn 网络代理避坑、本地链接测试、自动化脚本及工业级发布检查清单。</p></li>
</ol>

<h2 id="一-起点-我有一套完整的设计令牌">一、起点：我有一套完整的设计令牌</h2>

<p>我的个人项目 <code>moongate-vue</code> 的样式系统由三组文件构成：</p>

<ul>
<li><strong><code>src/styles/tokens/colors.css</code></strong>：浅色/深色双主题，语义化颜色变量（<code>--ui-primary</code>、<code>--ui-bg-muted</code>……），以及布局令牌</li>
<li><strong><code>src/styles/tokens/layout.css</code></strong>：间距、圆角、动效时长、字体、断点、z-index 等布局令牌</li>
<li><strong><code>src/styles/index.css</code></strong>：全局样式入口，导入令牌 + 组件样式 + 极简工具类</li>
</ul>

<p>这套令牌系统不依赖任何框架，任何组件都可以通过 <code>var(--ui-spacing-md)</code>、<code>var(--ui-primary)</code> 获取设计约束。它稳定、直观、可维护，是我整个组件库的<strong>地基</strong>。</p>

<p>关于变量规模，需要诚实说明：<code>colors.css</code> 浅色/深色模式各定义了 <strong>68 个颜色变量</strong>（远超初版的 40+）。其中一部分是组件库实际消费的 UI 令牌，另一部分是沿袭自 <code>moongate-theme</code> 项目的<strong>编辑器主题扩展变量</strong>——例如 <code>--ui-ansi-red</code>（ANSI 终端色）、<code>--ui-bracket1</code>（括号对色）、<code>--ui-git-added</code>（Git 状态色）、<code>--ui-debug-start</code>（调试断点色）等。</p>

<p>这些变量是我个人的编辑器主题项目（moongate-theme）所需的，虽然组件库自身不消费它们，但它们确实存在于令牌文件中——这是我通过&rdquo;生成脚本自动产出完整令牌集&rdquo;而非&rdquo;只产出组件所需子集&rdquo;的选择。<strong>它们不是冗余代码，只是个人其他项目的设计资产共享。</strong></p>

<h2 id="二-诱惑-unocss-的轻量与原子化">二、诱惑：UnoCSS 的轻量与原子化</h2>

<p>我听过 Tailwind 的&rdquo;笨重&rdquo;名声，但 UnoCSS 号称按需生成、零运行时、超轻量。作为个人开发者，我渴望&rdquo;不用写 CSS 类名&rdquo;的体验——直接在模板里堆 <code>flex p-4 text-center</code>，不用切文件，不用想命名。</p>

<p>于是某个夜晚，我决定：把设计令牌&rdquo;映射&rdquo;到 UnoCSS 上，既保留设计系统，又享受原子化书写。</p>

<h2 id="三-碰撞-一个晚上的挣扎-附真实配置">三、碰撞：一个晚上的挣扎（附真实配置）</h2>

<p>我写了 <code>uno.config.ts</code>，试图把每个 <code>--ui-*</code> 变量映射成原子类。以下是我当时失败的部分配置：</p>

<pre><code class="language-ts">// uno.config.ts（失败版本）
import { defineConfig } from &quot;unocss&quot;

export default defineConfig({
  theme: {
    colors: {
      primary: &quot;var(--ui-primary)&quot;,
      success: &quot;var(--ui-success)&quot;,
      warning: &quot;var(--ui-warning)&quot;,
      error: &quot;var(--ui-error)&quot;,
      &quot;bg-muted&quot;: &quot;var(--ui-bg-muted)&quot;,
      &quot;border-subtle&quot;: &quot;var(--ui-border-subtle)&quot;,
      // 需要映射 68 个变量，此处省略...
    },
    spacing: {
      xs: &quot;var(--ui-spacing-xs)&quot;,
      sm: &quot;var(--ui-spacing-sm)&quot;,
      md: &quot;var(--ui-spacing-md)&quot;,
      lg: &quot;var(--ui-spacing-lg)&quot;,
      xl: &quot;var(--ui-spacing-xl)&quot;,
      &quot;2xl&quot;: &quot;var(--ui-spacing-2xl)&quot;,
      &quot;3xl&quot;: &quot;var(--ui-spacing-3xl)&quot;,
    },
    borderRadius: {
      none: &quot;var(--ui-radius-none)&quot;,
      sm: &quot;var(--ui-radius-sm)&quot;,
    },
  },
})
</code></pre>

<p>然后在 <code>Button.vue</code> 里把原来的 scoped 样式全部替换成原子类：</p>

<pre><code class="language-vue">&lt;!-- 改造后的 Button.vue（失败尝试） --&gt;
&lt;button :class=&quot;cn('bg-primary text-white', 'bg-bg-muted', 'border-border-subtle')&quot;&gt;
</code></pre>

<p>问题很快暴露：</p>

<ul>
<li><strong>类名冗余且不语义化</strong>：<code>bg-bg-muted</code>、<code>border-border-subtle</code>，读起来像口吃。</li>
<li><strong>映射维护成本高</strong>：CSS 变量改一处，UnoCSS 配置也要改，单一真实源变成两个。</li>
<li><strong>条件逻辑爆炸</strong>：为了处理不同颜色变体，得写 <code>color === 'neutral' ? 'text-dim' : 'text-'+color</code>。</li>
<li><strong>调试困难</strong>：浏览器里看到 <code>bg-bg-muted</code>，得反向查找它对应哪个 CSS 变量。</li>
<li><strong>IDE 无补全</strong>：UnoCSS 的类型生成无法自动覆盖我的自定义变量名，导致开发时毫无提示。</li>
</ul>

<p>当晚我就删掉了所有映射代码，回到了原始方案。</p>

<h2 id="四-顿悟-设计令牌就是最轻量的原子化框架">四、顿悟：设计令牌就是最轻量的原子化框架</h2>

<p>看着 <code>bg-bg-muted</code>，我突然问自己：<strong>为什么不直接写 <code>background-color: var(--ui-bg-muted)</code> 呢？</strong></p>

<p>我的设计令牌系统本身已经提供了所有设计约束。UnoCSS 宣称的&rdquo;设计约束&rdquo;能力，我的 CSS 变量全都有。它剩下的唯一价值就是&rdquo;快速书写&rdquo;——即语法糖。</p>

<p>更深一层：<strong>把 <code>--ui-primary</code> 映射成 <code>bg-primary</code>，本质上是在&rdquo;用 CSS 封装 CSS&rdquo;</strong>。UnoCSS 在编译阶段生成 <code>.bg-primary { background-color: var(--ui-primary); }</code> 这样的代码。既然最终都是这行 CSS，我直接在 scoped 样式里写它，不是更直接吗？</p>

<h2 id="五-量化对比-纯设计令牌方案-vs-unocss-映射方案">五、量化对比：纯设计令牌方案 vs. UnoCSS 映射方案</h2>

<p>为了客观判断&rdquo;不划算&rdquo;到底多不划算，我整理了下表（基于我的项目实测，令牌数按实际 68 统计）：</p>

<table>
<thead>
<tr>
<th>指标</th>
<th>纯设计令牌方案（最终采用）</th>
<th>UnoCSS 映射方案（放弃）</th>
</tr>
</thead>

<tbody>
<tr>
<td>CSS 变量数量</td>
<td>68（浅/深各 68）</td>
<td>68（不变）</td>
</tr>

<tr>
<td>额外配置文件行数</td>
<td>0</td>
<td>~200 行（<code>uno.config.ts</code>，含 68 个颜色映射）</td>
</tr>

<tr>
<td>组件模板中类名长度</td>
<td>短（<code>mg-button</code>）</td>
<td>长（<code>bg-primary text-white rounded</code>）</td>
</tr>

<tr>
<td>修改一个颜色需要改几处</td>
<td>1 处（CSS 变量定义）</td>
<td>2 处（CSS 变量 + UnoCSS 映射）</td>
</tr>

<tr>
<td>TypeScript 支持</td>
<td>原生 CSS 变量无提示</td>
<td>可通过类型生成获得，但需额外配置</td>
</tr>

<tr>
<td>首屏 CSS 体积（gzip）</td>
<td>~4 KB（组件库实际消费）</td>
<td>~2 KB（按需生成更小）</td>
</tr>

<tr>
<td>调试体验</td>
<td>直接看到 <code>background: var(--ui-primary)</code></td>
<td>需要查找 <code>bg-primary</code> 映射到哪个变量</td>
</tr>

<tr>
<td>学习成本（新人）</td>
<td>低（只需理解 CSS 变量）</td>
<td>中（需理解映射逻辑 + UnoCSS 规则）</td>
</tr>
</tbody>
</table>
<p><strong>结论</strong>：牺牲 ~2 KB 体积，换取维护成本的巨大降低。对于个人项目，<strong>维护成本、语义清晰度、调试体验</strong>比极致的体积优化更重要。</p>

<blockquote>
<p>💡 关于&rdquo;68 个变量&rdquo;的说明：组件库实际消费的 UI 令牌大约一半，另一半是编辑器主题扩展变量（ANSI 色、括号对色等，用于个人主题项目共享）。UnoCSS 映射方案需要为全部 68 个变量维护映射，这恰恰放大了映射成本——而直接使用 CSS 变量则天然支持&rdquo;只引用需要的变量&rdquo;。</p>
</blockquote>

<h2 id="六-融合之道-务实的边界">六、融合之道：务实的边界</h2>

<p>我的经历并不证明原子化 CSS 不好，而是证明：<strong>当你的项目已经拥有一套成熟的设计令牌时，强行把令牌映射成原子类是多余的</strong>。</p>

<p>但这不等于要完全放弃原子化工具。我后来找到了合理的分工，关键是<strong>区分&rdquo;与数值无关的类&rdquo;和&rdquo;与数值相关的类&rdquo;</strong>：</p>

<table>
<thead>
<tr>
<th>样式类型</th>
<th>推荐方案</th>
<th>理由</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>布局</strong>（flex、grid、position）</td>
<td>UnoCSS 原子类（<code>flex</code>、<code>grid</code>、<code>items-center</code>、<code>justify-between</code>、<code>relative</code>）</td>
<td>不涉及数值，无需令牌，开箱即用</td>
</tr>

<tr>
<td><strong>间距</strong>（padding、margin、gap）</td>
<td><strong>优先 scoped 样式 + <code>var(--ui-spacing-*)</code></strong></td>
<td>保持主题一致性。如果非要用原子类，必须修改 UnoCSS 配置将数值映射到你的令牌（见下方案例）</td>
</tr>

<tr>
<td><strong>颜色、圆角、阴影、动效</strong></td>
<td>一律 scoped 样式 + CSS 变量</td>
<td>语义化强，无映射成本，调试直观</td>
</tr>

<tr>
<td><strong>响应式变体</strong></td>
<td>可使用 UnoCSS 的 <code>md:</code> 前缀，但只用于布局/间距类，不用于颜色</td>
<td>简洁且不污染设计令牌</td>
</tr>
</tbody>
</table>

<h3 id="关于间距的特殊说明">关于间距的特殊说明</h3>

<p>UnoCSS 默认的 <code>p-4</code> 对应 <code>1rem</code>，而你的设计令牌中可能是 <code>--ui-spacing-md: 12px</code>。如果直接写 <code>p-4</code>，就会绕过设计系统。如果你真的想用原子类的间距，必须修改配置：</p>

<pre><code class="language-ts">// uno.config.ts（仅当你想用原子间距时）
theme: {
  spacing: {
    sm: 'var(--ui-spacing-sm)',
    md: 'var(--ui-spacing-md)',
    lg: 'var(--ui-spacing-lg)',
  }
}
</code></pre>

<p>然后你就可以写 <code>p-sm</code>、<code>m-md</code>。但注意：这又回到了映射维护的问题——每次修改令牌值，都要同步更新 UnoCSS 配置。<strong>我最终选择完全不使用原子间距类，只使用无数值的布局类</strong>。</p>

<h2 id="七-如果没有设计令牌-应该用原子化吗">七、如果没有设计令牌，应该用原子化吗？</h2>

<p>我并不是原子化 CSS 的反对者。如果以下条件满足，我会毫不犹豫选择 UnoCSS/Tailwind：</p>

<ul>
<li>✅ 新项目、快速原型</li>
<li>✅ 设计系统尚未成熟，还在快速迭代</li>
<li>✅ 团队全员熟悉原子化语法</li>
<li>✅ 不需要跨框架复用样式</li>
</ul>

<p><strong>不适用场景</strong>：</p>

<ul>
<li>❌ 已有成熟设计令牌且需要长期维护</li>
<li>❌ 需要样式文件跨 Vue/React/Svelte 复用</li>
<li>❌ 对 CSS 体积不敏感（现代原子化框架其实很小，这不是主要矛盾）</li>
</ul>

<h2 id="八-给同样处境的开发者建议">八、给同样处境的开发者建议</h2>

<p>如果你和我一样，已经有一套完整的设计令牌系统（CSS 变量、Design Tokens），但想尝试原子化 CSS，我的建议是：</p>

<ol>
<li><strong>不要映射颜色、主题间距、圆角等核心令牌</strong>。这些应该直接通过 <code>var(--ui-*)</code> 在 scoped 样式中使用。</li>
<li><strong>只使用原子化工具提供的通用布局类</strong>（<code>flex</code>, <code>grid</code>, <code>items-center</code>, <code>relative</code>……）。这些与你的设计系统无关，且无需任何映射。</li>
<li><strong>警惕&rdquo;魔法数字&rdquo;</strong>：避免在模板里写 <code>p-3.5</code> 或 <code>gap-11</code> 这种硬编码值，它们会破坏设计令牌的契约。如果你发现自己经常需要偏离令牌系统，说明你的令牌定义可能不够灵活，需要扩展而非绕过。</li>
<li><strong>对于响应式布局</strong>，可以继续使用原子化工具的响应式变体（<code>md:flex</code>），但尽量只用于布局，不用于颜色/间距。</li>
<li><strong>如果原子化工具让你觉得&rdquo;为了用而用&rdquo;，完全可以不用</strong>。原生 CSS + 设计令牌已经足够清晰、可维护。</li>
</ol>

<h2 id="九-后续演进-dtcg-与未来">九、后续演进：DTCG 与未来</h2>

<p>值得一提的是，W3C 旗下的设计令牌社区组（DTCG）已在 2025 年底发布首个稳定规范，推动设计令牌成为跨平台通用语言。这意味着&rdquo;以令牌为中心&rdquo;的架构，正成为行业共识。</p>

<p>原子化工具可以消费设计令牌，但不应绑架设计令牌。将令牌硬编码成特定工具的原子类，相当于把自己的设计系统锁死在该工具的语法上。而直接使用 CSS 变量，则是框架无关、面向未来的选择。</p>

<p>未来，当 DTCG 工具链成熟后，可能会自动从设计令牌生成原子类，届时我会重新评估 UnoCSS。但目前（2026），手动映射仍是维护负担。</p>

<h2 id="十-结论-设计令牌优先-原子化可选">十、结论：设计令牌优先，原子化可选</h2>

<p>最终，我的 <code>Button.vue</code> 回到了最原始的样子：</p>

<pre><code class="language-css">/* src/styles/components/button.css */
.mg-button-filled-primary {
  background-color: var(--ui-primary);
  color: white;
}
.mg-button-filled-primary:hover:not(:disabled) {
  background-color: color-mix(in srgb, var(--ui-primary), black 10%);
}
</code></pre>

<p>简单、直接、语义化。这才是设计令牌该有的用法。</p>

<p>UnoCSS 和 Tailwind 是好工具，但它们不是设计系统的替代品。设计令牌才是地基，原子化只是上面的一层涂料。当你已经有一块坚实的地基时，是否涂上这层涂料，取决于你愿不愿意接受那点语法糖带来的维护成本。</p>

<p>至少对我来说，<strong>不划算</strong>。</p>
]]></content:encoded>
      <description><![CDATA[当个人开发者尝试用 UnoCSS 映射已有设计令牌失败后，反思工具迷信，提出设计令牌优先于原子化 CSS 的架构观点，并探索两者融合的务实边界。]]></description>
      <category><![CDATA[CSS]]></category>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[Design System]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:moongate-vue]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[用原生 <details> 实现系列折叠页：从“点两次”到“稳定可控”]]></title>
      <link>https://moongate.top/docs/series-collapse-with-details</link>
      <guid isPermaLink="true">566b3b37-191a-4423-9dd6-1f6a97c571c4</guid>
      <pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>个人博客的系列页面，我选择了原生 <code>&lt;details&gt;</code> 元素来实现折叠列表。本以为是最简单直接的方式，却在添加“全部折叠/展开”按钮时遇到了“点两次”的诡异问题。这篇文章记录了排查和解决的过程，也让我对原生 DOM 与状态同步有了更深的理解。</p>
</blockquote>

<hr>

<h2 id="1-背景与需求">1. 背景与需求</h2>

<p>我的博客最近新增了“系列”页面，用于按主题聚合文章（如“URL状态同步”、“设计系统”等）。每个系列包含多篇文章，默认折叠，用户点击系列标题可展开查看该系列下的文章列表。同时，页面右上角需要一个按钮，能够<strong>一键折叠/展开所有系列</strong>。</p>

<p>理想很朴素：用原生 <code>&lt;details&gt;</code> 和 <code>&lt;summary&gt;</code> 实现折叠面板，再写几行 JavaScript 控制全局按钮。但实际实现中，我遇到了一个典型问题：<strong>点击全局按钮时，需要点击两次才能完成切换</strong>。下面记录完整的实现过程与解决方案。</p>

<hr>

<h2 id="2-初始实现-简单的-details-循环">2. 初始实现：简单的 <code>&lt;details&gt;</code> 循环</h2>

<p>首先，我从数据库中获取所有文章，按系列分组，渲染成一个 <code>&lt;details&gt;</code> 列表。每个系列标题显示系列名和文章数量，内部展示文章标题、等级和日期。</p>

<pre><code class="language-vue">&lt;template&gt;
  &lt;div class=&quot;max-w-3xl mx-auto&quot;&gt;
    &lt;div v-for=&quot;series in seriesList&quot; :key=&quot;series.slug&quot; class=&quot;mb-4&quot;&gt;
      &lt;details&gt;
        &lt;summary class=&quot;flex items-center gap-2 cursor-pointer&quot;&gt;
          &lt;span&gt;{{ series.name }}&lt;/span&gt;
          &lt;span class=&quot;text-sm text-gray-500&quot;&gt;({{ series.docs.length }})&lt;/span&gt;
        &lt;/summary&gt;
        &lt;div class=&quot;pl-4 mt-2 space-y-2&quot;&gt;
          &lt;div v-for=&quot;article in series.docs&quot; :key=&quot;article.id&quot;&gt;
            &lt;NuxtLink :to=&quot;article.path&quot; class=&quot;text-blue-600 hover:underline&quot;&gt;
              {{ article.title }}
            &lt;/NuxtLink&gt;
            &lt;div class=&quot;text-xs text-gray-500&quot;&gt;
              {{ article.level }} · {{ formatDate(article.date) }}
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/details&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/template&gt;
</code></pre>

<p>这一步一切正常，每个系列都可以独立展开/折叠。</p>

<hr>

<h2 id="3-添加-全部折叠-展开-按钮">3. 添加“全部折叠/展开”按钮</h2>

<p>为了实现全局控制，我需要一个变量来记录当前是否有系列处于展开状态，并据此决定按钮的行为（全部折叠或全部展开）。</p>

<pre><code class="language-ts">const isAnyExpanded = ref(false);

// 更新全局状态：遍历所有 &lt;details&gt;，检查是否有 open 属性为 true
const updateAnyExpanded = () =&gt; {
  const details = document.querySelectorAll(&quot;details&quot;);
  isAnyExpanded.value = Array.from(details).some((detail) =&gt; detail.open);
};

// 全部折叠/展开
const toggleAll = () =&gt; {
  const details = document.querySelectorAll(&quot;details&quot;);
  const shouldExpand = !isAnyExpanded.value;
  details.forEach((detail) =&gt; {
    detail.open = shouldExpand;
  });
  updateAnyExpanded(); // 更新状态
};
</code></pre>

<p><strong>问题出现了</strong>：点击“全部折叠/展开”按钮时，需要点击<strong>两次</strong>才能生效。第一次点击似乎没有反应，第二次才能正确切换所有系列的状态。</p>

<hr>

<h2 id="4-问题定位-异步-dom-更新与事件冲突">4. 问题定位：异步 DOM 更新与事件冲突</h2>

<p>为什么会出现“点两次”？</p>

<p>最初我试图通过监听全局 <code>toggle</code> 事件来同步手动点击的状态。<code>&lt;details&gt;</code> 的 <code>toggle</code> 事件在用户点击 <code>&lt;summary&gt;</code> 后会<strong>异步</strong>触发，但通过 JavaScript 修改 <code>open</code> 属性<strong>并不会触发</strong> <code>toggle</code> 事件。因此，监听 <code>toggle</code> 事件只能响应手动操作，而批量操作时状态不会自动更新。</p>

<p>在 <code>toggleAll</code> 中批量设置 <code>open</code> 后，我调用了 <code>updateAnyExpanded()</code> 来同步状态，但此时 <code>open</code> 属性虽然已修改，可后续如果有其他异步操作（例如来自之前手动点击遗留的 <code>toggle</code> 事件）干扰，就会导致状态被覆盖。更关键的是，如果同时存在手动点击时的状态同步逻辑（比如在 <code>onMounted</code> 中监听了 <code>toggle</code> 事件），批量操作会与这些事件处理交错，造成最终状态错误。</p>

<p>简单来说，<strong>批量操作与手动点击的状态同步逻辑混在一起，导致状态被错误地更新</strong>。</p>

<hr>

<h2 id="5-解决方案-放弃事件监听-直接同步状态">5. 解决方案：放弃事件监听，直接同步状态</h2>

<p>既然 <code>toggle</code> 事件会干扰批量操作，我决定<strong>完全放弃监听 <code>toggle</code> 事件</strong>，改为：</p>

<ul>
<li>批量操作时，直接根据目标状态设置所有 <code>&lt;details&gt;</code> 的 <code>open</code>，并<strong>立即将状态变量设为目标值</strong>，不再依赖 DOM 查询。</li>
<li>手动点击时，通过 <code>&lt;summary&gt;</code> 的 <code>click</code> 事件，使用 <code>setTimeout</code> 延迟读取 DOM 状态，确保浏览器已更新 <code>open</code> 属性。</li>
</ul>

<h3 id="修改后的-toggleall-函数">修改后的 <code>toggleAll</code> 函数</h3>

<pre><code class="language-ts">const toggleAll = () =&gt; {
  const details = document.querySelectorAll(&quot;details&quot;);
  const shouldExpand = !isAnyExpanded.value; // 目标状态
  details.forEach((detail) =&gt; {
    detail.open = shouldExpand;
  });
  // 直接根据本次意图设置状态，不再调用 updateAnyExpanded
  isAnyExpanded.value = shouldExpand;
};
</code></pre>

<h3 id="手动点击时的状态同步">手动点击时的状态同步</h3>

<p>为 <code>&lt;summary&gt;</code> 添加 <code>@click=&quot;onSummaryClick&quot;</code>，在回调中使用 <code>setTimeout</code> 延迟到下一个事件循环再读取 DOM 状态。</p>

<pre><code class="language-ts">const onSummaryClick = () =&gt; {
  setTimeout(() =&gt; {
    updateAnyExpanded();
  }, 0);
};
</code></pre>

<p>模板中的 <code>&lt;summary&gt;</code>：</p>

<pre><code class="language-vue">&lt;summary class=&quot;flex items-center gap-2 cursor-pointer&quot; @click=&quot;onSummaryClick&quot;&gt;
  &lt;span&gt;{{ series.name }}&lt;/span&gt;
  &lt;span class=&quot;text-sm text-gray-500&quot;&gt;({{ series.docs.length }})&lt;/span&gt;
&lt;/summary&gt;
</code></pre>

<h3 id="组件挂载时初始化状态">组件挂载时初始化状态</h3>

<pre><code class="language-ts">onMounted(() =&gt; {
  updateAnyExpanded();
});
</code></pre>

<p><strong>为什么用 <code>setTimeout(..., 0)</code>？</strong><br>
用户点击 <code>&lt;summary&gt;</code> 后，浏览器会<strong>同步</strong>修改 <code>&lt;details&gt;</code> 的 <code>open</code> 属性，但 <code>toggle</code> 事件的触发是<strong>异步</strong>的，且 Vue 的响应式更新也可能在下一次微任务中执行。<code>setTimeout</code> 将读取操作推迟到下一个宏任务，此时 <code>open</code> 属性已经就绪，且任何可能影响状态的其他异步操作（如 <code>toggle</code> 事件）也已执行完毕。</p>

<blockquote>
<p>如果用户快速连续点击同一个 <code>&lt;summary&gt;</code>，多个 <code>setTimeout</code> 会排队执行，可能导致 <code>updateAnyExpanded</code> 被多次调用。虽然最终状态正确，但会有不必要的性能开销。由于系列页面的交互频率极低，这个影响可以忽略；如果希望更严谨，可以增加一个简单的防抖函数，但当前实现已经足够稳定。</p>
</blockquote>

<hr>

<h2 id="6-最终代码">6. 最终代码</h2>

<p>以下是完整的系列页面代码，包含数据获取、分组、折叠控制及样式。该实现假设页面在加载后不会动态增减 <code>&lt;details&gt;</code>（符合博客系列页的静态特性），因此状态同步逻辑简单可靠。</p>

<pre><code class="language-vue">&lt;template&gt;
  &lt;div class=&quot;max-w-3xl mx-auto&quot;&gt;
    &lt;div class=&quot;flex justify-end items-center mb-6&quot;&gt;
      &lt;UButton
        :ui=&quot;{ leadingIcon: 'toolbar-icon-btn' }&quot;
        class=&quot;cursor-pointer&quot;
        variant=&quot;ghost&quot;
        :icon=&quot;isAnyExpanded ? 'lucide-chevrons-up' : 'lucide-chevrons-down'&quot;
        @click=&quot;toggleAll&quot;
      /&gt;
    &lt;/div&gt;

    &lt;div v-for=&quot;series in seriesList&quot; :key=&quot;series.slug&quot; class=&quot;mb-4&quot;&gt;
      &lt;details&gt;
        &lt;summary
          class=&quot;flex items-center gap-2 cursor-pointer&quot;
          @click=&quot;onSummaryClick&quot;
        &gt;
          &lt;span&gt;{{ series.name }}&lt;/span&gt;
          &lt;span class=&quot;text-sm text-gray-500&quot;&gt;({{ series.docs.length }})&lt;/span&gt;
        &lt;/summary&gt;
        &lt;div class=&quot;pl-4 mt-2 space-y-2&quot;&gt;
          &lt;div v-for=&quot;article in series.docs&quot; :key=&quot;article.id&quot;&gt;
            &lt;NuxtLink :to=&quot;article.path&quot; class=&quot;text-blue-600 hover:underline&quot;&gt;
              {{ article.title }}
            &lt;/NuxtLink&gt;
            &lt;div class=&quot;text-xs text-gray-500&quot;&gt;
              {{ article.level }} · {{ formatDate(article.date) }}
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/details&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script setup lang=&quot;ts&quot;&gt;
import dayjs from &quot;dayjs&quot;;

const { tm } = useI18nSafe();

// ==================== 数据获取与分组 ====================
const { data: docsList } = await useAsyncData(&quot;series&quot;, () =&gt; {
  return queryCollection(&quot;docs&quot;)
    .order(&quot;date&quot;, &quot;ASC&quot;)
    .select(&quot;id&quot;, &quot;series&quot;, &quot;title&quot;, &quot;level&quot;, &quot;path&quot;, &quot;seo&quot;, &quot;date&quot;)
    .all();
});

// 将文档按 series 分组
const seriesMap = computed(() =&gt; {
  const map = new Map&lt;string, typeof docsList.value&gt;();
  if (!docsList.value) return map;
  for (const doc of docsList.value) {
    if (!doc.series) continue;
    if (!map.has(doc.series)) map.set(doc.series, []);
    map.get(doc.series)!.push(doc);
  }
  return map;
});

// 系列列表（从 i18n 获取系列名）
const seriesList = computed(() =&gt; {
  const seriesObj = tm(&quot;series&quot;) as Record&lt;string, string&gt;;
  return Object.entries(seriesObj).map(([slug, name]) =&gt; ({
    slug,
    name,
    docs: seriesMap.value.get(slug) || [],
  }));
});

// ==================== 折叠/展开所有系列 ====================

// 记录当前是否有任何系列处于展开状态，用于动态切换按钮图标
const isAnyExpanded = ref(false);

/**
 * 更新全局展开状态
 * 直接遍历 DOM 中所有 &lt;details&gt; 元素，检查是否有任一展开
 * 该函数仅在手动点击 summary 时调用，用于同步按钮状态
 */
const updateAnyExpanded = () =&gt; {
  const details = document.querySelectorAll(&quot;details&quot;);
  isAnyExpanded.value = Array.from(details).some((detail) =&gt; detail.open);
};

/**
 * 切换所有系列的展开/折叠状态
 * 由右上角按钮触发
 * 1. 根据当前 isAnyExpanded 计算目标状态（全部展开或全部折叠）
 * 2. 批量设置所有 &lt;details&gt; 的 open 属性
 * 3. 直接更新 isAnyExpanded 为目标状态，无需再次查询 DOM
 */
const toggleAll = () =&gt; {
  const details = document.querySelectorAll(&quot;details&quot;);
  const shouldExpand = !isAnyExpanded.value; // 目标状态：当前全部折叠则展开，否则折叠
  details.forEach((detail) =&gt; {
    detail.open = shouldExpand;
  });
  // 直接根据本次操作意图设置状态，避免因 toggle 事件干扰而需要两次点击
  isAnyExpanded.value = shouldExpand;
};

/**
 * 用户手动点击 summary（系列标题）时的回调
 * 由于点击后浏览器会异步更新 &lt;details&gt; 的 open 属性，需要延迟到下一轮事件循环再更新状态
 * 确保 DOM 已完全更新后，同步按钮图标
 */
const onSummaryClick = () =&gt; {
  setTimeout(() =&gt; {
    updateAnyExpanded();
  }, 0);
};

/**
 * 组件挂载后，初始化全局展开状态（页面加载时所有 &lt;details&gt; 默认为折叠）
 */
onMounted(() =&gt; {
  updateAnyExpanded();
});

// ==================== 辅助函数 ====================
const formatDate = (date: string) =&gt; dayjs(date).format(&quot;YYYY-MM-DD&quot;);
&lt;/script&gt;

&lt;style scoped&gt;
/* 隐藏 details 默认的三角形图标（与 flex 布局无关，确保所有浏览器都隐藏） */
details &gt; summary {
  list-style: none;
}
details &gt; summary::-webkit-details-marker {
  display: none;
}
&lt;/style&gt;
</code></pre>

<blockquote>
<p><strong>注</strong>：本实现假设页面中的 <code>&lt;details&gt;</code> 元素数量在加载后不会发生变化（符合博客系列页的静态特性）。如果后续通过异步操作动态增删系列，则需要对 <code>isAnyExpanded</code> 的同步逻辑做额外处理（例如在增删时手动调用 <code>updateAnyExpanded</code>）。不过对个人博客而言，当前实现已足够稳定。</p>
</blockquote>

<hr>

<h2 id="7-思考与总结">7. 思考与总结</h2>

<h3 id="为什么不用-ui-库的手风琴组件">为什么不用 UI 库的手风琴组件？</h3>

<p>我的博客追求极简风格，不希望引入太多依赖。原生 <code>&lt;details&gt;</code> 足以满足基础折叠功能，且代码量少，完全可控。虽然官方组件（如 Nuxt UI 的 <code>UAccordion</code>）功能更强大（动画、多选、无障碍），但对个人博客而言，<strong>够用就好</strong>。</p>

<h3 id="这次踩坑的收获">这次踩坑的收获</h3>

<ul>
<li><strong>批量操作原生 DOM 时，要警惕其触发的事件对状态的影响</strong>。批量修改时，直接同步状态变量比依赖 DOM 事件更可靠。</li>
<li><strong>用 <code>setTimeout(..., 0)</code> 等待 DOM 更新后再读取状态是常见模式</strong>，尤其适用于需要等待异步事件或框架响应式更新完成的情况。</li>
<li><strong>放弃复杂的全局事件监听，采用简单的点击回调配合状态变量，代码更可控</strong>。</li>
</ul>

<h3 id="什么时候适合用原生-details">什么时候适合用原生 <code>&lt;details&gt;</code>？</h3>

<ul>
<li>项目规模小，不需要复杂动画</li>
<li>你希望完全控制样式和行为</li>
<li>读者群体以技术人群为主，对原生 Web 标准接受度高</li>
</ul>

<h3 id="什么时候该用组件库的手风琴">什么时候该用组件库的手风琴？</h3>

<ul>
<li>需要平滑动画、键盘导航、多选模式</li>
<li>团队协作，希望快速交付</li>
<li>无障碍要求高</li>
</ul>

<hr>

<h2 id="8-结语">8. 结语</h2>

<p>这次为系列页面添加“全部折叠/展开”功能，让我对原生 DOM 操作和 Vue 状态同步有了更深的认识。虽然只是一个小小的功能，但背后的原理和调试过程值得记录。希望这篇文章能帮到同样在使用原生 <code>&lt;details&gt;</code> 时遇到类似问题的你。</p>
]]></content:encoded>
      <description><![CDATA[本文记录了在博客系列页面中使用原生 `<details>` 实现折叠列表时，添加“全部折叠/展开”按钮遇到的“点两次”问题，通过放弃 `toggle` 事件监听、直接同步状态变量和使用 `setTimeout` 等待 DOM 更新，最终实现稳定可控的全局控制功能。包含完整代码和原理分析。]]></description>
      <category><![CDATA[HTML]]></category>
      <category><![CDATA[CSS]]></category>
      <category><![CDATA[JavaScript]]></category>
      <category><![CDATA[Nuxt]]></category>
      <dc:subject>P3</dc:subject>
      
    </item>

    <item>
      <title><![CDATA[为评论区添加内容过滤与安全防护]]></title>
      <link>https://moongate.top/docs/nuxt-comment-security</link>
      <guid isPermaLink="true">e87b100b-4980-433c-8ae0-4c490bcfc60c</guid>
      <pubDate>Mon, 23 Mar 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>在前文<a href="./nuxt-multi-level-replies.md">《从零到一：为 Moongate 博客打造一个支持多级引用的评论区》</a>中，我们构建了一个功能完备的评论系统。本文将在此基础上，为评论区增加内容过滤、安全校验和防滥用机制，确保评论区的健康与安全。</p>
</blockquote>

<h2 id="1-背景与需求">1. 背景与需求</h2>

<p>在开放的评论区中，可能面临以下风险：</p>

<ul>
<li><strong>敏感词</strong>：用户可能发布违规内容，影响社区氛围。</li>
<li><strong>恶意灌水</strong>：大量重复评论或垃圾广告。</li>
<li><strong>跨文档污染</strong>：通过伪造 <code>permalink</code> 将评论插入到不存在的文档或他人文章。</li>
<li><strong>XSS 攻击</strong>：通过 Markdown 注入恶意脚本（已在前文中通过安全渲染解决）。</li>
</ul>

<p>为了维护评论区秩序，我们需要增加以下防护：</p>

<ul>
<li><strong>前端实时敏感词提示</strong>：提升用户体验，减少无效提交。</li>
<li><strong>后端严格过滤</strong>：作为最后防线，确保入库内容安全。</li>
<li><strong>文档归属验证</strong>：确保评论只属于当前文档。</li>
<li><strong>防重复提交与限流</strong>：防止恶意刷屏。</li>
</ul>

<h2 id="2-整体架构">2. 整体架构</h2>

<p>过滤功能涉及前后端协作：</p>

<pre><code class="language-text">用户输入 → 前端实时检测（可选） → 提交 → 后端校验（敏感词、长度、文档归属） → 入库 → 返回结果
</code></pre>

<p>本文将分模块实现。</p>

<h2 id="3-敏感词过滤">3. 敏感词过滤</h2>

<h3 id="3-1-词库设计">3.1 词库设计</h3>

<p>敏感词库应仅包含<strong>底线词汇</strong>，避免过度拦截技术术语。同时引入<strong>技术白名单</strong>，允许某些专业词汇（如“暴力破解”）正常使用。</p>

<pre><code class="language-ts">// utils/commentValidator.ts

// 敏感词列表（仅底线词汇）
const blockedKeywords = [
  &quot;广告&quot;,
  &quot;垃圾&quot;,
  &quot;诈骗&quot;,
  &quot;赌博&quot;,
  &quot;色情&quot;,
  &quot;暴力&quot;,
  &quot;fuck&quot;,
  &quot;shit&quot;,
  &quot;damn&quot;,
];

// 技术白名单（豁免词汇，需与敏感词冲突时使用）
const technicalWhitelist = [
  &quot;暴力破解&quot;,
  &quot;暴力枚举&quot;,
  &quot;攻击向量&quot;,
  &quot;死锁&quot;,
  &quot;死循环&quot;,
  &quot;垃圾回收&quot;,
  &quot;垃圾收集&quot;,
];
</code></pre>

<h3 id="3-2-验证函数实现">3.2 验证函数实现</h3>

<p>为避免将白名单词汇误判为敏感词，采用<strong>先移除白名单内容，再检测敏感词</strong>的策略，而非简单的子串匹配。</p>

<pre><code class="language-ts">// utils/commentValidator.ts

// 构建敏感词正则（大小写不敏感，自动转义）
const sensitiveRegex = new RegExp(
  blockedKeywords
    .map((word) =&gt; word.replace(/[.*+?^${}()|[\]\\]/g, &quot;\\$&amp;&quot;))
    .join(&quot;|&quot;),
  &quot;i&quot;,
);

export interface ValidationResult {
  valid: boolean;
  message?: string;
  foundWords?: string[];
}

/**
 * 验证评论内容
 * @param content 用户输入的文本
 * @param maxLength 最大长度，默认 5000（考虑代码块）
 */
export function validateComment(
  content: string,
  maxLength: number = 5000,
): ValidationResult {
  // 1. 空内容检查
  if (!content?.trim()) {
    return { valid: false, message: &quot;评论内容不能为空&quot; };
  }

  // 2. 长度限制
  if (content.length &gt; maxLength) {
    return { valid: false, message: `评论内容不能超过 ${maxLength} 个字符` };
  }

  // 3. 移除白名单词汇，避免误判
  let text = content;
  for (const word of technicalWhitelist) {
    text = text.replace(new RegExp(word, &quot;gi&quot;), &quot;&quot;);
  }

  // 4. 敏感词检测
  const matches = text.match(sensitiveRegex);
  if (matches) {
    const found = [...new Set(matches)];
    return {
      valid: false,
      message: `包含敏感词: ${found.join(&quot;, &quot;)}`,
      foundWords: found,
    };
  }

  return { valid: true };
}
</code></pre>

<p><strong>说明</strong>：</p>

<ul>
<li>通过先剔除白名单词汇，确保“垃圾回收”不会被误判为“垃圾”。</li>
<li>长度默认 5000 字符，足以容纳中等长度的代码块和技术讨论（一段 30 行的代码约 2400 字符）。</li>
<li>正则表达式一次匹配所有敏感词，性能优于循环 <code>includes</code>。</li>
</ul>

<h3 id="3-3-前端实时验证与提交按钮联动">3.3 前端实时验证与提交按钮联动</h3>

<h4 id="3-3-1-修改输入预览组件">3.3.1 修改输入预览组件</h4>

<p>在 <code>components/docs/CommentInputPreview.vue</code> 中增加实时验证和字符计数。</p>

<pre><code class="language-vue">&lt;template&gt;
  &lt;div class=&quot;grid grid-cols-1 md:grid-cols-2 gap-6 mt-4 mb-6&quot;&gt;
    &lt;!-- 左侧预览 --&gt;
    &lt;div class=&quot;bg-ui-bg-elevated&quot;&gt;
      &lt;div class=&quot;text-xs font-mono text-ui-text-muted mb-2 tracking-wider&quot;&gt;
        // {{ t(&quot;comment.input.preview&quot;) }}
      &lt;/div&gt;
      &lt;DocsMarkdownRenderer
        class=&quot;text-ui-text/90 text-base leading-relaxed&quot;
        :content=&quot;localValue&quot;
      /&gt;
    &lt;/div&gt;

    &lt;!-- 右侧输入 --&gt;
    &lt;div class=&quot;bg-ui-bg&quot;&gt;
      &lt;div class=&quot;text-xs font-mono text-ui-text-muted mb-2 tracking-wider&quot;&gt;
        // {{ t(&quot;comment.input.input&quot;) }}
      &lt;/div&gt;
      &lt;UTextarea
        :model-value=&quot;localValue&quot;
        autoresize
        :rows=&quot;5&quot;
        variant=&quot;none&quot;
        :placeholder=&quot;t('comment.input.placeholder')&quot;
        class=&quot;w-full bg-transparent border-0 focus:ring-0 p-0 text-ui-text placeholder:text-ui-text-muted/50 font-mono text-sm&quot;
        @update:model-value=&quot;handleInput&quot;
      /&gt;
      &lt;div v-if=&quot;validationError&quot; class=&quot;mt-2 text-xs font-mono text-ui-error&quot;&gt;
        // {{ validationError }}
      &lt;/div&gt;
      &lt;div class=&quot;text-xs text-ui-text-muted text-right mt-1&quot;&gt;
        {{ localValue.length }}/{{ maxLength }}
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script setup&gt;
import { useDebounceFn } from &quot;@vueuse/core&quot;;
import { validateComment } from &quot;~/utils/commentValidator&quot;;

const props = defineProps({
  modelValue: { type: String, default: &quot;&quot; },
  debounceTime: { type: Number, default: 300 },
  permalink: { type: String, required: true },
  storageType: { type: String, default: &quot;none&quot; },
  maxLength: { type: Number, default: 5000 }
});

const emit = defineEmits([&quot;update:modelValue&quot;]);

const localValue = ref(props.modelValue);
const validationError = ref('');

const validate = (value: string) =&gt; {
  const result = validateComment(value, props.maxLength);
  validationError.value = result.valid ? '' : result.message;
  return result.valid;
};

const handleInput = (value: string) =&gt; {
  localValue.value = value;
  validate(value);          // 实时验证，仅用于显示错误提示
  debouncedEmit(value);
};

const debouncedEmit = useDebounceFn((value: string) =&gt; {
  emit(&quot;update:modelValue&quot;, value);
}, props.debounceTime);

watch(() =&gt; props.modelValue, (newVal) =&gt; {
  localValue.value = newVal;
  validate(newVal);
});

onMounted(() =&gt; validate(localValue.value));
&lt;/script&gt;
</code></pre>

<h4 id="3-3-2-在-store-中管理验证状态">3.3.2 在 Store 中管理验证状态</h4>

<p>为了让提交按钮能够响应验证结果，需要在评论 store 中增加计算属性：</p>

<pre><code class="language-ts">// stores/comment.ts
import { validateComment } from &quot;~/utils/commentValidator&quot;;

export const useCommentStore = defineStore(&quot;comment&quot;, () =&gt; {
  const comment = ref(&quot;&quot;);
  // ... 其他状态

  const isCommentValid = computed(() =&gt; {
    const { valid } = validateComment(comment.value, 5000);
    return valid;
  });

  return {
    comment,
    isCommentValid,
    // ... 其他
  };
});
</code></pre>

<h4 id="3-3-3-修改评论区容器组件">3.3.3 修改评论区容器组件</h4>

<p>在 <code>CommentSection.vue</code> 中，使用 store 的计算属性禁用提交按钮，并显示后端错误。</p>

<pre><code class="language-vue">&lt;template&gt;
  &lt;!-- 其他部分... --&gt;
  &lt;div class=&quot;flex justify-end mb-8&quot;&gt;
    &lt;ClientOnly v-if=&quot;loggedIn&quot;&gt;
      &lt;UButton
        :disabled=&quot;
          !commentStore.comment.trim() ||
          commentStore.submitting ||
          !commentStore.isCommentValid
        &quot;
        :loading=&quot;commentStore.submitting&quot;
        :label=&quot;t('comment.actions.send')&quot;
        size=&quot;lg&quot;
        @click=&quot;commentStore.submitComment()&quot;
      /&gt;
    &lt;/ClientOnly&gt;
    &lt;div v-else class=&quot;flex items-center gap-2&quot;&gt;
      &lt;p&gt;{{ t(&quot;comment.status.login_to_comment&quot;) }}&lt;/p&gt;
      &lt;SharedLogin /&gt;
    &lt;/div&gt;
  &lt;/div&gt;

  &lt;!-- 显示后端错误 --&gt;
  &lt;div v-if=&quot;commentStore.error&quot; class=&quot;text-ui-error text-sm mt-2&quot;&gt;
    {{ commentStore.error }}
  &lt;/div&gt;
  &lt;!-- 其他部分... --&gt;
&lt;/template&gt;
</code></pre>

<h3 id="3-4-后端严格验证">3.4 后端严格验证</h3>

<p>在评论和回复的 API 中，必须再次调用 <code>validateComment</code>，确保任何绕过前端的请求都被拦截。所有 API 统一返回对象格式（不使用 <code>throw createError</code>），以便前端统一处理。</p>

<p><strong>修改 <code>server/api/comment/post.ts</code></strong>：</p>

<pre><code class="language-ts">import { eq } from &quot;drizzle-orm&quot;;
import { useDB } from &quot;~~/server/db&quot;;
import { comments, users } from &quot;~~/server/db/schema&quot;;
import { validateComment } from &quot;~/../utils/commentValidator&quot;;

export default defineEventHandler(async (event) =&gt; {
  const body = await readBody(event);
  const session = await getUserSession(event);

  // 1. 验证 session
  if (!session.user?.id) {
    return { success: false, status: 401, message: &quot;请先登录&quot; };
  }

  // 2. 查询用户是否存在
  const db = useDB();
  const user = await db.query.users.findFirst({
    where: eq(users.id, session.user.id),
  });
  if (!user) {
    await clearUserSession(event);
    return { success: false, status: 401, message: &quot;用户不存在&quot; };
  }

  // 3. 验证评论内容
  const content = body.content?.trim();
  const { valid, message } = validateComment(content, 5000);
  if (!valid) {
    return { success: false, status: 400, message: message || &quot;评论内容无效&quot; };
  }

  // 4. 验证 permalink 非空
  if (!body.permalink) {
    return { success: false, status: 400, message: &quot;永久链接不能为空&quot; };
  }

  // 5. 验证文档是否存在（假设使用 Nuxt Content，具体实现需根据项目调整）
  // 注意：这里使用了 `#content/server` 虚拟模块，实际项目中可能需要替换为其他方式。
  // 若无法验证，应返回错误而不是放行。
  try {
    const { queryCollection } = await import(&quot;#content/server&quot;);
    const doc = await queryCollection(&quot;docs&quot;)
      .where(&quot;permalink&quot;, &quot;=&quot;, body.permalink)
      .first();
    if (!doc) {
      return { success: false, status: 404, message: &quot;文档不存在&quot; };
    }
  } catch (err) {
    console.error(&quot;文档存在性验证失败，请检查 Nuxt Content 服务端配置&quot;, err);
    return {
      success: false,
      status: 500,
      message: &quot;服务器配置错误，无法验证文档&quot;,
    };
  }

  // 6. 保存评论到数据库
  try {
    const [comment] = await db
      .insert(comments)
      .values({
        user_id: user.id,
        content: body.content.trim(),
        permalink: body.permalink,
      })
      .returning();

    return {
      success: true,
      status: 201,
      message: &quot;评论存储成功&quot;,
      data: { ...comment },
    };
  } catch (error) {
    console.error(&quot;评论存储失败&quot;, error);
    return { success: false, status: 500, message: &quot;评论存储失败&quot; };
  }
});
</code></pre>

<p>同样修改 <code>server/api/reply/post.ts</code>（完整代码见第 4 节）。</p>

<h2 id="4-文档归属验证">4. 文档归属验证</h2>

<h3 id="4-1-评论-api-验证文档存在">4.1 评论 API 验证文档存在</h3>

<p>已在评论 API 中实现（见 3.4 节）。注意：文档存在性验证依赖于 Nuxt Content 的服务端能力，若不可用，建议在数据库中维护 <code>documents</code> 表，并在评论表中关联文档 ID。</p>

<h3 id="4-2-回复-api-完整代码-含归属验证">4.2 回复 API 完整代码（含归属验证）</h3>

<p>由于回复表没有 <code>permalink</code> 字段，需要通过目标评论的 <code>permalink</code> 来验证。下面给出完整的 <code>reply/post.ts</code> 实现，包含用户认证、参数校验、敏感词过滤、归属验证和限流（可选）。</p>

<pre><code class="language-ts">import { eq, sql } from &quot;drizzle-orm&quot;;
import { useDB } from &quot;~~/server/db&quot;;
import { replies, users, comments } from &quot;~~/server/db/schema&quot;;
import { validateComment } from &quot;~/../utils/commentValidator&quot;;

// 注意：内存限流仅用于演示，生产环境请替换为 Redis 或数据库
const rateLimit = new Map();

export default defineEventHandler(async (event) =&gt; {
  const body = await readBody(event);
  const session = await getUserSession(event);

  // 1. 参数校验
  if (
    !body.target_id ||
    ![&quot;comment&quot;, &quot;reply&quot;].includes(body.target_type) ||
    !body.content?.trim()
  ) {
    return { success: false, status: 400, message: &quot;参数错误&quot; };
  }
  if (!body.permalink) {
    return { success: false, status: 400, message: &quot;缺少 permalink 参数&quot; };
  }
  // 确保 target_id 为数字
  const targetId = Number(body.target_id);
  if (isNaN(targetId)) {
    return { success: false, status: 400, message: &quot;target_id 必须为数字&quot; };
  }

  // 2. 验证 session
  if (!session.user?.id) {
    return { success: false, status: 401, message: &quot;请先登录&quot; };
  }

  const db = useDB();

  // 3. 验证用户存在
  const user = await db.query.users.findFirst({
    where: eq(users.id, session.user.id),
  });
  if (!user) {
    await clearUserSession(event);
    return { success: false, status: 401, message: &quot;用户不存在&quot; };
  }

  // 4. 验证回复内容
  const content = body.content?.trim();
  const { valid, message } = validateComment(content, 5000);
  if (!valid) {
    return {
      success: false,
      status: 400,
      message: message || &quot;回复包含敏感词&quot;,
    };
  }

  // 5. 验证目标存在并检查是否属于当前文档
  let targetPermalink = &quot;&quot;;
  if (body.target_type === &quot;comment&quot;) {
    const comment = await db.query.comments.findFirst({
      where: eq(comments.id, targetId),
    });
    if (!comment) {
      return { success: false, status: 404, message: &quot;评论不存在&quot; };
    }
    targetPermalink = comment.permalink;
  } else {
    // 目标是回复，需要向上追溯找到根评论的 permalink
    // 使用递归 CTE 查询（PostgreSQL）
    const result = await db.execute(sql`
      WITH RECURSIVE reply_chain AS (
        SELECT id, target_id, target_type
        FROM replies
        WHERE id = ${targetId}
        UNION ALL
        SELECT r.id, r.target_id, r.target_type
        FROM replies r
        JOIN reply_chain rc ON rc.target_id = r.id AND rc.target_type = 'reply'
      )
      SELECT c.permalink
      FROM reply_chain rc
      JOIN comments c ON c.id = rc.target_id AND rc.target_type = 'comment'
      LIMIT 1
    `);
    if (!result.rows.length) {
      return { success: false, status: 404, message: &quot;目标评论或回复不存在&quot; };
    }
    targetPermalink = result.rows[0].permalink;
  }

  if (targetPermalink !== body.permalink) {
    return { success: false, status: 400, message: &quot;目标不属于当前文档&quot; };
  }

  // 6. 可选：防重复提交限流（同一用户对同一文档 1 分钟内只能回复一次）
  // 注意：内存限流仅用于演示，生产环境请使用 Redis 或数据库
  const rateKey = `${user.id}:${body.permalink}`;
  const last = rateLimit.get(rateKey);
  if (last &amp;&amp; Date.now() - last &lt; 60000) {
    return { success: false, status: 429, message: &quot;操作过于频繁，请稍后再试&quot; };
  }
  rateLimit.set(rateKey, Date.now());
  setTimeout(() =&gt; rateLimit.delete(rateKey), 60000); // 自动清理过期条目

  // 7. 保存回复
  try {
    const [newReply] = await db
      .insert(replies)
      .values({
        user_id: user.id,
        target_id: targetId,
        target_type: body.target_type,
        content: content,
      })
      .returning();

    return {
      success: true,
      status: 201,
      message: &quot;回复成功&quot;,
      data: newReply,
    };
  } catch (error) {
    console.error(error);
    return { success: false, status: 500, message: &quot;服务器内部错误&quot; };
  }
});
</code></pre>

<p><strong>说明</strong>：</p>

<ul>
<li>递归 CTE 查询支持多级引用（回复的回复），并获取根评论的 <code>permalink</code>。</li>
<li>若不需要多级引用，可以限制 <code>target_type</code> 只能为 <code>'comment'</code>，简化归属验证。</li>
<li>限流使用内存 Map，<strong>生产环境请替换为 Redis 或数据库实现</strong>，以保证多实例同步。</li>
<li>增加了 <code>target_id</code> 类型校验，确保为有效数字。</li>
</ul>

<h2 id="5-防重复提交与限流">5. 防重复提交与限流</h2>

<h3 id="5-1-前端禁用按钮">5.1 前端禁用按钮</h3>

<p>已在 <code>CommentSection.vue</code> 中实现，通过 <code>submitting</code> 状态禁用提交按钮。</p>

<h3 id="5-2-后端限流">5.2 后端限流</h3>

<p>已在回复 API 中添加简单内存限流示例（每分钟 1 次）。<strong>生产环境请务必替换为 Redis 或数据库</strong>，避免内存丢失或跨实例不同步。</p>

<h2 id="6-多语言翻译">6. 多语言翻译</h2>

<p>新增的文案需要添加到多语言文件中。以下提供中、英、日三语示例，可根据实际路径调整。</p>

<h3 id="i18n-locales-zh-cn-json">/i18n/locales/zh_cn.json</h3>

<pre><code class="language-json">{
  &quot;comment&quot;: {
    &quot;input&quot;: {
      &quot;preview&quot;: &quot;预览&quot;,
      &quot;input&quot;: &quot;输入&quot;,
      &quot;placeholder&quot;: &quot;支持 Markdown，代码块请用 ``` 包裹...&quot;
    }
  }
}
</code></pre>

<h3 id="i18n-locales-en-json">/i18n/locales/en.json</h3>

<pre><code class="language-json">{
  &quot;comment&quot;: {
    &quot;input&quot;: {
      &quot;preview&quot;: &quot;Preview&quot;,
      &quot;input&quot;: &quot;Input&quot;,
      &quot;placeholder&quot;: &quot;Markdown supported, use ``` for code blocks...&quot;
    }
  }
}
</code></pre>

<h3 id="i18n-locales-ja-json">/i18n/locales/ja.json</h3>

<pre><code class="language-json">{
  &quot;comment&quot;: {
    &quot;input&quot;: {
      &quot;preview&quot;: &quot;プレビュー&quot;,
      &quot;input&quot;: &quot;入力&quot;,
      &quot;placeholder&quot;: &quot;Markdown 対応、コードブロックは ``` で囲んでください...&quot;
    }
  }
}
</code></pre>

<h2 id="7-整合与测试">7. 整合与测试</h2>

<h3 id="7-1-文件清单">7.1 文件清单</h3>

<ul>
<li><code>utils/commentValidator.ts</code> – 敏感词验证逻辑（含白名单剔除）</li>
<li><code>components/docs/CommentInputPreview.vue</code> – 前端实时验证、字符计数</li>
<li><code>stores/comment.ts</code> – 添加 <code>isCommentValid</code> 计算属性和 <code>error</code> 状态</li>
<li><code>server/api/comment/post.ts</code> – 后端验证 + 文档归属</li>
<li><code>server/api/reply/post.ts</code> – 后端验证 + 归属验证 + 限流（含类型校验）</li>
</ul>

<h3 id="7-2-测试用例">7.2 测试用例</h3>

<ul>
<li><strong>空内容</strong> → 提示“评论内容不能为空”</li>
<li><strong>超长内容</strong>（&gt;5000 字符）→ 提示“不能超过 5000 个字符”</li>
<li><strong>包含敏感词</strong>（如“垃圾”）→ 前端提示，提交按钮禁用；若绕过前端，后端返回错误</li>
<li><strong>包含白名单词</strong>（如“暴力破解”）→ 正常提交</li>
<li><strong>伪造 <code>permalink</code></strong> → 后端返回“文档不存在”</li>
<li><strong>快速多次提交</strong> → 触发限流提示（若实现）</li>
<li><strong>回复多级引用</strong> → 递归查询正确找到根评论归属</li>
</ul>

<h2 id="8-总结与展望">8. 总结与展望</h2>

<p>通过添加内容过滤、归属验证和限流机制，评论区的安全性大大提升，能够抵御常见的恶意行为。这些功能与已有的 Markdown 安全渲染、用户认证共同构成了一个健壮的评论系统。</p>

<p>未来还可扩展：</p>

<ul>
<li><strong>管理员审核模式</strong>：敏感词评论自动进入待审列表。</li>
<li><strong>黑名单机制</strong>：封禁恶意用户。</li>
<li><strong>评论举报功能</strong>：让读者参与监督。</li>
</ul>

<p>现在，评论区已经可以放心地开放给所有读者了。如果在实践中有任何问题，欢迎在评论区交流。</p>
]]></content:encoded>
      <description><![CDATA[为 Nuxt 评论区增加敏感词过滤、文档归属验证、防重复提交与限流，构建多层安全防护体系。包含前端实时验证、后端严格校验、递归 CTE 归属验证及生产环境建议。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[Security]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:comment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[手写一个更适合 Nuxt 的 useRouteQuery：简化 URL 状态同步]]></title>
      <link>https://moongate.top/docs/nuxt-use-route-query-composables</link>
      <guid isPermaLink="true">5a3bf4e6-d9d5-4c40-9473-6074ad0831c4</guid>
      <pubDate>Sun, 22 Mar 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h1 id="手写一个更适合-nuxt-的-useroutequery-简化-url-状态同步">手写一个更适合 Nuxt 的 useRouteQuery：简化 URL 状态同步</h1>

<blockquote>
<p>在生产项目中，我经历过手写 70 行重复的 <code>watch</code> 与 <code>pushQuery</code>，也踩过官方 <code>@vueuse/router</code> 的 SSR 坑。最终我封装了一套开箱即用的 <code>useRouteQueryString</code>、<code>useRouteQueryNumber</code>、<code>useRouteQueryArray</code>，将代码量从 70 行压缩到 7 行，且完全可控、SSR 安全。本文将分享这套封装的设计思路与完整代码。</p>
</blockquote>

<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共四篇，覆盖 Nuxt 中 URL 与状态双向同步的全流程：</p>

<ol>
<li><p><a href="./nuxt-url-state-guide">Nuxt 中 URL 与状态双向绑定的终极指南（原理篇）</a>
—— 讲解 URL 与状态双向同步的原理与手写方案。</p></li>

<li><p><a href="./nuxt-use-route-query-composables">手写一个更适合 Nuxt 的 useRouteQuery（封装篇）</a>
—— 将重复逻辑封装成开箱即用的 composable。</p></li>

<li><p><a href="./nuxt-docs-list-page-complete-guide">从零到一：构建一个功能完备的文档列表页（实战篇）</a>
—— 综合运用前两篇的知识，实现完整的文档列表页。</p></li>

<li><p><a href="./nuxt-go-fullstack-closed-loop">Nuxt + Go 全栈实践：从 URL 状态到后端 API 的完整闭环</a>
—— 将前端 URL 状态与 Go 后端 API 打通，形成完整的数据流闭环。</p></li>
</ol>

<h2 id="一-背景-手写方案的痛点">一、背景：手写方案的痛点</h2>

<p>在 Nuxt 中实现 URL 与状态双向同步，常见的做法是：</p>

<pre><code class="language-ts">// 1. 定义所有状态（从 URL 初始化）
const searchInput = ref(route.query.search?.toString() || &quot;&quot;);
const searchOption = ref(Number(route.query.option) || 1);
const page = ref(Number(route.query.page) || 1);
const size = ref(Number(route.query.size) || 10);
const viewMode = ref(Number(route.query.viewMode) || 1);
const level = ref(route.query.level?.toString() || &quot;&quot;);
const tags = ref&lt;string[]&gt;([]);

// 解析 tags 数组
const parseTagsFromQuery = () =&gt; {
  const tagParam = route.query.tag;
  tags.value = tagParam
    ? Array.isArray(tagParam)
      ? tagParam
      : tagParam.split(&quot;,&quot;)
    : [];
};
parseTagsFromQuery();

// 2. 监听 URL 变化，同步到内部状态
watch(
  () =&gt; route.query,
  (q) =&gt; {
    searchInput.value = q.search?.toString() || &quot;&quot;;
    searchOption.value = Number(q.option) || 1;
    page.value = Number(q.page) || 1;
    size.value = Number(q.size) || 10;
    viewMode.value = Number(q.viewMode) || 1;
    level.value = q.level?.toString() || &quot;&quot;;
    parseTagsFromQuery();
  },
  { immediate: true },
);

// 3. 监听内部状态变化，同步到 URL
function pushQuery() {
  const query: Record&lt;string, string&gt; = {};
  if (searchInput.value) query.search = searchInput.value;
  if (searchOption.value !== 1) query.option = String(searchOption.value);
  if (page.value !== 1) query.page = String(page.value);
  if (size.value !== 10) query.size = String(size.value);
  if (viewMode.value !== 1) query.viewMode = String(viewMode.value);
  if (level.value) query.level = level.value;
  if (tags.value.length) query.tag = tags.value.join(&quot;,&quot;);

  if (JSON.stringify(route.query) !== JSON.stringify(query)) {
    router.push({ query });
  }
}

watch([searchInput, searchOption, page, size, viewMode, level, tags], () =&gt;
  pushQuery(),
);
</code></pre>

<p>重复 7 个状态，代码量庞大，且每个新页面都要重写一遍。这种代码不仅笨重，还容易漏掉某个 <code>watch</code>，导致 URL 与状态不同步。</p>

<h2 id="二-官方-useroutequery-的隐患">二、官方 <code>useRouteQuery</code> 的隐患</h2>

<p><code>@vueuse/router</code> 提供了 <code>useRouteQuery</code>，看似简洁，但在生产环境中我遇到了 <code>Invalid value used as weak map key</code> 的错误，原因是其内部使用了全局 <code>WeakMap</code> 和 <code>nextTick</code> 批量更新，在 SSR 下可能跨请求污染。最终我放弃了第三方库，决定自己封装一个稳定、可控的版本。</p>

<h2 id="三-封装设计-按类型拆分-各司其职">三、封装设计：按类型拆分，各司其职</h2>

<p>我将 URL 查询参数按常见类型拆分为三个专用函数，每个函数只做一件事，语义清晰。</p>

<h3 id="3-1-基础函数-useroutequeryraw">3.1 基础函数 <code>useRouteQueryRaw</code></h3>

<p>不对外暴露，仅用于内部读写原始值，<strong>使用 <code>replace</code> 避免产生多余历史记录</strong>：</p>

<pre><code class="language-ts">function useRouteQueryRaw(name: string) {
  const route = useRoute();
  const router = useRouter();
  const value = ref(route.query[name]);

  // 监听路由变化，同步到内部 ref
  watch(
    () =&gt; route.query[name],
    (newVal) =&gt; {
      value.value = newVal;
    },
  );

  // 监听内部 ref 变化，同步到 URL
  watch(value, (newVal) =&gt; {
    const query = { ...route.query };
    if (newVal !== undefined &amp;&amp; newVal !== null &amp;&amp; newVal !== &quot;&quot;) {
      query[name] = newVal;
    } else {
      delete query[name];
    }
    router.replace({ query });
  });

  return value;
}
</code></pre>

<p><strong>为什么用 <code>replace</code> 而不是 <code>push</code>？</strong></p>

<p>如果使用 <code>push</code>，每次筛选条件变化都会在浏览器历史中产生一条新记录，用户点击后退按钮时需要多次后退才能离开当前页面。<code>replace</code> 只替换当前历史记录，用户体验更符合直觉。</p>

<h3 id="3-2-字符串类型-useroutequerystring">3.2 字符串类型 <code>useRouteQueryString</code></h3>

<pre><code class="language-ts">export function useRouteQueryString(
  name: string,
  options?: { defaultValue?: string },
) {
  const raw = useRouteQueryRaw(name);
  const defaultValue = options?.defaultValue ?? &quot;&quot;;

  return computed({
    get: () =&gt; (raw.value?.toString() ?? defaultValue) as string,
    set: (v: string) =&gt; {
      raw.value = v === defaultValue ? undefined : v;
    },
  }) as Ref&lt;string&gt;;
}
</code></pre>

<h3 id="3-3-数字类型-useroutequerynumber">3.3 数字类型 <code>useRouteQueryNumber</code></h3>

<pre><code class="language-ts">export function useRouteQueryNumber(
  name: string,
  options?: { defaultValue?: number },
) {
  const raw = useRouteQueryRaw(name);
  const defaultValue = options?.defaultValue ?? 0;

  return computed({
    get: () =&gt; {
      const val = raw.value;
      if (val === undefined) return defaultValue;
      const num = Number(val);
      return isNaN(num) ? defaultValue : num;
    },
    set: (v: number) =&gt; {
      raw.value = v === defaultValue ? undefined : v.toString();
    },
  }) as Ref&lt;number&gt;;
}
</code></pre>

<h3 id="3-4-数组类型-从逗号分隔到多参数格式">3.4 数组类型：从逗号分隔到多参数格式</h3>

<p>在早期版本中，<code>useRouteQueryArray</code> 使用逗号分隔格式：</p>

<pre><code class="language-ts">// 旧版：逗号分隔
set: (v: string[]) =&gt; {
  raw.value = v.length ? v.join(&quot;,&quot;) : undefined;
};
// URL：?tag=go,vue
</code></pre>

<p>当项目引入 Go Gin 后端后，问题出现了：</p>

<pre><code class="language-go">// Go 后端期望：?tag=go&amp;tag=vue
tags := c.QueryArray(&quot;tag&quot;)  // 期望 [&quot;go&quot;, &quot;vue&quot;]

// 但前端发送的是：?tag=go,vue
tags := c.QueryArray(&quot;tag&quot;)  // 得到 [&quot;go,vue&quot;] ❌
</code></pre>

<p>Gin 的 <code>QueryArray</code> 原生支持多参数格式（<code>?tag=go&amp;tag=vue</code>），但不认识逗号分隔。如果继续用逗号分隔，就需要在 Go 后端手动 <code>strings.Split</code> 解析。</p>

<p>与其在每个后端接口都写一遍解析逻辑，不如统一改成多参数格式：</p>

<pre><code class="language-ts">// 新版：多参数格式
watch(value, (newVal) =&gt; {
  const query = { ...route.query };
  if (newVal.length === 0) {
    delete query[name];
  } else {
    query[name] = newVal;  // Vue Router 自动展开成 ?tag=go&amp;tag=vue
  }
  router.replace({ query });
}, { deep: true });
</code></pre>

<p><strong>一次修改，前后端格式对齐：</strong></p>

<pre><code>前端写入：tags.value = ['go', 'vue']
URL 变成：?tag=go&amp;tag=vue
Go 读取：c.QueryArray(&quot;tag&quot;) → [&quot;go&quot;, &quot;vue&quot;] ✅
</code></pre>

<p><strong>完整实现：</strong></p>

<pre><code class="language-ts">/**
 * 字符串数组类型查询参数
 * 使用多参数格式：?tag=go&amp;tag=vue
 * 与 Gin 的 c.QueryArray(&quot;tag&quot;) 天然兼容，无需后端额外解析
 */
export function useRouteQueryArray(name: string) {
  const route = useRoute();
  const router = useRouter();

  const getValue = (): string[] =&gt; {
    const val = route.query[name];
    if (!val) return [];
    return Array.isArray(val) ? val : [val];
  };

  const value = ref(getValue());

  watch(() =&gt; route.query[name], () =&gt; {
    const newVal = getValue();
    if (JSON.stringify(value.value) !== JSON.stringify(newVal)) {
      value.value = newVal;
    }
  });

  watch(value, (newVal) =&gt; {
    const query = { ...route.query };
    if (newVal.length === 0) {
      delete query[name];
    } else {
      query[name] = newVal;
    }
    router.replace({ query });
  }, { deep: true });

  return value;
}
</code></pre>

<table>
<thead>
<tr>
<th>格式</th>
<th>URL 示例</th>
<th>Gin 解析</th>
<th>标准程度</th>
</tr>
</thead>

<tbody>
<tr>
<td>逗号分隔（旧版）</td>
<td><code>?tag=go,vue</code></td>
<td>需手动 <code>strings.Split</code></td>
<td>❌ 非标准</td>
</tr>

<tr>
<td>多参数（新版）</td>
<td><code>?tag=go&amp;tag=vue</code></td>
<td><code>c.QueryArray(&quot;tag&quot;)</code> 原生支持</td>
<td>✅ HTTP 标准</td>
</tr>
</tbody>
</table>

<h2 id="四-使用示例-从-70-行到-7-行">四、使用示例：从 70 行到 7 行</h2>

<h3 id="4-1-定义状态">4.1 定义状态</h3>

<pre><code class="language-ts">const searchInput = useRouteQueryString(&quot;search&quot;, { defaultValue: &quot;&quot; });
const searchOption = useRouteQueryNumber(&quot;option&quot;, { defaultValue: 1 });
const page = useRouteQueryNumber(&quot;page&quot;, { defaultValue: 1 });
const size = useRouteQueryNumber(&quot;size&quot;, { defaultValue: 10 });
const viewMode = useRouteQueryNumber(&quot;viewMode&quot;, { defaultValue: 1 });
const level = useRouteQueryString(&quot;level&quot;, { defaultValue: &quot;&quot; });
const tags = useRouteQueryArray(&quot;tag&quot;);
</code></pre>

<h3 id="4-2-在模板中使用">4.2 在模板中使用</h3>

<pre><code class="language-vue">&lt;template&gt;
  &lt;UInput v-model=&quot;searchInput&quot; placeholder=&quot;搜索&quot; /&gt;
  &lt;!-- 其他筛选组件直接使用对应的状态变量 --&gt;
&lt;/template&gt;
</code></pre>

<h3 id="4-3-处理搜索防抖">4.3 处理搜索防抖</h3>

<p>由于直接修改 <code>searchInput</code> 会立即更新 URL，如果你希望实现&rdquo;输入停止后才更新&rdquo;的效果，可以引入一个防抖中间变量：</p>

<pre><code class="language-ts">// 实际搜索词（与 URL 同步）
const searchInput = useRouteQueryString(&quot;search&quot;, { defaultValue: &quot;&quot; });
const page = useRouteQueryNumber(&quot;page&quot;, { defaultValue: 1 });

// 防抖中间变量
const searchInputDebounced = ref(searchInput.value);

// 防抖写入 URL
watchDebounced(
  searchInputDebounced,
  (val) =&gt; {
    searchInput.value = val;
    page.value = 1;
  },
  { debounce: 500 },
);

// URL 变化时反向同步到防抖变量（后退/前进时保持输入框一致）
watch(searchInput, (val) =&gt; {
  searchInputDebounced.value = val;
});
</code></pre>

<p>模板中绑定 <code>searchInputDebounced</code> 而不是 <code>searchInput</code>，实现输入防抖同时保持 URL 双向同步。</p>

<h2 id="五-方案对比">五、方案对比</h2>

<table>
<thead>
<tr>
<th>维度</th>
<th>手写方案（70行/页面）</th>
<th>官方 <code>useRouteQuery</code></th>
<th>本封装</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>SSR 安全</strong></td>
<td>✅</td>
<td>⚠️ 有隐患（<code>WeakMap</code> 跨请求）</td>
<td>✅</td>
</tr>

<tr>
<td><strong>数组支持</strong></td>
<td>需手动解析</td>
<td>需 <code>transform</code></td>
<td>✅ 内置</td>
</tr>

<tr>
<td><strong>数组格式</strong></td>
<td>任意</td>
<td>任意</td>
<td><strong>多参数格式</strong>（标准）</td>
</tr>

<tr>
<td><strong>使用便捷</strong></td>
<td>❌ 繁琐</td>
<td>中等</td>
<td>函数名即类型</td>
</tr>

<tr>
<td><strong>代码量</strong></td>
<td>~70行/页面</td>
<td>~15行</td>
<td>~7行</td>
</tr>

<tr>
<td><strong>历史记录</strong></td>
<td>可配置</td>
<td><code>push</code></td>
<td><code>replace</code>（更符合直觉）</td>
</tr>
</tbody>
</table>

<h2 id="六-ssr-安全保证">六、SSR 安全保证</h2>

<ul>
<li><strong>无全局状态</strong>：所有数据存储在组件实例的 <code>ref</code> 中，不会跨请求污染。</li>
<li><strong>直接监听 <code>route.query</code></strong>：保证服务端和客户端初始值一致。</li>
<li><strong>不使用 <code>nextTick</code></strong>：避免在 SSR 中因异步更新导致 DOM 不匹配。</li>
</ul>

<h2 id="七-完整代码">七、完整代码</h2>

<pre><code class="language-ts">// composables/useRouteQuery.ts
import { useRoute, useRouter } from 'vue-router'
import type { Ref } from 'vue'

/**
 * 基础原始查询参数读写（不暴露给外部，仅内部使用）
 * 负责核心的 URL 同步逻辑，使用 replace 避免产生多余历史记录
 */
function useRouteQueryRaw(name: string) {
  const route = useRoute()
  const router = useRouter()
  const value = ref(route.query[name])

  watch(() =&gt; route.query[name], (newVal) =&gt; {
    value.value = newVal
  })

  watch(value, (newVal) =&gt; {
    const query = { ...route.query }
    if (newVal !== undefined &amp;&amp; newVal !== null &amp;&amp; newVal !== '') {
      query[name] = newVal
    } else {
      delete query[name]
    }
    router.replace({ query })
  })

  return value
}

/**
 * 字符串类型查询参数
 */
export function useRouteQueryString(name: string, options?: { defaultValue?: string }) {
  const raw = useRouteQueryRaw(name)
  const defaultValue = options?.defaultValue ?? ''
  return computed({
    get: () =&gt; (raw.value?.toString() ?? defaultValue) as string,
    set: (v: string) =&gt; {
      raw.value = v === defaultValue ? undefined : v
    }
  }) as Ref&lt;string&gt;
}

/**
 * 数字类型查询参数
 */
export function useRouteQueryNumber(name: string, options?: { defaultValue?: number }) {
  const raw = useRouteQueryRaw(name)
  const defaultValue = options?.defaultValue ?? 0
  return computed({
    get: () =&gt; {
      const val = raw.value
      if (val === undefined) return defaultValue
      const num = Number(val)
      return isNaN(num) ? defaultValue : num
    },
    set: (v: number) =&gt; {
      raw.value = v === defaultValue ? undefined : v.toString()
    }
  }) as Ref&lt;number&gt;
}

/**
 * 字符串数组类型查询参数
 * 使用多参数格式：?tag=go&amp;tag=vue
 * 与 Gin 的 c.QueryArray(&quot;tag&quot;) 天然兼容，无需后端额外解析
 */
export function useRouteQueryArray(name: string) {
  const route = useRoute()
  const router = useRouter()

  const getValue = (): string[] =&gt; {
    const val = route.query[name]
    if (!val) return []
    return Array.isArray(val) ? val : [val]
  }

  const value = ref(getValue())

  watch(() =&gt; route.query[name], () =&gt; {
    const newVal = getValue()
    if (JSON.stringify(value.value) !== JSON.stringify(newVal)) {
      value.value = newVal
    }
  })

  watch(value, (newVal) =&gt; {
    const query = { ...route.query }
    if (newVal.length === 0) {
      delete query[name]
    } else {
      query[name] = newVal
    }
    router.replace({ query })
  }, { deep: true })

  return value
}
</code></pre>

<h2 id="八-总结">八、总结</h2>

<p>这套封装解决了四个核心问题：</p>

<ol>
<li><strong>减少重复代码</strong>：从 70 行重复逻辑缩减到 7 行声明。</li>
<li><strong>保证 SSR 安全</strong>：无全局状态、无 <code>nextTick</code> 依赖，彻底避免水合错误。</li>
<li><strong>更好的历史记录体验</strong>：使用 <code>replace</code> 而非 <code>push</code>，避免后退按钮产生困惑。</li>
<li><strong>标准数组格式</strong>：使用多参数格式（<code>?tag=go&amp;tag=vue</code>），与主流后端框架天然兼容。</li>
</ol>

<p>其中第 4 点是在引入 Go Gin 后端后才意识到的。最初的设计用了逗号分隔，但当后端需要读取 <code>?tag=go&amp;tag=vue</code> 时，才发现格式不兼容。这个教训让我意识到：<strong>前端组件的设计不仅要考虑前端使用体验，也要考虑后端接口的兼容性。</strong> 多参数格式是 HTTP 标准，比自定义的逗号分隔格式更通用。</p>

<p>如果你的项目中也有类似的 URL 状态同步需求，不妨试试这套封装。它已经在我的 Nuxt 项目中稳定运行，希望也能帮到你。</p>
]]></content:encoded>
      <description><![CDATA[封装一套开箱即用的 useRouteQueryString / Number / Array，将 70 行重复的 URL 状态同步代码压缩到 7 行，并彻底解决官方版本的 SSR 隐患。包含完整源码、防抖处理与反向同步示例。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[State Management]]></category>
      <category><![CDATA[Hydration]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:url-state]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[从零到一：构建一个功能完备的文档列表页]]></title>
      <link>https://moongate.top/docs/nuxt-docs-list-page-complete-guide</link>
      <guid isPermaLink="true">760e47b3-05bc-4ad6-9d43-ad95426b8127</guid>
      <pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>本文完整记录了我在 Nuxt 4 中构建一个功能完备的文档列表页的全过程，涵盖 URL 状态同步、SSR 水合问题、移动端适配、标签多选、键盘翻页等 20+ 细节。包含可直接复用的代码片段和踩坑总结，适合正在构建类似页面的开发者。</p>
</blockquote>

<hr>

<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共三篇，覆盖 Nuxt 中 URL 与状态双向同步的全流程：</p>

<ol>
<li><p><a href="./nuxt-url-state-guide">Nuxt 中 URL 与状态双向绑定的终极指南（原理篇）</a>
—— 讲解 URL 与状态双向同步的原理与手写方案。</p></li>

<li><p><a href="./nuxt-use-route-query-composables">手写一个更适合 Nuxt 的 useRouteQuery（封装篇）</a>
—— 将重复逻辑封装成开箱即用的 composable。</p></li>

<li><p><a href="./nuxt-docs-list-page-complete-guide">从零到一：构建一个功能完备的文档列表页（实战篇）</a>
—— 综合运用前两篇的知识，实现完整的文档列表页。</p></li>

<li><p><a href="./nuxt-go-fullstack-closed-loop">Nuxt + Go 全栈实践：从 URL 状态到后端 API 的完整闭环</a>
—— 将前端 URL 状态与 Go 后端 API 打通，形成完整的数据流闭环。</p></li>
</ol>

<hr>

<h2 id="一-需求与挑战">一、需求与挑战</h2>

<h3 id="最终效果">✅ 最终效果</h3>

<ul>
<li><strong>桌面端</strong>：分页 + 键盘左右键翻页，支持 <strong>Ctrl/⌘ + 点击</strong> 多选标签。</li>
</ul>

<p><img src="../../images/desktop-demo.gif" style="width: 100%; max-width: 800px;" alt="桌面端综合演示" /></p>

<ul>
<li><strong>移动端</strong>：无限滚动 + 下滑刷新，支持 <strong>“多选模式”开关</strong> 进行标签多选（无需键盘）。</li>
</ul>

<p><img src="../../images/mobile-demo.gif" style="width: 100%; max-width: 400px;" alt="移动端综合演示" /></p>

<ul>
<li><strong>所有筛选状态</strong>（搜索词、搜索范围、页码、每页条数、视图模式、等级、标签）均与 URL 同步，页面可分享、可刷新、可后退/前进。</li>
<li><strong>SSR 安全</strong>：无水合错误，服务端和客户端渲染结果完全一致。</li>
</ul>

<h3 id="核心挑战">⚠️ 核心挑战</h3>

<ul>
<li><strong>URL 与状态双向同步</strong>：用户操作更新 URL，URL 变化（如后退/前进）更新内部状态。</li>
<li><strong>SSR 水合问题</strong>：服务端与客户端渲染的 DOM 结构必须完全一致。</li>
<li><strong>组件拆分时的 SSR 陷阱</strong>：<code>isMobile</code>、<code>isDesktop</code> 等环境敏感值不能重复调用，否则会导致水合失败。</li>
<li><strong>移动端手势与桌面端键盘</strong>：两套交互逻辑需要兼容并优雅切换。</li>
<li><strong>标签多选</strong>：桌面端用 Ctrl，移动端用显式的“多选模式”开关（避免长按误触）。</li>
</ul>

<h2 id="二-技术选型与项目结构">二、技术选型与项目结构</h2>

<ul>
<li><strong>框架</strong>：Nuxt 4（SSR）</li>
<li><strong>数据源</strong>：Nuxt Content（<code>queryCollection</code>）</li>
<li><strong>UI 组件</strong>：Nuxt UI 的 <code>UBlogPost</code>、<code>UInput</code>、<code>USelect</code>、<code>UPagination</code> 等</li>
<li><strong>工具库</strong>：<code>@vueuse/core</code> 提供 <code>useLocalStorage</code>、<code>useScroll</code>、<code>useEventListener</code> 等</li>
</ul>

<p>项目结构（关键部分）：</p>

<pre><code class="language-bash">nuxt.config.ts
plugins/
  useResponsive.ts         # 响应式判断（ssrWidth: 768）
  useSwipe.ts              # 滑动检测
layouts/
pages/
  index.vue                # 主页面
components/
  docs/
    SearchHeader.vue       # 搜索栏 + 下拉框
    NavigationLevel.vue    # 等级导航
    TagFilter.vue          # 标签云 + 多选模式开关
    List.vue               # 文章列表
    PaginationBar.vue      # 分页组件
composables/
  useResponsive.ts         # 响应式判断（ssrWidth: 768）
  useSwipe.ts              # 滑动检测
utils/
  tags.ts                  # 标签白名单
</code></pre>

<h2 id="三-url-状态同步-手写闭环">三、URL 状态同步：手写闭环</h2>

<h3 id="3-1-核心思路">3.1 核心思路</h3>

<ul>
<li><strong>单一数据源</strong>：所有状态从 <code>route.query</code> 初始化。</li>
<li><strong>双向同步</strong>：<code>watch(route.query)</code> 将 URL 变化同步到内部 ref；<code>watch</code> 内部 ref 变化时调用 <code>router.push</code> 更新 URL。</li>
<li><strong>防抖</strong>：搜索输入防抖，其他立即更新。</li>
</ul>

<h3 id="3-2-代码示例-主文件-index-vue-中的状态定义与同步">3.2 代码示例（主文件 <code>index.vue</code> 中的状态定义与同步）</h3>

<pre><code class="language-ts">const route = useRoute();
const router = useRouter();

// 状态定义（全部从 URL 初始化）
const searchInput = ref(route.query.search?.toString() || &quot;&quot;);
const searchOption = ref(Number(route.query.option) || 1);
const page = ref(Number(route.query.page) || 1);
const size = ref(Number(route.query.size) || 10);
const viewMode = ref(Number(route.query.viewMode) || 1);
const level = ref(route.query.level?.toString() || &quot;&quot;);
const tags = ref&lt;string[]&gt;([]);

// 解析 URL 中的标签（支持逗号分隔）
const parseTagsFromQuery = () =&gt; {
  const tagParam = route.query.tag;
  tags.value = tagParam
    ? Array.isArray(tagParam)
      ? tagParam
      : tagParam.split(&quot;,&quot;)
    : [];
};
parseTagsFromQuery();

// 监听路由变化（后退/前进）
watch(
  () =&gt; route.query,
  (newValue) =&gt; {
    searchInput.value = newValue.search?.toString() || &quot;&quot;;
    searchOption.value = Number(newValue.option) || 1;
    page.value = Number(newValue.page) || 1;
    size.value = Number(newValue.size) || 10;
    viewMode.value = Number(newValue.viewMode) || 1;
    level.value = newValue.level?.toString() || &quot;&quot;;
    parseTagsFromQuery();
  },
  { immediate: true },
);

// 推送路由
function pushQuery() {
  const query: Record&lt;string, string&gt; = {};
  if (searchInput.value) query.search = searchInput.value;
  if (searchOption.value !== 1) query.option = String(searchOption.value);
  if (page.value !== 1) query.page = String(page.value);
  if (size.value !== 10) query.size = String(size.value);
  if (viewMode.value !== 1) query.viewMode = String(viewMode.value);
  if (level.value) query.level = level.value;
  if (tags.value.length) query.tag = tags.value.join(&quot;,&quot;);

  if (JSON.stringify(route.query) !== JSON.stringify(query)) {
    router.push({ query });
  }
}

// 监听状态变化，自动更新 URL（关键：直接监听 ref 确保数组变化也能捕获）
watch([page, size, viewMode, level, tags], () =&gt; pushQuery());
watchDebounced(
  searchInput,
  () =&gt; {
    page.value = 1;
    pushQuery();
  },
  { debounce: 500 },
);
watch(searchOption, () =&gt; pushQuery());
</code></pre>

<h2 id="四-数据获取-useasyncdata-响应式依赖">四、数据获取：useAsyncData + 响应式依赖</h2>

<h3 id="4-1-核心代码">4.1 核心代码</h3>

<pre><code class="language-ts">const { data: docsData, pending } = await useAsyncData(
  &quot;docs-list&quot;,
  async () =&gt; {
    let query = queryCollection(&quot;docs&quot;).order(&quot;date&quot;, &quot;DESC&quot;);
    const keyword = searchInput.value.trim();

    // 搜索条件
    if (keyword) {
      if (searchOption.value === 1) {
        query = query.orWhere((q) =&gt;
          q
            .where(&quot;title&quot;, &quot;LIKE&quot;, `%${keyword}%`)
            .where(&quot;description&quot;, &quot;LIKE&quot;, `%${keyword}%`),
        );
      } else {
        query = query.where(&quot;title&quot;, &quot;LIKE&quot;, `%${keyword}%`);
      }
    }

    // 等级过滤
    if (level.value) {
      query = query.where(&quot;level&quot;, &quot;=&quot;, level.value);
    }

    // 标签过滤（AND 关系）
    if (tags.value.length) {
      query = query.andWhere((q) =&gt; {
        tags.value.forEach((tag) =&gt; {
          q = q.where(&quot;tags&quot;, &quot;LIKE&quot;, `%${tag}%`);
        });
        return q;
      });
    }

    // 分页
    const [total, list] = await Promise.all([
      query.count(),
      query
        .skip((page.value - 1) * size.value)
        .limit(size.value)
        .all(),
    ]);

    return { total, list };
  },
  {
    // 注意：viewMode 仅用于 UI 展示，不应触发数据重新请求，因此未放入 watch
    watch: [searchInput, searchOption, page, size, level, tags],
  },
);
</code></pre>

<blockquote>
<p><strong>关键点</strong>：<code>watch</code> 数组中直接使用 ref 本身（如 <code>tags</code>），确保数组内部变化能被正确捕获。同时将 <code>viewMode</code> 移出 watch，避免因视图模式切换导致无意义的数据重载。</p>
</blockquote>

<h3 id="4-2-移动端累积列表-无限滚动">4.2 移动端累积列表（无限滚动）</h3>

<pre><code class="language-ts">const docsList = ref&lt;any[]&gt;([]);
watch(
  () =&gt; docsData.value?.list,
  (newList) =&gt; {
    if (!newList) return;
    // 仅当移动端且 page &gt; 1 时才合并（水合阶段 page=1，不会进入）
    if (isMobile.value &amp;&amp; page.value &gt; 1) {
      const merged = [...docsList.value, ...newList];
      const uniqueMap = new Map(merged.map((item) =&gt; [item.id, item]));
      docsList.value = Array.from(uniqueMap.values());
    } else {
      docsList.value = newList;
    }
  },
  { immediate: true },
);
</code></pre>

<blockquote>
<p><strong>说明</strong>：当用户执行搜索、切换等级或标签时，<code>page</code> 会被重置为 1，此时 <code>page.value &gt; 1</code> 条件不满足，<code>docsList</code> 会被重新赋值为新数据，累积列表自动清空，符合预期。</p>
</blockquote>

<h2 id="五-ssr-安全-避免水合失败的黄金法则">五、SSR 安全：避免水合失败的黄金法则</h2>

<p>水合失败的根本原因是服务端与客户端渲染的 DOM 结构不一致。我们的解决方案：</p>

<ol>
<li><strong>所有影响初始 DOM 的状态从 URL 初始化</strong>（<code>level</code>、<code>tags</code>、<code>page</code> 等），保证服务端和客户端初始值一致。</li>
<li><strong><code>isMobile</code> / <code>isDesktop</code> 只在根组件计算一次，通过 props 传递给子组件</strong>，避免子组件重复调用 <code>useResponsive</code> 导致服务端/客户端判断不一致。下文示例中，<code>TagFilter.vue</code> 的 <code>isDesktop</code> 即由父组件传入。</li>
<li><strong><code>isFilterVisible</code> 使用 <code>useLocalStorage</code></strong>（不影响初始 DOM，服务端默认为 false，客户端从 localStorage 读取后更新，但不会改变水合结构）。</li>
<li><strong>累积列表逻辑仅在水合完成后才启用</strong>（通过 <code>page.value &gt; 1</code> 条件限制，水合时 <code>page</code> 为 1）。</li>
<li><strong><code>useAsyncData</code> 的 <code>watch</code> 直接使用 ref</strong>，确保数据变化能正确触发。</li>
</ol>

<h2 id="六-组件拆分与职责划分">六、组件拆分与职责划分</h2>

<h3 id="6-1-父组件-index-vue-负责">6.1 父组件（<code>index.vue</code>）负责</h3>

<ul>
<li>所有 URL 状态的管理与同步</li>
<li>数据获取（<code>useAsyncData</code>）</li>
<li>全局交互（键盘、手势、滚动）</li>
<li>将 <code>isDesktop</code>、<code>tags</code>、<code>getTagLink</code>、<code>isTagSelected</code>、<code>handleTagClick</code> 等传递给子组件</li>
</ul>

<h3 id="6-2-子组件仅负责展示与事件转发">6.2 子组件仅负责展示与事件转发</h3>

<p>例如 <code>TagFilter.vue</code>，其 <code>isDesktop</code> 由父组件通过 props 传入，确保 SSR 安全：</p>

<pre><code class="language-vue">&lt;template&gt;
  &lt;div&gt;
    &lt;!-- 桌面端提示 --&gt;
    &lt;span v-if=&quot;isDesktop&quot; class=&quot;ml-2 text-xs text-gray-500&quot;&gt;
      Ctrl+点击多选
    &lt;/span&gt;

    &lt;!-- 移动端多选模式开关 --&gt;
    &lt;div v-if=&quot;!isDesktop&quot; class=&quot;flex justify-end mb-2&quot;&gt;
      &lt;button
        @click=&quot;multiSelectMode = !multiSelectMode&quot;
        class=&quot;text-xs px-2 py-1 rounded bg-gray-700 text-gray-300&quot;
        :class=&quot;{ 'bg-blue-600 text-white': multiSelectMode }&quot;
      &gt;
        {{ multiSelectMode ? &quot;退出多选&quot; : &quot;多选模式&quot; }}
      &lt;/button&gt;
    &lt;/div&gt;

    &lt;div class=&quot;w-full flex flex-wrap&quot;&gt;
      &lt;NuxtLink
        v-for=&quot;tag in ALLOWED_TAGS&quot;
        :key=&quot;tag&quot;
        :to=&quot;getTagLink(tag)&quot;
        class=&quot;block p-2 mx-1 nav-link&quot;
        :class=&quot;{ active: isTagSelected(tag) }&quot;
        @click.prevent=&quot;onTagClick(tag, $event)&quot;
      &gt;
        #{{ tag }}
      &lt;/NuxtLink&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script setup&gt;
import { ALLOWED_TAGS } from &quot;~/utils/tags&quot;;

const { isMobile } = useResponsive(); // 仅在子组件内部使用 isMobile 判断移动端分支，不会影响初始 DOM
const props = defineProps({
  isDesktop: { type: Boolean, required: true }, // 从父组件传入
  getTagLink: { type: Function, required: true },
  isTagSelected: { type: Function, required: true },
});

const multiSelectMode = ref(false);
const emit = defineEmits([&quot;tag-click&quot;]);

const onTagClick = (tag, event) =&gt; {
  let isMulti = false;
  if (isMobile.value) {
    // 移动端：使用多选模式开关
    isMulti = multiSelectMode.value;
  } else {
    // 桌面端：按 Ctrl/Cmd 多选
    isMulti = event.ctrlKey || event.metaKey;
  }
  emit(&quot;tag-click&quot;, tag, { ctrlKey: isMulti, metaKey: isMulti });
};
&lt;/script&gt;
</code></pre>

<p>父组件中的 <code>handleTagClick</code> 统一处理 URL 更新：</p>

<pre><code class="language-ts">const handleTagClick = (tag: string, event: MouseEvent) =&gt; {
  const isMulti = event.ctrlKey || event.metaKey; // 子组件传递的 event 已包含 ctrlKey
  let newTags: string[];
  if (isMulti) {
    newTags = tags.value.includes(tag)
      ? tags.value.filter((t) =&gt; t !== tag)
      : [...tags.value, tag];
  } else {
    newTags = tags.value.includes(tag) ? [] : [tag];
  }
  const query = { ...route.query };
  if (newTags.length) query.tag = newTags.join(&quot;,&quot;);
  else delete query.tag;
  query.page = &quot;1&quot;;
  router.push({ query });
};
</code></pre>

<blockquote>
<p><strong>注意</strong>：子组件 <code>onTagClick</code> 和父组件 <code>handleTagClick</code> 命名不同，职责清晰，避免混淆。</p>
</blockquote>

<h2 id="七-移动端体验优化">七、移动端体验优化</h2>

<h3 id="7-1-无限滚动与下拉刷新">7.1 无限滚动与下拉刷新</h3>

<p>使用自定义 <code>useSwipe</code> 组合式函数监听上滑/下滑，上滑触发 <code>loadMoreDocs</code>（<code>page += 1</code>），下滑触发 <code>refreshDocs</code>（<code>page = 1</code>）。滚动检测使用 <code>useScroll</code> 判断是否接近底部或顶部。</p>

<p><code>useSwipe</code> 简化实现：</p>

<pre><code class="language-ts">// composables/useSwipe.ts
import { useEventListener } from &quot;@vueuse/core&quot;;

export function useSwipe(
  handlers: {
    onUp?: () =&gt; void;
    onDown?: () =&gt; void;
  },
  { threshold = 60 } = {},
) {
  const touchStartY = ref(0);

  useEventListener(&quot;touchstart&quot;, (e: TouchEvent) =&gt; {
    touchStartY.value = e.touches[0].clientY;
  });

  useEventListener(&quot;touchend&quot;, (e: TouchEvent) =&gt; {
    const distance = touchStartY.value - e.changedTouches[0].clientY;
    if (distance &gt; threshold) handlers.onUp?.();
    else if (-distance &gt; threshold) handlers.onDown?.();
  });
}
</code></pre>

<h3 id="7-2-多选模式开关">7.2 多选模式开关</h3>

<ul>
<li>移动端没有 Ctrl 键，因此在标签区域右上角增加“多选模式”按钮。</li>
<li>点击按钮进入多选状态，再次点击退出。多选模式下点击标签直接切换选中状态（无需 Ctrl）。</li>
</ul>

<h3 id="7-3-响应式布局">7.3 响应式布局</h3>

<ul>
<li>桌面端：2 列网格，奇数条时最后一条占满。</li>
<li>移动端：1 列网格，隐藏分页组件，使用无限滚动 + “加载更多”按钮（实际上使用手势上滑，但也可保留按钮作为备选）。</li>
</ul>

<h2 id="八-标签系统设计">八、标签系统设计</h2>

<h3 id="8-1-标签白名单">8.1 标签白名单</h3>

<p>为保持标签系统整洁，定义受控词表（<code>utils/tags.ts</code>）：</p>

<pre><code class="language-ts">export const ALLOWED_TAGS = [
  'Nuxt', 'Vue', 'Docker', 'Caddy', 'GitHub Actions', ...
];
</code></pre>

<h3 id="8-2-标签筛选逻辑">8.2 标签筛选逻辑</h3>

<ul>
<li>多标签 <strong>AND</strong> 关系（文章必须包含所有选中标签）。</li>
<li>查询时使用 <code>andWhere</code> + 循环添加条件。</li>
<li>前端通过 <code>tags.value.includes(tag)</code> 判断高亮。</li>
</ul>

<h2 id="九-空状态与用户体验细节">九、空状态与用户体验细节</h2>

<ul>
<li>当筛选结果为空时，显示友好提示和“清除所有筛选”按钮。</li>
<li>加载状态：通过 <code>pending</code> 显示骨架或禁用按钮。</li>
<li>键盘事件：左右键翻页，ESC 失焦。为避免在输入框中误触翻页，全局键盘事件中需判断当前聚焦元素：</li>
</ul>

<pre><code class="language-ts">const isInputFocused = computed(() =&gt; {
  const active = document.activeElement;
  return active?.tagName === &quot;INPUT&quot; || active?.tagName === &quot;TEXTAREA&quot;;
});

useEventListener(&quot;keydown&quot;, (e) =&gt; {
  if (e.key === &quot;Escape&quot; &amp;&amp; isInputFocused.value) {
    (document.activeElement as HTMLElement)?.blur();
    return;
  }
  if (isInputFocused.value) return; // 输入框聚焦时不响应翻页

  if (e.key === &quot;ArrowLeft&quot; &amp;&amp; hasPrevPage.value) {
    e.preventDefault();
    page.value -= 1;
  } else if (e.key === &quot;ArrowRight&quot; &amp;&amp; hasNextPage.value) {
    e.preventDefault();
    page.value += 1;
  }
});
</code></pre>

<ul>
<li>移动端手势：上滑加载更多，下滑刷新。</li>
</ul>

<pre><code class="language-ts">// 空状态提示
const emptyStateMessage = computed(() =&gt; {
  const hasSearch = searchInput.value;
  const hasLevel = level.value;
  const hasTags = tags.value.length;
  if (hasSearch || hasLevel || hasTags) {
    return &quot;没有找到符合条件的文档，试试调整筛选条件吧&quot;;
  }
  return &quot;还没有文档，请稍后再来&quot;;
});

// 清除所有筛选
const clearAllFilters = () =&gt; {
  searchInput.value = &quot;&quot;;
  level.value = &quot;&quot;;
  tags.value = [];
  page.value = 1;
  size.value = 10;
  viewMode.value = 1;
};
</code></pre>

<h2 id="十-踩坑与总结">十、踩坑与总结</h2>

<h3 id="10-1-踩过的坑">10.1 踩过的坑</h3>

<ol>
<li><strong>水合失败</strong>：子组件重复调用 <code>useResponsive</code> 导致 <code>isMobile</code> 不一致。解决方案：将 <code>isDesktop</code> 作为 props 从父组件传递（见第六节）。</li>
<li><strong><code>useAsyncData</code> 的 <code>watch</code> 不响应数组变化</strong>：直接监听 <code>tags</code> 而不是 <code>() =&gt; tags.value</code> 即可解决。</li>
<li><strong><code>viewMode</code> 触发数据请求</strong>：将 <code>viewMode</code> 从 <code>watch</code> 中移除，避免无意义的网络请求。</li>
<li><strong>移动端长按多选</strong>：长按容易误触且需要处理滚动干扰，最终改为显式“多选模式”开关，体验更好。</li>
<li><strong>分页组件在移动端显示过多</strong>：移动端隐藏分页，改用无限滚动 + 加载更多按钮。</li>
<li><strong>空状态重复提示</strong>：底部“已查询到 0 条文档”与空状态重复，移除。</li>
</ol>

<h3 id="10-2-最终成果">10.2 最终成果</h3>

<ul>
<li><strong>功能完整性</strong>：分页、搜索、等级、标签、视图模式、键盘左右键、移动端手势、无限滚动、下拉刷新、空状态。</li>
<li><strong>SSR 安全</strong>：无任何水合错误，服务端与客户端渲染一致。</li>
<li><strong>代码质量</strong>：状态集中管理，组件职责清晰，支持未来扩展。</li>
</ul>

<h2 id="十一-结语">十一、结语</h2>

<p>构建这个文档列表页花了不少时间，但也让我对 Nuxt SSR 的运作机制有了更深的理解。如果你也在类似项目中遇到水合问题或状态同步的困扰，希望这份记录能给你一些启发。所有代码均已在实际项目中稳定运行，欢迎参考。</p>

<p><strong>相关链接</strong>：</p>

<ul>
<li><a href="./nuxt-url-state-guide">Nuxt 中 URL 与状态双向绑定指南</a></li>
<li><a href="./nuxt-state-persistence-guide">Nuxt 4 中安全实现状态持久化：根治水合失败指南</a></li>
</ul>

<hr>

<p><em>文中代码为实际项目简化版，完整实现请参考项目源码。</em></p>
]]></content:encoded>
      <description><![CDATA[手把手教你用 Nuxt 4 构建一个支持 URL 状态同步、多维度筛选、移动端无限滚动、键盘翻页的文档列表页。包含手写状态管理、SSR 水合问题排查、组件拆分陷阱、标签多选（桌面端 Ctrl/移动端开关）等完整实现，附可复用代码。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[State Management]]></category>
      <category><![CDATA[Hydration]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:url-state]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[自托管 Umami 分析服务与 Nuxt 4 项目集成指南（扩展篇）]]></title>
      <link>https://moongate.top/docs/umami-integration-auto-deploy</link>
      <guid isPermaLink="true">89857e17-36c9-4a7b-a879-43a1f64f54a6</guid>
      <pubDate>Wed, 18 Mar 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共六篇，覆盖从静态网站到生产级 Docker 部署及服务集成的全流程：</p>

<ol>
<li><p><a href="./static-site-auto-deploy"><strong>静态网站自动化部署（静态篇）</strong></a>
—— 纯前端资源的自动化发布，Caddy 自动 HTTPS 和 SPA 路由支持。</p></li>

<li><p><a href="dynamic-site-auto-deploy" target="_blank"><strong>动态网站自动化部署（动态篇）</strong></a>
—— 后端服务进程管理、环境变量注入、数据库迁移，结合 Caddy 反向代理。</p></li>

<li><p><a href="docker-quickstart-auto-deploy" target="_blank"><strong>Docker 极简入门（入门篇）</strong></a>
—— 从零开始用 Docker + GitHub Actions 实现 CI/CD 流水线。</p></li>

<li><p><a href="docker-production-auto-deploy" target="_blank"><strong>Docker 生产级部署（进阶篇）</strong></a>
—— 多容器编排、健康检查、数据库迁移、自动 HTTPS，打造可靠的生产环境。</p></li>

<li><p><a href="./umami-integration-auto-deploy"><strong>自托管 Umami 分析服务与 Nuxt 4 项目集成指南（扩展篇）</strong></a>
—— 在现有 Docker 生产环境中集成 Umami 分析服务，实现自动化数据跟踪与安全加固。</p></li>

<li><p><a href="./vitepress-docker-existing-infrastructure-subdomain-deployment"><strong>VitePress 文档站接入已有 Docker 基础设施：子域名部署（扩展篇）</strong></a>
—— 将 VitePress 静态文档站作为子域名接入现有 Docker 基础设施，复用 Caddy 反向代理与网络。</p></li>
</ol>

<p>本篇将在进阶篇的基础上，详细讲解如何将 Umami 分析服务集成到现有 Docker 化部署的 Nuxt 项目中。</p>

<hr>

<h2 id="版本声明">📌 版本声明</h2>

<p>本文档所有工具均采用 <strong>2026 年最新稳定版</strong>：</p>

<table>
<thead>
<tr>
<th>组件</th>
<th>版本</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td>Nuxt</td>
<td>4.x</td>
<td>前端框架，兼容 Nuxt 3</td>
</tr>

<tr>
<td>nuxt-umami</td>
<td>3.2.1</td>
<td>Umami 集成模块</td>
</tr>

<tr>
<td>Node.js</td>
<td>24.x</td>
<td>最新 LTS 版本</td>
</tr>

<tr>
<td>pnpm</td>
<td>10.x</td>
<td>高性能包管理器</td>
</tr>

<tr>
<td>Docker Engine</td>
<td>29.x</td>
<td>支持 BuildKit 和多阶段构建</td>
</tr>

<tr>
<td>Docker Compose</td>
<td>v5</td>
<td>新版 Compose 规范，支持 <code>name</code> 项目</td>
</tr>

<tr>
<td>Caddy</td>
<td>2.8+</td>
<td>自动 HTTPS 的反向代理</td>
</tr>

<tr>
<td>PostgreSQL</td>
<td>alpine 最新</td>
<td>Umami 数据库</td>
</tr>

<tr>
<td>Umami</td>
<td>postgresql-latest</td>
<td>分析服务（生产环境建议固定具体版本，如 <code>postgresql-3.0.3</code>）</td>
</tr>

<tr>
<td>GitHub Actions</td>
<td>最新</td>
<td>CI/CD 平台</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="最终目标">🎯 最终目标</h2>

<ul>
<li>在现有 Docker Compose 环境中添加 Umami 服务（应用 + PostgreSQL 数据库）。</li>
<li>通过 Caddy 自动 HTTPS 暴露 <code>umami.你的域名.com</code>。</li>
<li>环境变量安全注入，数据持久化。</li>
<li>在 Nuxt 项目中通过 <code>nuxt-umami</code> 模块自动加载跟踪脚本，并在每次部署时自动更新配置。</li>
<li>强化生产环境安全，提供 IP 白名单、防火墙、备份等建议。</li>
</ul>

<hr>

<h2 id="系统架构图">🏗️ 系统架构图</h2>

<pre><code class="language-text">┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 本地开发 │────▶│ GitHub Actions │────▶│ 阿里云 ACR │
└─────────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌───────────────────────────────────────────────────────┐
│ 阿里云 ECS │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ PostgreSQL │◀──▶│ Umami │◀──▶│ Caddy │ │
│ │ (umami-db) │ │ (umami) │ │ (caddy) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ▲ ▲ ▲ │
│ └─────────────────┼──────────────────┘ │
│ 同一网络 `app-network` │
└───────────────────────────────────────────────────────┘
</code></pre>

<p><strong>说明</strong>：网络名 <code>app-network</code> 与进阶篇保持一致，用户可根据实际情况自定义，但需确保所有服务在同一网络。</p>

<hr>

<h2 id="前置准备">📦 前置准备</h2>

<ol>
<li><strong>完成进阶篇</strong>，已有基于 Docker Compose 的 Nuxt 项目生产环境（含 Caddy、PostgreSQL、应用容器），项目名统一为 <code>my-app</code>，网络名为 <code>app-network</code>。</li>
<li><strong>一个子域名</strong>（例如 <code>umami.your-domain.com</code>）已添加 A 记录指向服务器 IP。</li>
<li><strong>阿里云 ACR</strong> 已配置好命名空间和固定密码。</li>
<li><strong>服务器安全组</strong>开放 <code>80</code>、<code>443</code> 端口。</li>
<li><strong>GitHub Secrets</strong> 已包含进阶篇所需的所有变量（数据库密码、应用密钥等）。</li>
</ol>

<hr>

<h2 id="第一部分-docker-compose-中添加-umami-服务">🚀 第一部分：Docker Compose 中添加 Umami 服务</h2>

<p>编辑服务器上的 <code>/var/www/my-app/docker-compose.yml</code>，在 <code>services</code> 段末尾添加 Umami 及其数据库。<strong>注意</strong>：如果原文件中已定义 <code>networks</code> 和 <code>volumes</code>，请合并而非重复添加。</p>

<details>
<summary>点击展开完整代码</summary>

```yaml
name: my-app

services:
  # 原有服务（postgres, app, caddy）保持不变，此处省略...
  # 注意：请将以下服务名“app”替换为您实际的主应用服务名（应与进阶篇一致）

  umami-db:
    image: postgres:alpine
    container_name: my-app-umami-db
    restart: always
    environment:
      POSTGRES_DB: ${UMAMI_DB_NAME}
      POSTGRES_USER: ${UMAMI_DB_USER}
      POSTGRES_PASSWORD: ${UMAMI_DB_PASSWORD}
    volumes:
      - umami_db_data:/var/lib/postgresql/data
    networks:
      - app-network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${UMAMI_DB_USER} -d ${UMAMI_DB_NAME}"]
      interval: 10s
      timeout: 5s
      retries: 5
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

  umami:
    image: ghcr.io/umami-software/umami:postgresql-latest # 生产环境建议固定版本，如 postgresql-3.0.3
    container_name: my-app-umami
    restart: always
    depends_on:
      umami-db:
        condition: service_healthy
    environment:
      DATABASE_URL: postgresql://${UMAMI_DB_USER}:${UMAMI_DB_PASSWORD}@umami-db:5432/${UMAMI_DB_NAME}
      DATABASE_TYPE: postgresql
      APP_SECRET: ${UMAMI_APP_SECRET}
    networks:
      - app-network
    healthcheck:
      test:
        [
          "CMD",
          "node",
          "-e",
          "require('http').get('http://localhost:3000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})",
        ]
      interval: 30s
      timeout: 5s
      retries: 3
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

networks:
  app-network:
    driver: bridge
    # 如果原文件已定义，此处无需重复

volumes:
  postgres_data:
  caddy_data:
  caddy_config:
  umami_db_data:
  # 如果原文件已定义相应卷，此处只需追加 umami_db_data
```

</details>

<p><strong>关键点</strong>：</p>

<ul>
<li>所有服务使用同一网络 <code>app-network</code>，通过服务名通信（<code>umami-db</code> 和 <code>umami</code>）。</li>
<li>数据库和应用均配置健康检查，确保启动顺序。</li>
<li>日志切割防止磁盘爆满。</li>
<li><code>APP_SECRET</code> 用于加密会话，必须为足够长的随机字符串（建议使用十六进制生成，见下文）。</li>
<li>生产环境应避免使用 <code>latest</code> 标签，建议固定具体版本（如 <code>postgresql-3.0.3</code>），以确保稳定性。</li>
</ul>

<hr>

<h2 id="第二部分-caddy-子域名配置">🌐 第二部分：Caddy 子域名配置</h2>

<p>编辑 <code>/var/www/my-app/Caddyfile</code>，添加 <code>umami.your-domain.com</code> 配置块。请将 <code>your-domain.com</code> 替换为实际域名，并将 <code>reverse_proxy</code> 目标指向 Umami 容器服务名 <code>umami:3000</code>（而非主应用）。</p>

<pre><code class="language-caddy">umami.your-domain.com {
    reverse_proxy umami:3000   # 关键：指向 umami 容器
    encode gzip zstd
    header {
        X-Content-Type-Options &quot;nosniff&quot;
        X-Frame-Options &quot;DENY&quot;
        X-XSS-Protection &quot;1; mode=block&quot;
        Referrer-Policy &quot;strict-origin-when-cross-origin&quot;
        Strict-Transport-Security &quot;max-age=31536000; includeSubDomains; preload&quot;
    }
}
</code></pre>

<p><strong>说明</strong>：访问控制（如 IP 白名单）将在安全优化部分单独添加，此处仅配置基本代理和安全头。</p>

<p>保存后重启 Caddy 容器使配置生效：</p>

<pre><code class="language-bash">cd /var/www/my-app
docker compose restart caddy
</code></pre>

<hr>

<h2 id="第三部分-环境变量与密钥管理">🔐 第三部分：环境变量与密钥管理</h2>

<h3 id="3-1-生成-umami-所需密码和密钥">3.1 生成 Umami 所需密码和密钥</h3>

<p>在服务器上执行以下命令生成强密码（<strong>推荐使用十六进制，避免特殊字符</strong>）：</p>

<pre><code class="language-bash"># 生成数据库密码（32位十六进制）
openssl rand -hex 16
# 生成 APP_SECRET（64位十六进制）
openssl rand -hex 32
</code></pre>

<blockquote>
<p><strong>注意</strong>：使用 <code>-hex</code> 生成的字符串只包含 <code>0-9a-f</code>，可安全用于环境变量，无需担心 shell 转义问题。</p>
</blockquote>

<h3 id="3-2-更新服务器-env-文件">3.2 更新服务器 <code>.env</code> 文件</h3>

<p>编辑 <code>/var/www/my-app/.env</code>，添加以下变量（使用生成的值替换占位符）：</p>

<pre><code class="language-bash"># Umami 配置
UMAMI_DB_NAME=umami
UMAMI_DB_USER=umami
UMAMI_DB_PASSWORD=your-generated-db-password
UMAMI_APP_SECRET=your-generated-app-secret

# 用于 Nuxt 的环境变量（稍后会在 CI 中写入）
NUXT_PUBLIC_UMAMI_ID=待填入
NUXT_PUBLIC_UMAMI_HOST=https://umami.your-domain.com
</code></pre>

<p>保存后设置权限：</p>

<pre><code class="language-bash">chmod 600 /var/www/my-app/.env
</code></pre>

<h3 id="3-3-在-github-secrets-中添加变量">3.3 在 GitHub Secrets 中添加变量</h3>

<p>进入 GitHub 仓库 → <strong>Settings</strong> → <strong>Secrets and variables</strong> → <strong>Actions</strong>，添加以下 Secrets：</p>

<table>
<thead>
<tr>
<th>Secret 名称</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>UMAMI_DB_NAME</code></td>
<td>固定为 <code>umami</code></td>
</tr>

<tr>
<td><code>UMAMI_DB_USER</code></td>
<td>固定为 <code>umami</code></td>
</tr>

<tr>
<td><code>UMAMI_DB_PASSWORD</code></td>
<td>生成的数据库密码</td>
</tr>

<tr>
<td><code>UMAMI_APP_SECRET</code></td>
<td>生成的 APP_SECRET</td>
</tr>

<tr>
<td><code>NUXT_PUBLIC_UMAMI_ID</code></td>
<td>稍后从 Umami 后台获取（暂留空）</td>
</tr>

<tr>
<td><code>NUXT_PUBLIC_UMAMI_HOST</code></td>
<td><code>https://umami.your-domain.com</code></td>
</tr>
</tbody>
</table>

<hr>

<h2 id="第四部分-首次启动-umami-并获取-website-id">🚀 第四部分：首次启动 Umami 并获取 Website ID</h2>

<blockquote>
<p><strong>重要提示</strong>：本步骤需<strong>先手动执行一次</strong>，获取 Website ID 后更新 GitHub Secrets，再触发 CI 部署包含该 ID 的应用。不能在一次 CI 中完成所有步骤。</p>
</blockquote>

<h3 id="4-1-启动-umami-服务">4.1 启动 Umami 服务</h3>

<pre><code class="language-bash">cd /var/www/my-app
docker compose up -d umami-db umami
docker compose logs -f umami   # 观察日志，等待启动成功（看到 &quot;Ready in&quot; 字样）
</code></pre>

<h3 id="4-2-访问后台并添加网站">4.2 访问后台并添加网站</h3>

<ul>
<li>浏览器打开 <code>https://umami.your-domain.com</code>。</li>
<li>默认登录账号：<code>admin</code>，密码：<code>umami</code>。</li>
</ul>

<blockquote>
<p>⚠️ <strong>立即修改默认密码</strong>：登录后进入 <strong>Settings</strong> → <strong>Profile</strong>，将密码更改为强密码。
- 点击 <strong>Settings</strong> → <strong>Websites</strong> → <strong>Add Website</strong>，填写：
  - Name：<code>Your Site Name</code>（如 <code>My Blog</code>）
  - Domain：<code>your-domain.com</code>（主站域名）
- 保存后，复制生成的 <strong>Website ID</strong>（UUID 格式）。</p>
</blockquote>

<h3 id="4-3-更新-github-secrets">4.3 更新 GitHub Secrets</h3>

<p>将复制的 Website ID 填入 GitHub Secrets 中的 <code>NUXT_PUBLIC_UMAMI_ID</code>。</p>

<hr>

<h2 id="第五部分-nuxt-项目集成-nuxt-umami">🧩 第五部分：Nuxt 项目集成 <code>nuxt-umami</code></h2>

<h3 id="5-1-安装模块">5.1 安装模块</h3>

<p>在本地项目根目录执行：</p>

<pre><code class="language-bash">pnpm add nuxt-umami
# 或使用 nuxi 添加
pnpx nuxi@latest module add nuxt-umami
</code></pre>

<h3 id="5-2-配置-nuxt-config-ts">5.2 配置 <code>nuxt.config.ts</code></h3>

<pre><code class="language-typescript">export default defineNuxtConfig({
  modules: [&quot;nuxt-umami&quot;],
  umami: {
    id: process.env.NUXT_PUBLIC_UMAMI_ID,
    host: process.env.NUXT_PUBLIC_UMAMI_HOST,
    autoTrack: true,
    // 可选：仅在非开发环境启用
    enabled: process.env.NODE_ENV !== &quot;development&quot;,
  },
  // 其他配置...
});
</code></pre>

<h3 id="5-3-本地测试-可选">5.3 本地测试（可选）</h3>

<p>在项目根目录创建 <code>.env</code> 文件（<strong>不提交 Git</strong>）：</p>

<pre><code class="language-bash">NUXT_PUBLIC_UMAMI_ID=your-website-id
NUXT_PUBLIC_UMAMI_HOST=https://umami.your-domain.com
</code></pre>

<p>运行 <code>pnpm dev</code>，访问 <code>http://localhost:3000</code>，打开开发者工具 → Network，应能看到 Umami 脚本请求。</p>

<hr>

<h2 id="第六部分-dockerfile-必须接收构建参数">🔧 第六部分：Dockerfile 必须接收构建参数</h2>

<p><strong>关键</strong>：<code>NUXT_PUBLIC_*</code> 变量在构建时被嵌入客户端代码，必须在 Docker 构建阶段通过 <code>ARG</code> 和 <code>ENV</code> 传递。</p>

<p>编辑项目根目录的 <code>Dockerfile</code>，确保包含以下内容：</p>

<pre><code class="language-dockerfile"># 构建阶段
FROM node:24-alpine AS builder

# 接收所有 NUXT_PUBLIC_* 变量
ARG NUXT_PUBLIC_SITE_URL
ENV NUXT_PUBLIC_SITE_URL=$NUXT_PUBLIC_SITE_URL

ARG NUXT_PUBLIC_UMAMI_ID
ENV NUXT_PUBLIC_UMAMI_ID=$NUXT_PUBLIC_UMAMI_ID

ARG NUXT_PUBLIC_UMAMI_HOST
ENV NUXT_PUBLIC_UMAMI_HOST=$NUXT_PUBLIC_UMAMI_HOST

# 其余构建步骤保持不变...
</code></pre>

<p><strong>提醒</strong>：若后续增加其他 <code>NUXT_PUBLIC_*</code> 变量，必须同步添加 <code>ARG</code> 和 <code>ENV</code>。</p>

<hr>

<h2 id="第七部分-github-actions-工作流完善">⚙️ 第七部分：GitHub Actions 工作流完善</h2>

<h3 id="7-1-在-build-push-步骤中传递构建参数">7.1 在 <code>build-push</code> 步骤中传递构建参数</h3>

<p>编辑 <code>.github/workflows/deploy.yml</code>，在 <code>docker/build-push-action</code> 步骤的 <code>build-args</code> 中添加 Umami 变量：</p>

<pre><code class="language-yaml">- name: Build and push Docker image
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    build-args: |
      NUXT_PUBLIC_SITE_URL=${{ secrets.NUXT_PUBLIC_SITE_URL }}
      NUXT_PUBLIC_UMAMI_ID=${{ secrets.NUXT_PUBLIC_UMAMI_ID }}
      NUXT_PUBLIC_UMAMI_HOST=${{ secrets.NUXT_PUBLIC_UMAMI_HOST }}
    tags: |
      ${{ secrets.ACR_REGISTRY }}/my-app:latest
      ${{ secrets.ACR_REGISTRY }}/my-app:${{ github.sha }}
</code></pre>

<h3 id="7-2-在-ssh-部署脚本中写入-env-文件">7.2 在 SSH 部署脚本中写入 <code>.env</code> 文件</h3>

<p>确保 <code>appleboy/ssh-action</code> 步骤包含 Umami 数据库变量，并将它们写入服务器的 <code>.env</code>。<strong>注意</strong>：<code>envs</code> 列表需包含所有新增变量，并保持一行内逗号分隔。</p>

<pre><code class="language-yaml">- name: Deploy to Server via SSH
  uses: appleboy/ssh-action@v1.0.0
  env:
    # 原有变量...
    UMAMI_DB_NAME: ${{ secrets.UMAMI_DB_NAME }}
    UMAMI_DB_USER: ${{ secrets.UMAMI_DB_USER }}
    UMAMI_DB_PASSWORD: ${{ secrets.UMAMI_DB_PASSWORD }}
    UMAMI_APP_SECRET: ${{ secrets.UMAMI_APP_SECRET }}
    NUXT_PUBLIC_UMAMI_ID: ${{ secrets.NUXT_PUBLIC_UMAMI_ID }}
    NUXT_PUBLIC_UMAMI_HOST: ${{ secrets.NUXT_PUBLIC_UMAMI_HOST }}
  with:
    host: ${{ secrets.SERVER_HOST }}
    username: ${{ secrets.SERVER_USER }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    envs: &quot;ACR_REGISTRY,ACR_USERNAME,ACR_PASSWORD,POSTGRES_DB,POSTGRES_USER,POSTGRES_PASSWORD,NUXT_PUBLIC_SITE_URL,NUXT_SESSION_PASSWORD,NUXT_OAUTH_GITHUB_CLIENT_ID,NUXT_OAUTH_GITHUB_CLIENT_SECRET,UMAMI_DB_NAME,UMAMI_DB_USER,UMAMI_DB_PASSWORD,UMAMI_APP_SECRET,NUXT_PUBLIC_UMAMI_ID,NUXT_PUBLIC_UMAMI_HOST&quot;
    script: |
      set -e
      cd /var/www/my-app

      # 此操作将完全覆盖 .env 文件，请确保所有必要变量已包含在 envs 列表中。
      cat &gt; .env &lt;&lt; EOF
      # 原有变量...
      POSTGRES_DB=$POSTGRES_DB
      POSTGRES_USER=$POSTGRES_USER
      POSTGRES_PASSWORD=$POSTGRES_PASSWORD
      NUXT_PUBLIC_SITE_URL=$NUXT_PUBLIC_SITE_URL
      NUXT_SESSION_PASSWORD=$NUXT_SESSION_PASSWORD
      NUXT_OAUTH_GITHUB_CLIENT_ID=$NUXT_OAUTH_GITHUB_CLIENT_ID
      NUXT_OAUTH_GITHUB_CLIENT_SECRET=$NUXT_OAUTH_GITHUB_CLIENT_SECRET

      # Umami 变量
      UMAMI_DB_NAME=$UMAMI_DB_NAME
      UMAMI_DB_USER=$UMAMI_DB_USER
      UMAMI_DB_PASSWORD=$UMAMI_DB_PASSWORD
      UMAMI_APP_SECRET=$UMAMI_APP_SECRET
      NUXT_PUBLIC_UMAMI_ID=$NUXT_PUBLIC_UMAMI_ID
      NUXT_PUBLIC_UMAMI_HOST=$NUXT_PUBLIC_UMAMI_HOST
      EOF

      chmod 600 .env

      # 登录 ACR
      echo &quot;$ACR_PASSWORD&quot; | docker login &quot;$ACR_REGISTRY&quot; -u &quot;$ACR_USERNAME&quot; --password-stdin

      # 拉取最新镜像（请将 app 替换为您实际的主应用服务名）
      docker compose pull app
      docker compose pull umami

      # 重启应用容器（带构建参数的新镜像）
      docker compose up -d --force-recreate app

      # 如需更新 Umami 容器，可手动执行以下命令（会导致短暂停机）：
      # docker compose up -d --force-recreate umami

      # 重启 Caddy
      docker compose up -d --force-recreate caddy

      # 清理旧镜像
      docker image prune -f --filter &quot;until=24h&quot;
</code></pre>

<p><strong>说明</strong>：</p>

<ul>
<li>脚本中 <code>app</code> 请替换为您实际的主应用服务名。</li>
<li><code>envs</code> 列表使用逗号分隔在一行内，避免换行导致解析错误。</li>
<li>每次更新 <code>NUXT_PUBLIC_UMAMI_ID</code> 或 <code>UMAMI_*</code> 变量时，需确保它们已包含在 <code>envs</code> 和 <code>env</code> 部分中。</li>
</ul>

<hr>

<h2 id="第八部分-验证集成">✅ 第八部分：验证集成</h2>

<h3 id="8-1-检查网络请求">8.1 检查网络请求</h3>

<ul>
<li>访问 <code>https://your-domain.com</code>，打开开发者工具 → <strong>Network</strong> 标签，刷新页面。</li>
<li>过滤 <code>umami</code> 或 <code>api/send</code>，应能看到：

<ul>
<li>一个指向 <code>https://umami.your-domain.com/script.js</code> 的 GET 请求（加载跟踪脚本）。</li>
<li>一个指向 <code>https://umami.your-domain.com/api/send</code> 的 POST 请求（发送页面视图数据）。</li>
</ul></li>
</ul>

<h3 id="8-2-查看-umami-后台实时数据">8.2 查看 Umami 后台实时数据</h3>

<p>登录 <code>https://umami.your-domain.com</code>，进入 <strong>Realtime</strong> 页面，应显示当前访问记录。</p>

<h3 id="8-3-验证-window-umami-对象">8.3 验证 <code>window.umami</code> 对象</h3>

<p>在浏览器控制台输入 <code>window.umami</code>，应返回包含 <code>track</code>、<code>identify</code> 等方法的对象。</p>

<hr>

<h2 id="第九部分-安全优化建议">🔒 第九部分：安全优化建议</h2>

<h3 id="9-1-限制-umami-子域名访问范围">9.1 限制 Umami 子域名访问范围</h3>

<h4 id="9-1-1-ip-白名单-推荐">9.1.1 IP 白名单（推荐）</h4>

<p>如果仅允许特定 IP（如家庭宽带）访问 Umami 后台，可在 Caddy 中添加 IP 白名单。</p>

<p>编辑 Caddyfile，修改 <code>umami.your-domain.com</code> 块：</p>

<pre><code class="language-caddy">umami.your-domain.com {
    @allowed remote_ip 192.0.2.1 2001:db8::1 127.0.0.1
    handle @allowed {
        reverse_proxy umami:3000   # 指向 umami 容器
    }
    handle {
        respond &quot;Access Denied&quot; 403
    }

    encode gzip zstd
    header { ... }
}
</code></pre>

<p>将 <code>192.0.2.1</code> 和 <code>2001:db8::1</code> 替换为实际家庭或办公室的公网 IP（IPv4 和 IPv6）。注意家庭宽带 IP 可能变化，建议配合 DDNS 或定期更新。</p>

<h4 id="9-1-2-基础认证-可选">9.1.2 基础认证（可选）</h4>

<p>若需在移动网络下访问，可启用 Caddy 的 <code>basicauth</code>，但需注意与 Umami 自身登录页面的潜在冲突。正确配置方法：</p>

<ol>
<li>生成密码哈希（使用 <code>caddy hash-password</code> 命令）：</li>
</ol>

<pre><code class="language-bash">   docker exec my-app-caddy caddy hash-password --plaintext 'your-password'
</code></pre>

<p><strong>注意</strong>：将命令输出的字符串用<strong>单引号</strong>包裹后填入 Caddyfile，例如 <code>admin '$2a$...'</code>，以防止 <code>$</code> 被解析为环境变量。</p>

<ol>
<li>在 Caddyfile 中添加：</li>
</ol>

<pre><code class="language-caddy">umami.your-domain.com {
    basicauth * {
        admin '$2a$14$...'   # 使用单引号包裹哈希值
    }
    reverse_proxy umami:3000 {
        header_up -Authorization  # 移除 Authorization 头，避免干扰 Umami 会话
    }
    # ... 其他配置
}
</code></pre>

<p><strong>警告</strong>：这会导致浏览器先弹出 Basic Auth 对话框，通过后再显示 Umami 登录页，可能造成体验不佳。建议仅作为临时方案或与 IP 白名单结合使用。</p>

<h3 id="9-2-数据库安全">9.2 数据库安全</h3>

<ul>
<li>使用强密码（已使用 <code>openssl rand -hex</code> 生成）。</li>
<li>Umami 数据库仅对内部网络暴露，无需映射端口。</li>
<li>定期备份数据库（确保 <code>backups</code> 目录存在）：</li>
</ul>

<pre><code class="language-bash">  mkdir -p /var/www/my-app/backups
  docker exec my-app-umami-db pg_dump -U umami umami &gt; /var/www/my-app/backups/umami_$(date +%Y%m%d).sql
</code></pre>

<h3 id="9-3-caddy-安全头增强">9.3 Caddy 安全头增强</h3>

<p>在 Caddyfile 中添加更严格的 CSP 头（需根据实际资源调整，生产环境建议移除 <code>'unsafe-inline'</code> 并采用 nonce 或 hash 策略）：</p>

<pre><code class="language-caddy">header {
    Content-Security-Policy &quot;default-src 'self'; script-src 'self' https://umami.your-domain.com; style-src 'self'; img-src 'self' data:; connect-src 'self' https://umami.your-domain.com;&quot;
    # 其他头...
}
</code></pre>

<h3 id="9-4-日志配置与轮转">9.4 日志配置与轮转</h3>

<p>Caddy 日志可输出到文件并配置轮转。需提前创建日志目录并设置权限（Caddy 容器内用户 UID 为 1000）：</p>

<pre><code class="language-bash">sudo mkdir -p /var/log/caddy
sudo chown 1000:1000 /var/log/caddy
</code></pre>

<p>然后在 Caddyfile 中添加：</p>

<pre><code class="language-caddy">log {
    output file /var/log/caddy/umami-access.log {
        roll_size 10MB
        roll_keep 5
    }
}
</code></pre>

<p>若不想处理文件权限，可直接输出到标准输出（由 Docker 收集）。</p>

<h3 id="9-5-防火墙配置">9.5 防火墙配置</h3>

<ul>
<li>在阿里云安全组中，仅开放 80、443 端口给公网，22 端口限制为特定管理 IP。</li>
<li>在服务器内部使用 <code>ufw</code> 进一步限制：</li>
</ul>

<pre><code class="language-bash">  sudo ufw default deny incoming
  sudo ufw default allow outgoing
  sudo ufw allow 22/tcp  # 建议限制来源 IP
  sudo ufw allow 80/tcp
  sudo ufw allow 443/tcp
  sudo ufw enable
</code></pre>

<hr>

<h2 id="第十部分-深度问题排查手册">🧪 第十部分：深度问题排查手册</h2>

<table>
<thead>
<tr>
<th>现象</th>
<th>可能原因</th>
<th>解决方案</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>Umami 容器不断重启，日志显示 <code>password authentication failed</code></strong></td>
<td>数据库密码与 <code>.env</code> 不一致，或密码含特殊字符</td>
<td>检查 <code>.env</code> 中 <code>UMAMI_DB_PASSWORD</code> 是否匹配；使用字母数字密码；删除卷重建：<code>docker compose down -v umami-db umami &amp;&amp; docker compose up -d umami-db umami</code></td>
</tr>

<tr>
<td><strong>访问 <code>umami.your-domain.com</code> 返回 502</strong></td>
<td>Umami 容器未就绪，或 Caddy 代理配置错误</td>
<td>检查 <code>docker compose ps umami</code> 状态；查看 Caddy 日志；测试内部连通性：<code>docker exec my-app-caddy wget -O- http://umami:3000</code></td>
</tr>

<tr>
<td><strong>浏览器中 Umami 脚本未加载，Network 中无请求</strong></td>
<td>构建时未传递 <code>NUXT_PUBLIC_UMAMI_*</code> 变量</td>
<td>检查 Dockerfile 是否包含对应的 <code>ARG</code>/<code>ENV</code>；检查 GitHub Actions 日志中 <code>build-args</code> 是否传递</td>
</tr>

<tr>
<td><strong>后台无数据，但脚本已加载</strong></td>
<td>Website ID 错误，或广告拦截器阻止</td>
<td>核对 <code>NUXT_PUBLIC_UMAMI_ID</code>；在无痕模式下测试</td>
</tr>

<tr>
<td><strong>本地开发环境报 <code>id is missing</code></strong></td>
<td>本地未设置环境变量</td>
<td>可忽略，或使用 <code>enabled: process.env.NODE_ENV !== 'development'</code> 禁用</td>
</tr>

<tr>
<td><strong>IP 白名单不生效，所有 IP 均可访问</strong></td>
<td>Caddy 未重新加载配置 / <code>remote_ip</code> 匹配器语法错误</td>
<td>重启 Caddy 容器；检查 <code>@allowed</code> 定义中 IP 格式是否正确</td>
</tr>

<tr>
<td><strong>Basic Auth 配置后页面转圈</strong></td>
<td><code>Authorization</code> 头干扰 Umami 会话 / 浏览器缓存</td>
<td>添加 <code>header_up -Authorization</code> 到 <code>reverse_proxy</code>；清除浏览器缓存</td>
</tr>

<tr>
<td><strong>Docker 拉取镜像超时</strong></td>
<td>网络问题或未配置镜像加速器</td>
<td>配置镜像加速器并重启 Docker</td>
</tr>

<tr>
<td><strong><code>.env</code> 文件写入后权限错误</strong></td>
<td>文件权限设置不当</td>
<td>确保 <code>chmod 600 .env</code>，属主为运行 Docker 的用户</td>
</tr>

<tr>
<td><strong>Caddy 无法写入日志文件</strong></td>
<td>宿主机目录权限不足</td>
<td>确保目录存在且 UID 1000 有写权限：<code>sudo chown 1000:1000 /var/log/caddy</code></td>
</tr>
</tbody>
</table>

<hr>

<h2 id="第十一部分-日常运维">🛠️ 第十一部分：日常运维</h2>

<h3 id="11-1-常用命令">11.1 常用命令</h3>

<pre><code class="language-bash"># 查看所有服务状态
docker compose ps

# 查看 Umami 实时日志
docker compose logs -f umami

# 进入 Umami 容器
docker exec -it my-app-umami sh

# 备份 Umami 数据库（确保 backups 目录存在）
mkdir -p backups
docker exec my-app-umami-db pg_dump -U umami umami &gt; backups/umami_$(date +%Y%m%d).sql

# 恢复数据库
cat backups/umami_20260318.sql | docker exec -i my-app-umami-db psql -U umami -d umami

# 手动更新 Umami 镜像（拉取最新版本并重启）
docker compose pull umami &amp;&amp; docker compose up -d umami
</code></pre>

<h3 id="11-2-自动备份-可选">11.2 自动备份（可选）</h3>

<p>添加定时任务（crontab -e）：</p>

<pre><code class="language-bash">0 3 * * * cd /var/www/my-app &amp;&amp; mkdir -p backups &amp;&amp; docker exec my-app-umami-db pg_dump -U umami umami &gt; backups/umami_$(date +\%Y\%m\%d).sql
</code></pre>

<hr>

<h2 id="总结">🏁 总结</h2>

<p>通过本指南，读者成功实现了：</p>

<ul>
<li>在现有 Docker 生产环境中自托管 Umami 分析服务。</li>
<li>通过 Caddy 自动 HTTPS 暴露子域名。</li>
<li>环境变量安全注入与 CI/CD 自动化部署。</li>
<li>Nuxt 项目正确集成 <code>nuxt-umami</code> 模块，并在构建时传递必要变量。</li>
<li>生产级安全加固措施，保障服务仅被授权访问。</li>
</ul>

<p>现在，网站访问数据将被清晰记录，且整个过程完全自动化，无需人工干预。后续如需升级 Umami 或修改配置，只需修改 <code>docker-compose.yml</code> 中的镜像标签或环境变量，重新部署即可。</p>

<p><strong>核心维护要点</strong>：</p>

<ul>
<li>定期更新 Umami 镜像以获取安全补丁（建议测试后再更新）。</li>
<li>监控日志和磁盘使用情况。</li>
<li>若家庭 IP 变化，及时更新 IP 白名单。</li>
<li>妥善保管 <code>.env</code> 文件和 GitHub Secrets。</li>
<li>确保 <code>envs</code> 列表始终包含所有新增的环境变量，避免部署时丢失。</li>
</ul>
]]></content:encoded>
      <description><![CDATA[在现有 Docker 生产环境中集成自托管的 Umami 分析服务，通过 Caddy 自动 HTTPS 和 GitHub Actions 实现 Nuxt 4 项目的自动化数据跟踪。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Docker]]></category>
      <category><![CDATA[Caddy]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P4</dc:subject>
      <dc:relation><![CDATA[series:deployment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[GitHub Actions + Docker 生产级自动化部署（进阶篇）]]></title>
      <link>https://moongate.top/docs/docker-production-auto-deploy</link>
      <guid isPermaLink="true">947f4f24-fc9b-4c71-8fe5-cc85d2a7a794</guid>
      <pubDate>Mon, 16 Mar 2026 23:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共六篇，覆盖从静态网站到生产级 Docker 部署及服务集成的全流程：</p>

<ol>
<li><p><a href="./static-site-auto-deploy"><strong>静态网站自动化部署（静态篇）</strong></a>
—— 纯前端资源的自动化发布，Caddy 自动 HTTPS 和 SPA 路由支持。</p></li>

<li><p><a href="dynamic-site-auto-deploy" target="_blank"><strong>动态网站自动化部署（动态篇）</strong></a>
—— 后端服务进程管理、环境变量注入、数据库迁移，结合 Caddy 反向代理。</p></li>

<li><p><a href="docker-quickstart-auto-deploy" target="_blank"><strong>Docker 极简入门（入门篇）</strong></a>
—— 从零开始用 Docker + GitHub Actions 实现 CI/CD 流水线。</p></li>

<li><p><a href="docker-production-auto-deploy" target="_blank"><strong>Docker 生产级部署（进阶篇）</strong></a>
—— 多容器编排、健康检查、数据库迁移、自动 HTTPS，打造可靠的生产环境。</p></li>

<li><p><a href="./umami-integration-auto-deploy"><strong>自托管 Umami 分析服务与 Nuxt 4 项目集成指南（扩展篇）</strong></a>
—— 在现有 Docker 生产环境中集成 Umami 分析服务，实现自动化数据跟踪与安全加固。</p></li>

<li><p><a href="./vitepress-docker-existing-infrastructure-subdomain-deployment"><strong>VitePress 文档站接入已有 Docker 基础设施：子域名部署（扩展篇）</strong></a>
—— 将 VitePress 静态文档站作为子域名接入现有 Docker 基础设施，复用 Caddy 反向代理与网络。</p></li>
</ol>

<hr>

<h2 id="版本声明">📌 版本声明</h2>

<p>本文档所有工具均采用 <strong>2026 年最新稳定版</strong>：</p>

<table>
<thead>
<tr>
<th>工具</th>
<th>版本</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td>Node.js</td>
<td>24.x</td>
<td>最新 LTS 版本</td>
</tr>

<tr>
<td>pnpm</td>
<td>10.x</td>
<td>高性能包管理器</td>
</tr>

<tr>
<td>Docker Engine</td>
<td>29.x</td>
<td>支持 BuildKit 和多阶段构建</td>
</tr>

<tr>
<td>Docker Compose</td>
<td>v5</td>
<td>新版 Compose 规范，支持 <code>name</code> 项目和健康检查依赖</td>
</tr>

<tr>
<td>Caddy</td>
<td>2.8+</td>
<td>自动 HTTPS 的反向代理</td>
</tr>

<tr>
<td>PostgreSQL</td>
<td>17 (alpine)</td>
<td>轻量级数据库</td>
</tr>

<tr>
<td>Drizzle ORM</td>
<td>0.30+</td>
<td>TypeScript ORM，支持迁移</td>
</tr>

<tr>
<td>PM2</td>
<td>5+</td>
<td>进程守护工具</td>
</tr>

<tr>
<td>GitHub Actions</td>
<td>最新</td>
<td>CI/CD 平台（<code>checkout@v4</code>, <code>ssh-action@v1.0.0</code> 等）</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="本章目标">🎯 本章目标</h2>

<p>在入门篇的基础上，你将学会：</p>

<ul>
<li>✅ 多容器生产级编排（应用 + 数据库 + 反向代理）</li>
<li>✅ 健康检查与容器启动顺序控制</li>
<li>✅ 容器网络与服务发现（通过服务名通信）</li>
<li>✅ 环境变量安全传递（GitHub Secrets + 服务器 <code>.env</code> 权限）</li>
<li>✅ 数据持久化与自动备份</li>
<li>✅ 数据库迁移自动化（以 Drizzle ORM 为例）</li>
<li>✅ 零停机部署策略（解决端口冲突）</li>
<li>✅ 镜像加速器配置与国内优化</li>
<li>✅ 常见问题深度排查手册</li>
</ul>

<p>最终你将拥有一套<strong>可上生产、自动修复、安全可控</strong>的 Docker 部署流水线。</p>

<hr>

<h2 id="系统架构图">🏗️ 系统架构图</h2>

<pre><code class="language-text">┌─────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  本地开发    │────▶│  GitHub Actions │────▶│   阿里云 ACR    │
└─────────────┘     └─────────────────┘     └─────────────────┘
                                                      │
                                                      ▼
┌───────────────────────────────────────────────────────┐
│                    阿里云 ECS                          │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐ │
│  │  PostgreSQL │◀──▶│  Nuxt 应用  │◀──▶│    Caddy    │ │
│  │   (容器)    │    │   (容器)    │    │   (容器)    │ │
│  └─────────────┘    └─────────────┘    └─────────────┘ │
│         ▲                 ▲                  ▲         │
│         └─────────────────┼──────────────────┘         │
│                   同一网络 `app-network`                │
└───────────────────────────────────────────────────────┘
</code></pre>

<hr>

<h2 id="前置准备">📦 前置准备</h2>

<ol>
<li><strong>完成入门篇</strong>，已能跑通单容器部署。</li>
<li><strong>一个域名</strong>（例如 <code>your-domain.com</code>）并解析到服务器 IP。</li>
<li><strong>阿里云 ACR</strong> 已配置好命名空间和固定密码。</li>
<li><strong>服务器安全组</strong>开放 <code>80</code>、<code>443</code>、<code>22</code> 端口。</li>
<li><strong>项目已集成 Drizzle ORM</strong>，并生成迁移文件（已提交至 Git）。</li>
</ol>

<hr>

<h2 id="第一部分-生产级-docker-compose-配置">🚀 第一部分：生产级 Docker Compose 配置</h2>

<h3 id="1-1-目录结构">1.1 目录结构</h3>

<pre><code class="language-text">/var/www/my-app/
├── docker-compose.yml
├── .env                  # 环境变量（手动创建，不提交 Git）
├── Caddyfile             # Caddy 配置
└── backups/              # 数据库备份目录（可选）
</code></pre>

<h3 id="1-2-编写生产级-docker-compose-yml">1.2 编写生产级 docker-compose.yml</h3>

<details>
<summary>点击展开完整代码</summary>

```yaml
name: my-app # 固定项目名，避免网络混乱

services:
  postgres:
    image: postgres:alpine
    container_name: my-app-db
    restart: always
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - app-network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

  app:
    image: ${ACR_REGISTRY}/my-app:latest
    container_name: my-app
    restart: always
    # 不暴露端口到宿主机，仅内部网络访问（由 Caddy 代理）
    environment:
      NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL}
      NUXT_SESSION_PASSWORD: ${NUXT_SESSION_PASSWORD}
      NUXT_OAUTH_GITHUB_CLIENT_ID: ${NUXT_OAUTH_GITHUB_CLIENT_ID}
      NUXT_OAUTH_GITHUB_CLIENT_SECRET: ${NUXT_OAUTH_GITHUB_CLIENT_SECRET}
      NUXT_DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - app-network
    healthcheck:
      test:
        [
          "CMD",
          "node",
          "-e",
          "require('http').get('http://localhost:3000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})",
        ]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 15s
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

  caddy:
    image: caddy:alpine
    container_name: my-app-caddy
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - app-network
    depends_on:
      app:
        condition: service_healthy
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

volumes:
  postgres_data:
  caddy_data:
  caddy_config:

networks:
  app-network:
    driver: bridge
```

</details>

<p><strong>关键点</strong>：</p>

<ul>
<li><code>name</code> 固定项目名，避免因目录名变化导致网络不一致。</li>
<li>所有服务在同一网络，通过服务名通信。</li>
<li>应用容器不暴露端口，流量全走 Caddy，提升安全性。</li>
<li><code>depends_on</code> + <code>condition: service_healthy</code> 确保启动顺序。</li>
<li>日志切割防止磁盘爆满。</li>
</ul>

<h3 id="1-3-caddyfile-配置-自动-https">1.3 Caddyfile 配置（自动 HTTPS）</h3>

<p>请将 <code>your-domain.com</code> 替换为你的实际域名。</p>

<pre><code class="language-caddy">www.your-domain.com {
    redir https://your-domain.com{uri} permanent
}

your-domain.com {
    reverse_proxy app:3000
    encode gzip zstd
    header {
        X-Content-Type-Options &quot;nosniff&quot;
        X-Frame-Options &quot;DENY&quot;
        X-XSS-Protection &quot;1; mode=block&quot;
        Referrer-Policy &quot;strict-origin-when-cross-origin&quot;
    }
}
</code></pre>

<hr>

<h2 id="第二部分-环境变量安全">🔐 第二部分：环境变量安全</h2>

<h3 id="2-1-github-secrets-完整列表">2.1 GitHub Secrets 完整列表</h3>

<table>
<thead>
<tr>
<th>Secret 名称</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>ACR_REGISTRY</code></td>
<td>阿里云镜像仓库地址（如 <code>crpi-xxx.cn-beijing.personal.cr.aliyuncs.com</code>）</td>
</tr>

<tr>
<td><code>ACR_USERNAME</code></td>
<td>阿里云账号邮箱</td>
</tr>

<tr>
<td><code>ACR_PASSWORD</code></td>
<td>ACR 固定密码</td>
</tr>

<tr>
<td><code>SERVER_HOST</code></td>
<td>服务器 IP</td>
</tr>

<tr>
<td><code>SERVER_USER</code></td>
<td>SSH 用户名</td>
</tr>

<tr>
<td><code>SSH_PRIVATE_KEY</code></td>
<td>SSH 私钥（包含 <code>BEGIN</code> 和 <code>END</code> 行，保持完整换行）</td>
</tr>

<tr>
<td><code>POSTGRES_DB</code></td>
<td>数据库名</td>
</tr>

<tr>
<td><code>POSTGRES_USER</code></td>
<td>数据库用户</td>
</tr>

<tr>
<td><code>POSTGRES_PASSWORD</code></td>
<td>数据库密码</td>
</tr>

<tr>
<td><code>NUXT_PUBLIC_SITE_URL</code></td>
<td>网站域名（如 <code>https://your-domain.com</code>）</td>
</tr>

<tr>
<td><code>NUXT_SESSION_PASSWORD</code></td>
<td>会话加密密钥（至少 32 位）</td>
</tr>

<tr>
<td><code>NUXT_OAUTH_GITHUB_CLIENT_ID</code></td>
<td>GitHub OAuth Client ID</td>
</tr>

<tr>
<td><code>NUXT_OAUTH_GITHUB_CLIENT_SECRET</code></td>
<td>GitHub OAuth Client Secret</td>
</tr>
</tbody>
</table>

<h3 id="2-2-服务器-env-文件权限">2.2 服务器 <code>.env</code> 文件权限</h3>

<p>在首次部署前，手动创建 <code>/var/www/my-app/.env</code>，并设置权限：</p>

<pre><code class="language-bash">chmod 600 /var/www/my-app/.env
</code></pre>

<p>内容示例（请替换为实际值）：</p>

<pre><code class="language-bash">POSTGRES_DB=myapp
POSTGRES_USER=postgres
POSTGRES_PASSWORD=StrongPassword123!
ACR_REGISTRY=crpi-xxx.cn-beijing.personal.cr.aliyuncs.com
NUXT_PUBLIC_SITE_URL=https://your-domain.com
NUXT_SESSION_PASSWORD=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NUXT_OAUTH_GITHUB_CLIENT_ID=xxxxxxxxxx
NUXT_OAUTH_GITHUB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx
</code></pre>

<hr>

<h2 id="第三部分-github-actions-工作流-进阶版">⚙️ 第三部分：GitHub Actions 工作流（进阶版）</h2>

<details>
<summary>点击展开完整代码</summary>

```yaml
name: Production Deploy

on:
  push:
    branches: [main]
  workflow_dispatch: # 允许手动触发

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Login to ACR
        uses: docker/login-action@v3
        with:
          registry: ${{ secrets.ACR_REGISTRY }}
          username: ${{ secrets.ACR_USERNAME }}
          password: ${{ secrets.ACR_PASSWORD }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: |
            ${{ secrets.ACR_REGISTRY }}/my-app:latest
            ${{ secrets.ACR_REGISTRY }}/my-app:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Deploy to server
        uses: appleboy/ssh-action@v1.0.0
        env:
          ACR_REGISTRY: ${{ secrets.ACR_REGISTRY }}
          ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
          ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
          POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
          POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
          POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
          NUXT_PUBLIC_SITE_URL: ${{ secrets.NUXT_PUBLIC_SITE_URL }}
          NUXT_SESSION_PASSWORD: ${{ secrets.NUXT_SESSION_PASSWORD }}
          NUXT_OAUTH_GITHUB_CLIENT_ID: ${{ secrets.NUXT_OAUTH_GITHUB_CLIENT_ID }}
          NUXT_OAUTH_GITHUB_CLIENT_SECRET: ${{ secrets.NUXT_OAUTH_GITHUB_CLIENT_SECRET }}
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          envs: ACR_REGISTRY, ACR_USERNAME, ACR_PASSWORD, POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD, NUXT_PUBLIC_SITE_URL, NUXT_SESSION_PASSWORD, NUXT_OAUTH_GITHUB_CLIENT_ID, NUXT_OAUTH_GITHUB_CLIENT_SECRET
          script: |
            set -e
            cd /var/www/my-app

            # 写入环境变量（注意用 EOF 不加引号，确保变量展开）
            cat > .env << EOF
            POSTGRES_DB=$POSTGRES_DB
            POSTGRES_USER=$POSTGRES_USER
            POSTGRES_PASSWORD=$POSTGRES_PASSWORD
            ACR_REGISTRY=$ACR_REGISTRY
            NUXT_PUBLIC_SITE_URL=$NUXT_PUBLIC_SITE_URL
            NUXT_SESSION_PASSWORD=$NUXT_SESSION_PASSWORD
            NUXT_OAUTH_GITHUB_CLIENT_ID=$NUXT_OAUTH_GITHUB_CLIENT_ID
            NUXT_OAUTH_GITHUB_CLIENT_SECRET=$NUXT_OAUTH_GITHUB_CLIENT_SECRET
            EOF

            chmod 600 .env

            # 登录 ACR
            echo "$ACR_PASSWORD" | docker login "$ACR_REGISTRY" -u "$ACR_USERNAME" --password-stdin

            # 拉取最新镜像
            docker compose pull app

            # 执行数据库迁移（确保容器内有 drizzle-kit 或临时安装）
            # 方案：使用 npm 全局安装 drizzle-kit 后执行迁移
            docker compose run --rm app sh -c "npm install -g drizzle-kit && drizzle-kit migrate"

            # 重启应用（强制重新创建容器，避免端口冲突）
            docker compose up -d --force-recreate app

            # 重启 Caddy（如有更新）
            docker compose up -d --force-recreate caddy

            # 清理旧镜像（保留最近24小时）
            docker image prune -f --filter "until=24h"
```

</details>

<p><strong>进阶要点</strong>：</p>

<ul>
<li>使用 <code>cache-from</code> 加速构建。</li>
<li>远程脚本中执行数据库迁移（使用 <code>npm install -g</code> 确保有 <code>drizzle-kit</code>，避免依赖缺失）。</li>
<li><code>--force-recreate</code> 确保旧容器被完全替换，解决端口残留问题。</li>
<li>清理 24 小时前的旧镜像，避免磁盘占满。</li>
<li><code>envs</code> 列表中包含了所有需要传递的变量，确保远程 shell 能正确读取。</li>
</ul>

<hr>

<h2 id="第四部分-服务器初始化-生产准备">🧪 第四部分：服务器初始化（生产准备）</h2>

<h3 id="4-1-安装-docker-并配置镜像加速器">4.1 安装 Docker 并配置镜像加速器</h3>

<p>若加速器无效，可使用 ACR 的海外源镜像同步功能或自行推送镜像至私有仓库。</p>

<pre><code class="language-bash">curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# 重新登录或执行 newgrp docker 使组生效

sudo tee /etc/docker/daemon.json &lt;&lt;-'EOF'
{
  &quot;registry-mirrors&quot;: [&quot;https://your-mirror-id.mirror.aliyuncs.com&quot;],
  &quot;log-driver&quot;: &quot;json-file&quot;,
  &quot;log-opts&quot;: {
    &quot;max-size&quot;: &quot;10m&quot;,
    &quot;max-file&quot;: &quot;3&quot;
  }
}
EOF
sudo systemctl restart docker
</code></pre>

<h3 id="4-2-创建项目目录并上传文件">4.2 创建项目目录并上传文件</h3>

<pre><code class="language-bash">mkdir -p /var/www/my-app
cd /var/www/my-app
# 将本地的 docker-compose.yml 和 Caddyfile 上传到该目录
# 例如：scp docker-compose.yml Caddyfile root@your-server:/var/www/my-app/
</code></pre>

<h3 id="4-3-首次启动-手动">4.3 首次启动（手动）</h3>

<pre><code class="language-bash"># 手动创建 .env 文件（参照 2.2 的示例）
vim .env
chmod 600 .env

# 启动所有服务
docker compose up -d
</code></pre>

<h3 id="4-4-验证服务">4.4 验证服务</h3>

<pre><code class="language-bash">docker compose ps
curl -I http://localhost:3000  # 应返回 200（应用内部端口）
curl -I https://your-domain.com # 应返回 200（通过 Caddy）
</code></pre>

<hr>

<h2 id="第五部分-深度问题排查手册">🔧 第五部分：深度问题排查手册</h2>

<table>
<thead>
<tr>
<th>现象</th>
<th>可能原因</th>
<th>解决方案</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>Actions 中 SSH 连接失败</strong></td>
<td>私钥格式错误 / 安全组未开放 22 端口</td>
<td>检查 Secrets 中的私钥是否包含完整换行；检查安全组入方向规则</td>
</tr>

<tr>
<td><strong>Caddy 容器无法启动，端口 <sup>80</sup>&frasl;<sub>443</sub> 被占用</strong></td>
<td>宿主机有其他 Web 服务（如系统级 Caddy、Nginx）</td>
<td><code>sudo lsof -i :80 -i :443</code> 找到并停止进程；或修改端口映射</td>
</tr>

<tr>
<td><strong>应用无法连接数据库，日志显示 <code>getaddrinfo EAI_AGAIN postgres</code></strong></td>
<td>容器间网络问题 / 数据库服务名错误</td>
<td>确认 <code>app</code> 和 <code>postgres</code> 在同一网络；检查 <code>DATABASE_URL</code> 中的主机名是否为 <code>postgres</code></td>
</tr>

<tr>
<td><strong>数据库迁移失败，提示 <code>drizzle-kit: not found</code></strong></td>
<td>容器内未安装 drizzle-kit</td>
<td>已改为使用 <code>npm install -g drizzle-kit</code>，确保网络通畅；也可在 Dockerfile 中预装</td>
</tr>

<tr>
<td><strong>应用容器不断重启</strong></td>
<td>健康检查失败 / 依赖服务未就绪</td>
<td>查看日志：<code>docker logs my-app --tail 50</code>；检查 <code>depends_on</code> 条件</td>
</tr>

<tr>
<td><strong>HTTPS 证书未自动生成</strong></td>
<td>域名解析未生效 / 80 端口未开放</td>
<td>检查 DNS 解析；确保 Caddy 能访问外网</td>
</tr>

<tr>
<td><strong>镜像拉取慢</strong></td>
<td>未配置镜像加速器</td>
<td>按 4.1 配置加速器并重启 Docker</td>
</tr>

<tr>
<td><strong>部署后网站未更新</strong></td>
<td>容器未重启 / 镜像标签未更新</td>
<td>检查 Actions 日志；手动执行 <code>docker compose pull &amp;&amp; docker compose up -d</code></td>
</tr>

<tr>
<td><strong>宿主机重启后容器未自动恢复</strong></td>
<td>未设置 Docker 开机自启</td>
<td><code>sudo systemctl enable docker</code>；容器已设置 <code>restart: always</code>，会自动启动</td>
</tr>

<tr>
<td><strong><code>.env</code> 文件权限导致 Secrets 泄露风险</strong></td>
<td>权限设置不当</td>
<td>确保 <code>.env</code> 权限为 <code>600</code>，属主为运行 Docker 的用户</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="第六部分-日常运维">📈 第六部分：日常运维</h2>

<h3 id="6-1-常用命令">6.1 常用命令</h3>

<pre><code class="language-bash"># 查看所有服务状态
docker compose ps

# 查看实时日志
docker compose logs -f app

# 进入容器
docker exec -it my-app sh

# 备份数据库
docker exec my-app-db pg_dump -U postgres myapp &gt; backups/backup_$(date +%Y%m%d).sql

# 恢复数据库
cat backups/backup.sql | docker exec -i my-app-db psql -U postgres -d myapp

# 清理未使用的镜像
docker image prune -f

# 清理未使用的卷（谨慎操作）
docker volume prune -f
</code></pre>

<h3 id="6-2-自动备份-可选">6.2 自动备份（可选）</h3>

<p>添加定时任务（crontab -e）：</p>

<pre><code class="language-bash">0 2 * * * cd /var/www/my-app &amp;&amp; docker exec my-app-db pg_dump -U postgres myapp &gt; backups/backup_$(date +\%Y\%m\%d).sql
</code></pre>

<hr>

<h2 id="总结">🏁 总结</h2>

<p>至此，你已经构建了一套完整的、生产可用的 Docker 自动化部署体系：</p>

<ul>
<li>✅ 多服务容器化编排</li>
<li>✅ 健康检查与依赖控制</li>
<li>✅ 环境变量安全注入</li>
<li>✅ 数据库迁移自动化</li>
<li>✅ 零停机部署</li>
<li>✅ 自动 HTTPS</li>
<li>✅ 数据持久化与备份</li>
<li>✅ 日志切割与镜像清理</li>
</ul>

<p>这套方案可支撑中小型项目稳定运行。未来若需扩展微服务、K8s 等，亦可基于此基础演进。</p>
]]></content:encoded>
      <description><![CDATA[通过容器化技术实现环境一致性，自动构建镜像并分发至私有仓库，用 Docker Compose 编排服务，彻底告别环境依赖。]]></description>
      <category><![CDATA[Caddy]]></category>
      <category><![CDATA[Docker]]></category>
      <category><![CDATA[CI/CD]]></category>
      <dc:subject>P4</dc:subject>
      <dc:relation><![CDATA[series:deployment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[GitHub Actions + Docker 极简自动化部署教程（入门篇）]]></title>
      <link>https://moongate.top/docs/docker-quickstart-auto-deploy</link>
      <guid isPermaLink="true">fb702a74-215e-4f19-bcde-53486f3b10fe</guid>
      <pubDate>Mon, 16 Mar 2026 22:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共六篇，覆盖从静态网站到生产级 Docker 部署及服务集成的全流程：</p>

<ol>
<li><p><a href="./static-site-auto-deploy"><strong>静态网站自动化部署（静态篇）</strong></a>
—— 纯前端资源的自动化发布，Caddy 自动 HTTPS 和 SPA 路由支持。</p></li>

<li><p><a href="dynamic-site-auto-deploy" target="_blank"><strong>动态网站自动化部署（动态篇）</strong></a>
—— 后端服务进程管理、环境变量注入、数据库迁移，结合 Caddy 反向代理。</p></li>

<li><p><a href="docker-quickstart-auto-deploy" target="_blank"><strong>Docker 极简入门（入门篇）</strong></a>
—— 从零开始用 Docker + GitHub Actions 实现 CI/CD 流水线。</p></li>

<li><p><a href="docker-production-auto-deploy" target="_blank"><strong>Docker 生产级部署（进阶篇）</strong></a>
—— 多容器编排、健康检查、数据库迁移、自动 HTTPS，打造可靠的生产环境。</p></li>

<li><p><a href="./umami-integration-auto-deploy"><strong>自托管 Umami 分析服务与 Nuxt 4 项目集成指南（扩展篇）</strong></a>
—— 在现有 Docker 生产环境中集成 Umami 分析服务，实现自动化数据跟踪与安全加固。</p></li>

<li><p><a href="./vitepress-docker-existing-infrastructure-subdomain-deployment"><strong>VitePress 文档站接入已有 Docker 基础设施：子域名部署（扩展篇）</strong></a>
—— 将 VitePress 静态文档站作为子域名接入现有 Docker 基础设施，复用 Caddy 反向代理与网络。</p></li>
</ol>

<hr>

<p>本教程将带你用最简洁的方式，将你的应用打包成 Docker 镜像，并通过 GitHub Actions 自动部署到服务器。你将学会：</p>

<ul>
<li>编写一个极简的 Dockerfile</li>
<li>使用 docker-compose 管理应用 + 数据库</li>
<li>配置 GitHub Actions 实现 CI/CD</li>
<li>处理环境变量、端口冲突等常见问题</li>
</ul>

<blockquote>
<p><strong>适用人群</strong>：对 Docker 有基础了解，想快速搭建自动化部署的新手。</p>
</blockquote>

<hr>

<h2 id="最终效果">🚀 最终效果</h2>

<p>本地 <code>git push</code> → 自动构建镜像 → 推送到阿里云 ACR → 服务器自动拉取 → 服务重启 → 网站更新。</p>

<hr>

<h2 id="版本声明">📌 版本声明</h2>

<p>本文档所有工具均采用 <strong>2026 年最新稳定版</strong>，具体版本如下：</p>

<table>
<thead>
<tr>
<th>工具</th>
<th>版本</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td>Node.js</td>
<td>24.x</td>
<td>最新的主要版本，支持所有现代 JavaScript 特性</td>
</tr>

<tr>
<td>pnpm</td>
<td>10.x</td>
<td>高性能包管理器，与 Node.js 24 完美兼容</td>
</tr>

<tr>
<td>Docker Engine</td>
<td>29.x</td>
<td>容器运行时，支持 BuildKit 和多阶段构建</td>
</tr>

<tr>
<td>Docker Compose</td>
<td>v5</td>
<td>全新的 Compose 规范，支持 <code>name</code> 项目和 <code>depends_on</code> 条件</td>
</tr>

<tr>
<td>Caddy</td>
<td>2.8+</td>
<td>自动 HTTPS 的反向代理服务器</td>
</tr>

<tr>
<td>PostgreSQL</td>
<td>17 (alpine)</td>
<td>轻量级关系型数据库，alpine 版本镜像小巧</td>
</tr>

<tr>
<td>Drizzle ORM</td>
<td>0.30+</td>
<td>TypeScript 原生 ORM，支持迁移和类型安全查询</td>
</tr>

<tr>
<td>PM2</td>
<td>5+</td>
<td>生产级 Node.js 进程管理工具</td>
</tr>

<tr>
<td>GitHub Actions</td>
<td>最新</td>
<td>CI/CD 平台，所有 Action 均为当前最新版本（如 <code>checkout@v4</code>、<code>ssh-action@v1.0.0</code> 等）</td>
</tr>

<tr>
<td>阿里云 ACR</td>
<td>–</td>
<td>容器镜像服务，需使用固定密码进行认证</td>
</tr>
</tbody>
</table>

<blockquote>
<p><strong>注意</strong>：请根据你的项目实际需求调整具体版本号。若使用其他技术栈（如 Python、Java 等），请替换对应的运行时版本。</p>
</blockquote>

<hr>

<h2 id="第一步-准备项目">📦 第一步：准备项目</h2>

<h3 id="1-1-项目结构">1.1 项目结构</h3>

<pre><code class="language-text">my-app/
├── .github/workflows/deploy.yml   # GitHub Actions 配置
├── Dockerfile                     # 镜像构建文件
├── docker-compose.yml             # 容器编排文件
├── .env.example                   # 环境变量示例（用于参考，不提交）
└── ... 你的应用代码
</code></pre>

<h3 id="1-2-编写-dockerfile-以-node-js-为例">1.2 编写 Dockerfile（以 Node.js 为例）</h3>

<pre><code class="language-dockerfile"># 构建阶段
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build   # 根据项目调整构建命令，如 npm run generate

# 运行阶段
FROM node:24-alpine
WORKDIR /app
# 根据你的构建输出目录调整以下路径（常见：dist/、.output/、build/）
COPY --from=builder /app/.output ./.output   # 若使用 Nuxt
# 若使用其他框架，请替换为对应的输出目录，例如：
# COPY --from=builder /app/dist ./dist
EXPOSE 3000
# 启动命令也需对应调整，例如：
CMD [&quot;node&quot;, &quot;.output/server/index.mjs&quot;]
# 若使用 Express，可能是 CMD [&quot;node&quot;, &quot;server.js&quot;]
</code></pre>

<blockquote>
<p><strong>提示</strong>：请根据你的项目框架调整构建输出目录和启动命令。</p>
</blockquote>

<hr>

<h2 id="第二步-编写-docker-compose-yml">🛠️ 第二步：编写 docker-compose.yml</h2>

<p>创建一个包含应用和数据库的极简编排文件。<strong>注意</strong>：首次部署前，请确保服务器上已创建 <code>.env</code> 文件（见第五步）。</p>

<pre><code class="language-yaml">name: my-app

services:
  postgres:
    image: postgres:alpine
    container_name: my-app-db
    restart: always
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}&quot;]
      interval: 10s
      timeout: 5s
      retries: 5

  app:
    image: ${ACR_REGISTRY}/my-app:latest
    container_name: my-app
    restart: always
    ports:
      - &quot;3000:3000&quot; # 直接暴露端口（生产建议用反向代理）
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  postgres_data:
</code></pre>

<hr>

<h2 id="第三步-配置-github-secrets">🔐 第三步：配置 GitHub Secrets</h2>

<p>在 GitHub 仓库 Settings → Secrets and variables → Actions 中添加：</p>

<table>
<thead>
<tr>
<th>Secret 名称</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>ACR_REGISTRY</code></td>
<td>阿里云镜像仓库地址（例如 <code>crpi-xxx.cn-beijing.personal.cr.aliyuncs.com</code>，<strong>不要带 <code>https://</code></strong>）</td>
</tr>

<tr>
<td><code>ACR_USERNAME</code></td>
<td>阿里云账号（邮箱）</td>
</tr>

<tr>
<td><code>ACR_PASSWORD</code></td>
<td>阿里云容器镜像服务固定密码</td>
</tr>

<tr>
<td><code>SERVER_HOST</code></td>
<td>服务器公网 IP</td>
</tr>

<tr>
<td><code>SERVER_USER</code></td>
<td>SSH 用户名（如 <code>root</code> 或 <code>ubuntu</code>）</td>
</tr>

<tr>
<td><code>SSH_PRIVATE_KEY</code></td>
<td>服务器的 SSH 私钥（包含 <code>BEGIN</code> 和 <code>END</code> 行，保持完整换行）</td>
</tr>

<tr>
<td><code>POSTGRES_DB</code></td>
<td>数据库名</td>
</tr>

<tr>
<td><code>POSTGRES_USER</code></td>
<td>数据库用户</td>
</tr>

<tr>
<td><code>POSTGRES_PASSWORD</code></td>
<td>数据库密码</td>
</tr>
</tbody>
</table>

<blockquote>
<p><strong>⚠️ 重要</strong>：<code>.env</code> 文件包含敏感信息，<strong>切勿提交到 Git</strong>（已包含在 <code>.gitignore</code> 中）。首次部署前需手动在服务器上创建（见第五步）。</p>
</blockquote>

<hr>

<h2 id="第四步-创建-github-actions-工作流">⚙️ 第四步：创建 GitHub Actions 工作流</h2>

<p>在 <code>.github/workflows/deploy.yml</code> 中写入：</p>

<pre><code class="language-yaml">name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Login to ACR
        uses: docker/login-action@v3
        with:
          registry: ${{ secrets.ACR_REGISTRY }}
          username: ${{ secrets.ACR_USERNAME }}
          password: ${{ secrets.ACR_PASSWORD }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ${{ secrets.ACR_REGISTRY }}/my-app:latest

      - name: Deploy to server
        uses: appleboy/ssh-action@v1.0.0
        env:
          ACR_REGISTRY: ${{ secrets.ACR_REGISTRY }}
          ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
          ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
          POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
          POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
          POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          envs: ACR_REGISTRY, ACR_USERNAME, ACR_PASSWORD, POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD
          script: |
            cd /var/www/my-app
            # 写入环境变量（注意不要用单引号，否则变量不会展开）
            cat &gt; .env &lt;&lt; EOF
            POSTGRES_DB=$POSTGRES_DB
            POSTGRES_USER=$POSTGRES_USER
            POSTGRES_PASSWORD=$POSTGRES_PASSWORD
            ACR_REGISTRY=$ACR_REGISTRY
            EOF
            # 登录 ACR
            echo &quot;$ACR_PASSWORD&quot; | docker login &quot;$ACR_REGISTRY&quot; -u &quot;$ACR_USERNAME&quot; --password-stdin
            # 拉取新镜像
            docker compose pull app
            # 重启应用（若端口冲突可添加 --force-recreate）
            docker compose up -d --no-deps --force-recreate app
</code></pre>

<blockquote>
<p><strong>说明</strong>：使用 <code>--force-recreate</code> 确保旧容器被强制重新创建，避免端口冲突。若你的应用需要数据库迁移，请在重启前添加迁移命令，例如 <code>docker compose exec app npm run migrate</code>。</p>
</blockquote>

<hr>

<h2 id="第五步-服务器初始化-只需一次">🖥️ 第五步：服务器初始化（只需一次）</h2>

<h3 id="5-1-安装-docker-并配置镜像加速器">5.1 安装 Docker 并配置镜像加速器</h3>

<pre><code class="language-bash">curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# 重新登录或执行 newgrp docker 使组生效
# 配置阿里云镜像加速器（替换为你的加速地址）
sudo tee /etc/docker/daemon.json &lt;&lt;-'EOF'
{
  &quot;registry-mirrors&quot;: [&quot;https://your-mirror-id.mirror.aliyuncs.com&quot;]
}
EOF
sudo systemctl restart docker
</code></pre>

<h3 id="5-2-创建项目目录并准备环境">5.2 创建项目目录并准备环境</h3>

<pre><code class="language-bash">mkdir -p /var/www/my-app
cd /var/www/my-app
</code></pre>

<h3 id="5-3-首次部署-创建-env-文件并启动服务">5.3 首次部署：创建 <code>.env</code> 文件并启动服务</h3>

<p>由于首次部署时 GitHub Actions 还未运行，需要手动创建 <code>.env</code> 文件并启动一次服务，以便数据库初始化。</p>

<pre><code class="language-bash"># 根据你的实际值创建 .env 文件
cat &gt; .env &lt;&lt; EOF
POSTGRES_DB=myapp
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your-strong-password
ACR_REGISTRY=crpi-xxx.cn-beijing.personal.cr.aliyuncs.com
EOF
</code></pre>

<p>然后将本地的 <code>docker-compose.yml</code> 上传到该目录（例如使用 <code>scp</code>）：</p>

<pre><code class="language-bash"># 在本地执行
scp docker-compose.yml root@your-server:/var/www/my-app/
</code></pre>

<p>首次启动：</p>

<pre><code class="language-bash">docker compose up -d
</code></pre>

<blockquote>
<p><strong>注意</strong>：首次启动后，PostgreSQL 会创建数据卷，后续部署时数据不会丢失。</p>
</blockquote>

<h3 id="5-4-测试服务">5.4 测试服务</h3>

<pre><code class="language-bash">curl http://localhost:3000
# 或通过浏览器访问 http://你的服务器IP:3000
</code></pre>

<hr>

<h2 id="第六步-常见问题极简排查">🔍 第六步：常见问题极简排查</h2>

<table>
<thead>
<tr>
<th>现象</th>
<th>可能原因</th>
<th>解决</th>
</tr>
</thead>

<tbody>
<tr>
<td>Actions 中 SSH 连接失败</td>
<td>私钥格式错误 / 端口未开放</td>
<td>检查私钥是否包含完整换行；确保安全组开放 22 端口</td>
</tr>

<tr>
<td>容器启动失败，端口占用</td>
<td>宿主机有其他进程占用了 3000 端口</td>
<td><code>sudo lsof -i :3000</code> 找到进程并停止</td>
</tr>

<tr>
<td>应用无法连接数据库</td>
<td>环境变量 <code>DATABASE_URL</code> 错误</td>
<td>检查 <code>.env</code> 文件中的连接串，确认主机名为 <code>postgres</code>（服务名）</td>
</tr>

<tr>
<td>镜像拉取慢 / 超时</td>
<td>未配置镜像加速器</td>
<td>按 5.1 配置阿里云加速器并重启 Docker</td>
</tr>

<tr>
<td>容器重启后数据丢失</td>
<td>数据库卷未正确挂载</td>
<td>确认 <code>docker-compose.yml</code> 中有 <code>volumes</code> 定义，且数据卷存在</td>
</tr>

<tr>
<td>首次部署时数据库未初始化</td>
<td><code>.env</code> 文件缺失或变量错误</td>
<td>按 5.3 手动创建 <code>.env</code> 并再次执行 <code>docker compose up -d</code></td>
</tr>

<tr>
<td>应用更新后未生效</td>
<td>镜像未拉取或容器未重启</td>
<td>检查 Actions 日志；手动执行 <code>docker compose pull &amp;&amp; docker compose up -d</code></td>
</tr>
</tbody>
</table>

<hr>

<h2 id="附录-常用运维命令">📖 附录：常用运维命令</h2>

<pre><code class="language-bash"># 查看所有容器状态
docker compose ps

# 查看应用日志
docker compose logs -f app

# 进入应用容器内部调试
docker exec -it my-app sh

# 停止所有服务
docker compose down

# 重新构建并启动（如需重新构建镜像）
docker compose up -d --build
</code></pre>

<hr>

<h2 id="完成">🎉 完成</h2>

<p>现在你已经拥有了一套极简但可工作的自动化部署流水线。每次推送代码到 main 分支，都会自动构建镜像、推送到阿里云、并在服务器上重启应用。</p>

<p><strong>接下来可以探索</strong>：添加健康检查、使用 Caddy 反向代理（避免直接暴露端口）、多环境配置、数据库迁移自动化等进阶功能（参见本系列进阶篇）。</p>
]]></content:encoded>
      <description><![CDATA[从零开始，用最简洁的方式将你的应用打包成 Docker 镜像，并通过 GitHub Actions 实现自动构建、推送和服务器部署。适合 Docker 新手快速上手 CI/CD 流水线。]]></description>
      <category><![CDATA[Caddy]]></category>
      <category><![CDATA[Docker]]></category>
      <category><![CDATA[CI/CD]]></category>
      <dc:subject>P4</dc:subject>
      <dc:relation><![CDATA[series:deployment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[从零到一：为 Moongate 博客打造一个支持多级引用的评论区]]></title>
      <link>https://moongate.top/docs/nuxt-multi-level-replies</link>
      <guid isPermaLink="true">2f14097a-5218-4adb-8204-fca2f2eddc85</guid>
      <pubDate>Sun, 08 Mar 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<details>
<summary>📖 前言</summary>

如果你一路跟随我的系列教程，现在已经拥有了一个坚实的 Nuxt 4 项目基础：

- 通过[《Nuxt 4 集成 Drizzle ORM (PostgreSQL) 完整教程》](./nuxt-drizzle-postgresql.md)，你掌握了数据库的连接、模型定义与查询，为数据持久化铺平了道路。
- 在[《Nuxt 评论区完美支持 Markdown：从解析、高亮到安全渲染全攻略》](./nuxt-comment-markdown-guide.md)中，你学会了如何让用户输入的内容安全地支持 Markdown 和代码高亮。
- 而[《Nuxt 4 集成 GitHub 登录：从原理到实践》](./nuxt-oauth-github.md)则为你的应用添加了可靠的用户认证系统，确保只有真实用户才能参与互动。

现在，是时候将这些模块组合起来，打造一个真正**可用的、支持多级引用的评论区**了。本篇将基于上述基础，从数据库多态关联设计、后端 API 开发，到前端 Pinia 状态管理、组件交互打磨，一步步构建一个简洁但功能完备的评论系统。它不仅能处理常规的评论与回复，还支持**多级引用（引用的引用）**、**扁平时间线展示**、**点击引用块跳转并高亮**等实用功能，最终为你博客的读者提供一个沉浸式的讨论体验。

如果你尚未阅读前置教程，无需担心——我会在关键处说明引用，你也可以直接跟随本篇完成核心部分，待后续再补充细节。现在，让我们开始吧！🚀

</details>

<h2 id="代码说明">📌 代码说明</h2>

<p>本文所有代码均基于作者的项目环境编写，旨在清晰展示设计思路与核心实现。由于不同项目的配置（如数据库连接、环境变量、文件路径等）可能存在差异，请根据实际情况灵活调整。<strong>直接复制粘贴可能无法运行，理解原理后再动手，才是最高效的学习方式。</strong></p>

<h2 id="1-背景与需求">1. 背景与需求</h2>

<p>在个人技术博客中，评论区是连接作者与读者的重要桥梁。常见的评论区实现要么过于简单（仅支持一级评论），要么依赖第三方服务（如 Disqus），无法自由定制和掌控数据。Moongate 博客需要一个<strong>轻量、可定制、支持技术讨论深度</strong>的评论区，具体要求包括：</p>

<ul>
<li><strong>多级引用</strong>：读者可以针对某条评论或回复进行精确回应，形成对话链。</li>
<li><strong>扁平时间线</strong>：所有评论和回复按时间混合排列，避免视觉上的嵌套混乱。</li>
<li><strong>引用块跳转</strong>：点击引用内容可直接跳转到原评论并高亮，方便追溯上下文。</li>
<li><strong>用户认证</strong>：仅 GitHub 登录用户可发言，保证社区质量。</li>
<li><strong>响应式设计</strong>：在移动端同样有良好体验。</li>
</ul>

<p>技术栈基于 Nuxt v4、Vue 3、Pinia v3、Drizzle ORM 和 PostgreSQL v18，UI 层采用 Nuxt UI 组件库。</p>

<h2 id="2-技术选型与设计思路">2. 技术选型与设计思路</h2>

<h3 id="2-1-数据库设计-多态关联">2.1 数据库设计：多态关联</h3>

<p>传统的评论系统通常设计为“评论表 + 回复表”，回复通过外键指向所属评论。但这种结构无法支持“回复的回复”，即多级引用。为此我们选择了<strong>多态关联</strong>：</p>

<ul>
<li><strong>comments</strong> 表存储独立评论（根节点）。</li>
<li><strong>replies</strong> 表存储所有回复，使用 <code>target_id</code> + <code>target_type</code> 字段指向任意目标（评论或另一条回复）。</li>
</ul>

<p>这种设计灵活性极高，只需增加枚举类型 <code>target_type</code> 即可支持未来的扩展（如指向文章、用户等）。同时，使用 PostgreSQL 枚举确保数据完整性。</p>

<pre><code class="language-sql">CREATE TYPE target_type AS ENUM ('comment', 'reply');

CREATE TABLE replies (
  id SERIAL PRIMARY KEY,
  target_id INTEGER NOT NULL,
  target_type target_type NOT NULL DEFAULT 'comment',
  user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
  content TEXT NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_replies_target ON replies(target_id, target_type);
</code></pre>

<h3 id="2-2-扁平时间线-vs-嵌套展示">2.2 扁平时间线 vs 嵌套展示</h3>

<p>传统嵌套回复（如楼中楼）在视觉上会随着深度增加而不断缩进，导致界面复杂，且阅读长对话链时容易迷失。扁平时间线将所有条目（评论和回复）按创建时间统一排序，每条回复通过引用块表明针对的对象。这样既保持了上下文的连贯性，又让界面干净清爽。</p>

<h3 id="2-3-引用块设计">2.3 引用块设计</h3>

<p>回复内容上方显示引用块，格式为 <code>@用户名: 摘要</code>，左侧用细边框线视觉弱化，但保留可点击性。用户点击引用块可跳转到被引用的原内容并高亮，利用现代 CSS 特性 <code>color-mix</code> 实现柔和的高亮效果。</p>

<h2 id="3-后端实现">3. 后端实现</h2>

<h3 id="3-1-数据库-schema-与关系">3.1 数据库 Schema 与关系</h3>

<p>使用 Drizzle ORM 定义表和关系。由于多态关联的特殊性，我们放弃在 ORM 中定义复杂的关系，而是在业务代码中手动组装数据，保证灵活性和可读性。</p>

<details>
<summary>查看完整 schema</summary>

**`server/db/schema/comments.ts`**

```ts
import {
  pgTable,
  serial,
  varchar,
  timestamp,
  integer,
  text,
} from "drizzle-orm/pg-core";
import { users } from "./users";

export const comments = pgTable("comments", {
  id: serial("id").primaryKey(),
  user_id: integer("user_id").references(() => users.id, {
    onDelete: "set null",
  }),
  content: text("content").notNull(),
  permalink: varchar("permalink", { length: 255 }).notNull(),
  created_at: timestamp("created_at", { withTimezone: true }).defaultNow(),
});

export type CommentSelect = typeof comments.$inferSelect;
export type CommentInsert = typeof comments.$inferInsert;
```

**`server/db/schema/replies.ts`**（含枚举）

```ts
import {
  pgEnum,
  pgTable,
  serial,
  integer,
  text,
  timestamp,
} from "drizzle-orm/pg-core";
import { users } from "./users";

export const targetTypeEnum = pgEnum("target_type", ["comment", "reply"]);

export const replies = pgTable("replies", {
  id: serial("id").primaryKey(),
  target_id: integer("target_id").notNull(),
  target_type: targetTypeEnum("target_type").notNull().default("comment"),
  user_id: integer("user_id").references(() => users.id, {
    onDelete: "set null",
  }),
  content: text("content").notNull(),
  created_at: timestamp("created_at", { withTimezone: true }).defaultNow(),
});

export type ReplySelect = typeof replies.$inferSelect;
export type ReplyInsert = typeof replies.$inferInsert;
```

**`server/db/schema/users.ts`**

```ts
import {
  pgTable,
  serial,
  varchar,
  boolean,
  timestamp,
} from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: serial("id").primaryKey(),
  github_id: varchar("github_id", { length: 39 }).notNull().unique(),
  username: varchar("username", { length: 100 }).notNull(),
  is_admin: boolean("is_admin").default(false),
  created_at: timestamp("created_at", { withTimezone: true }).defaultNow(),
});

export type UserSelect = typeof users.$inferSelect;
export type UserInsert = typeof users.$inferInsert;
```

</details>

<details>
<summary>查看完整关系表</summary>

```ts
import { relations } from "drizzle-orm";
import { users, comments, replies } from "./index";

// comments 表的关系
export const commentsRelations = relations(comments, ({ one, many }) => ({
  user: one(users, {
    fields: [comments.user_id],
    references: [users.id],
  }),
  // 指向此评论的回复（通过 target_id 和 target_type 筛选）
  // 注意：这只是一个定义，实际查询时需在 where 中添加 target_type = 'comment'
  repliesFrom: many(replies, {
    relationName: "commentTarget",
  }),
}));

// users 表的关系（不变）
export const usersRelations = relations(users, ({ many }) => ({
  comments: many(comments),
  replies: many(replies),
}));

// replies 表的关系
export const repliesRelations = relations(replies, ({ one }) => ({
  user: one(users, {
    fields: [replies.user_id],
    references: [users.id],
  }),
  // 当 target_type = 'comment' 时，指向被引用的评论
  targetComment: one(comments, {
    fields: [replies.target_id],
    references: [comments.id],
    relationName: "commentTarget", // 与 commentsRelations 中的 repliesFrom 对应
  }),
  // 当 target_type = 'reply' 时，指向被引用的回复
  targetReply: one(replies, {
    fields: [replies.target_id],
    references: [replies.id],
    relationName: "replyTarget",
  }),
}));
```

</details>

<h3 id="3-2-获取时间线接口">3.2 获取时间线接口</h3>

<p>该接口需要返回当前文章的所有评论和回复，并为每条回复附上被引用内容的摘要（<code>reply_to</code>）。我们采用两步查询：先获取所有评论，再获取所有回复，然后在内存中组装并排序。</p>

<p><strong><code>server/api/comment/timeline.get.ts</code></strong></p>

<details>
<summary>完整的获取时间线接口代码</summary>

```ts
import { eq, sql } from "drizzle-orm";
import { useDB } from "~~/server/db";
import { comments, replies, users } from "~~/server/db/schema";

export default defineEventHandler(async (event) => {
  const { permalink } = getQuery(event);
  if (!permalink)
    throw createError({ status: 400, statusText: "缺少 permalink" });

  const db = useDB();

  // 获取所有评论
  const commentsData = await db
    .select({
      id: comments.id,
      content: comments.content,
      user_id: comments.user_id,
      created_at: comments.created_at,
      user: { username: users.username, is_admin: users.is_admin },
    })
    .from(comments)
    .leftJoin(users, eq(comments.user_id, users.id))
    .where(eq(comments.permalink, permalink as string))
    .orderBy(comments.created_at);

  // 构建评论映射，供后续引用摘要使用
  const commentMap = new Map(
    commentsData.map((c) => [
      c.id,
      { content: c.content, username: c.user?.username },
    ]),
  );

  // 获取所有回复（限制属于当前文章）
  const repliesData = await db
    .select({
      id: replies.id,
      content: replies.content,
      user_id: replies.user_id,
      created_at: replies.created_at,
      target_id: replies.target_id,
      target_type: replies.target_type,
      user: { username: users.username, is_admin: users.is_admin },
    })
    .from(replies)
    .leftJoin(users, eq(replies.user_id, users.id))
    .where(
      sql`${replies.target_id} IN (SELECT id FROM comments WHERE permalink = ${permalink})
                OR ${replies.target_id} IN (SELECT id FROM replies r2 WHERE r2.target_id IN (SELECT id FROM comments WHERE permalink = ${permalink}))`,
    )
    .orderBy(replies.created_at);

  // 构建回复映射
  const replyMap = new Map(
    repliesData.map((r) => [
      r.id,
      { content: r.content, username: r.user?.username },
    ]),
  );

  // 格式化评论
  const formattedComments = commentsData.map((c) => ({
    id: c.id,
    type: "comment" as const,
    content: c.content,
    user: c.user,
    created_at: c.created_at,
  }));

  // 格式化回复并添加引用摘要
  const formattedReplies = repliesData.map((r) => {
    const target =
      r.target_type === "comment"
        ? commentMap.get(r.target_id)
        : replyMap.get(r.target_id);

    return {
      id: r.id,
      type: "reply" as const,
      content: r.content,
      user: r.user,
      created_at: r.created_at,
      target_id: r.target_id,
      target_type: r.target_type,
      reply_to: target
        ? {
            id: r.target_id,
            type: r.target_type,
            username: target.username,
            excerpt:
              target.content.substring(0, 100) +
              (target.content.length > 100 ? "…" : ""),
          }
        : null,
    };
  });

  // 合并并按时间排序
  const timeline = [...formattedComments, ...formattedReplies].sort(
    (a, b) =>
      new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
  );

  return { success: true, data: timeline };
});
```

</details>

<h3 id="3-3-提交评论接口">3.3 提交评论接口</h3>

<p>简单地将用户输入插入 <code>comments</code> 表，返回新评论数据。</p>

<p><strong><code>server/api/comment/post.ts</code></strong>（略，可参考类似逻辑）</p>

<h3 id="3-4-提交回复接口">3.4 提交回复接口</h3>

<p>需要验证目标是否存在，并处理多态引用。注意使用 <code>createError</code> 抛出规范错误。</p>

<p><strong><code>server/api/reply/post.ts</code></strong></p>

<details>
<summary>查看完整的回复接口代码</summary>

```ts
import { eq } from "drizzle-orm";
import { useDB } from "~~/server/db";
import { replies, users, comments } from "~~/server/db/schema";

export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  const session = await getUserSession(event);

  // 参数校验
  if (
    !body.target_id ||
    !["comment", "reply"].includes(body.target_type) ||
    !body.content?.trim()
  ) {
    throw createError({ status: 400, statusText: "参数错误" });
  }
  if (!session.user?.id)
    throw createError({ status: 401, statusText: "请先登录" });

  const db = useDB();

  // 验证用户存在
  const user = await db.query.users.findFirst({
    where: eq(users.id, session.user.id),
  });
  if (!user) {
    await clearUserSession(event);
    throw createError({ status: 401, statusText: "用户不存在" });
  }

  // 验证目标存在
  if (body.target_type === "comment") {
    const comment = await db
      .select()
      .from(comments)
      .where(eq(comments.id, body.target_id))
      .limit(1);
    if (!comment.length)
      throw createError({ status: 404, statusText: "评论不存在" });
  } else {
    const reply = await db
      .select()
      .from(replies)
      .where(eq(replies.id, body.target_id))
      .limit(1);
    if (!reply.length)
      throw createError({ status: 404, statusText: "回复不存在" });
  }

  // 插入回复
  try {
    const [newReply] = await db
      .insert(replies)
      .values({
        user_id: user.id,
        target_id: body.target_id,
        target_type: body.target_type,
        content: body.content.trim(),
      })
      .returning();

    event.node.res.statusCode = 201;
    return { success: true, data: newReply };
  } catch (error) {
    console.error(error);
    throw createError({ status: 500, statusText: "服务器内部错误" });
  }
});
```

</details>

<h2 id="4-前端状态管理">4. 前端状态管理</h2>

<p>使用 Pinia 管理评论相关状态，包括当前输入内容、评论列表、加载状态等。</p>

<p><strong><code>stores/comment.ts</code></strong></p>

<details>
<summary>查看完整的pinia代码</summary>

```ts
import { defineStore } from "pinia";

export const useCommentStore = defineStore("comment", () => {
  const comment = ref(""); // 当前输入的评论内容
  const permalink = ref(""); // 当前文章标识
  const commentList = ref<any[]>([]); // 扁平时间线数据
  const loading = ref(false); // 获取列表加载状态
  const submitting = ref(false); // 提交评论/回复中

  const getCommentList = async (newPermalink?: string) => {
    if (newPermalink) permalink.value = newPermalink;
    if (!permalink.value) return;
    loading.value = true;
    try {
      const { data } = await $fetch("/api/comment/timeline", {
        query: { permalink: permalink.value },
      });
      commentList.value = data || [];
    } catch (error) {
      console.error("获取评论失败", error);
      commentList.value = [];
    } finally {
      loading.value = false;
    }
  };

  const submitComment = async () => {
    if (!comment.value.trim() || submitting.value) return false;
    submitting.value = true;
    try {
      const response = await $fetch("/api/comment/post", {
        method: "POST",
        body: { content: comment.value, permalink: permalink.value },
      });
      if (response.success) {
        comment.value = "";
        await getCommentList();
        return true;
      }
      return false;
    } catch (error) {
      console.error(error);
      return false;
    } finally {
      submitting.value = false;
    }
  };

  const submitReply = async (
    targetId: number,
    targetType: string,
    content: string,
  ) => {
    if (!content.trim() || submitting.value) return false;
    submitting.value = true;
    try {
      const response = await $fetch("/api/reply/post", {
        method: "POST",
        body: { target_id: targetId, target_type: targetType, content },
      });
      if (response.success) {
        await getCommentList();
        return true;
      }
      return false;
    } catch (error) {
      console.error(error);
      return false;
    } finally {
      submitting.value = false;
    }
  };

  return {
    comment,
    permalink,
    commentList,
    loading,
    submitting,
    getCommentList,
    submitComment,
    submitReply,
  };
});
```

</details>

<h2 id="5-前端组件实现">5. 前端组件实现</h2>

<h3 id="5-1-评论区容器组件">5.1 评论区容器组件</h3>

<p><strong><code>components/docs/CommentSection.vue</code></strong></p>

<details>
<summary>查看完整组件代码</summary>

```vue
<template>
  <details ref="containerRef" @toggle="onDetailsToggle">
    <summary class="text-center">{{ t("comment.section") }}</summary>

    <ClientOnly>
      <DocsCommentInputPreview
        v-model="commentStore.comment"
        :debounce-time="500"
        :permalink="prop.permalink"
        storage-type="none"
      />
    </ClientOnly>

    <div class="flex justify-end mb-8">
      <ClientOnly v-if="loggedIn">
        <UButton
          :disabled="!commentStore.comment.trim() || commentStore.submitting"
          :loading="commentStore.submitting"
          :label="t('comment.actions.send')"
          size="lg"
          @click="commentStore.submitComment()"
        />
      </ClientOnly>
      <div v-else class="flex items-center gap-2">
        <p>{{ t("comment.status.login_to_comment") }}</p>
        <SharedLogin />
      </div>
    </div>

    <div class="mt-4 min-h-50">
      <DocsCommentList v-if="commentStore.commentList.length" />
      <div v-else class="text-center">{{ t("comment.status.noComments") }}</div>
    </div>
  </details>
</template>

<script setup>
import { useCommentStore } from "~/stores/comment";
const commentStore = useCommentStore();
const { t } = useI18n();
const { containerRef, onDetailsToggle } = useDetailsScroll();
const { loggedIn } = useUserSession();

const prop = defineProps({ permalink: { type: String, required: true } });

watch(
  () => prop.permalink,
  (newPermalink) => {
    commentStore.getCommentList(newPermalink);
  },
  { immediate: true },
);
</script>
```

</details>

<h3 id="5-2-评论列表组件">5.2 评论列表组件</h3>

<p><strong><code>components/docs/CommentList.vue</code></strong></p>

<details>
<summary>查看完整组件代码</summary>

```vue
<template>
  <div class="max-h-150 overflow-y-auto mt-4 space-y-4">
    <div
      v-for="item in commentStore.commentList"
      :key="`${item.type}-${item.id}`"
      :id="`${item.type}-${item.id}`"
      class="group relative py-6 border-b border-ui-border/30 hover:bg-ui-bg-elevated/50 transition-colors"
    >
      <!-- 引用块（仅回复） -->
      <div
        v-if="item.type === 'reply' && item.reply_to"
        class="mb-2 pl-3 text-sm text-ui-text-muted/80 border-l-2 border-ui-border/40 hover:border-ui-primary/40 transition-colors cursor-pointer"
        @click="scrollToElement(item.reply_to.id, item.reply_to.type)"
      >
        <span class="font-medium">@{{ item.reply_to.username }}</span>
        <span class="italic ml-1">{{ item.reply_to.excerpt }}</span>
      </div>

      <!-- 作者信息 -->
      <div class="flex items-center gap-2 mb-1 text-xs">
        <span class="font-mono font-bold text-ui-text">{{
          item.user?.username
        }}</span>
        <span
          v-if="item.user?.is_admin"
          class="text-[9px] px-1 bg-ui-primary/10 text-ui-primary border border-ui-primary/20"
        >
          {{ t("comment.badge.admin") }}
        </span>
        <span class="text-ui-text-muted/60 text-[10px]">
          {{ dayjs(item.created_at).format("MM-DD HH:mm") }}
        </span>
      </div>

      <!-- 评论内容 -->
      <div
        class="text-ui-text/90 text-sm leading-relaxed break-words max-w-3xl"
      >
        <docsMarkdownRenderer :content="item.content" />
      </div>

      <!-- 回复按钮 -->
      <div class="flex justify-end mt-2">
        <button
          class="text-xs text-ui-text-muted/70 hover:text-ui-primary transition-colors cursor-pointer"
          @click="toggleReply(item.id, item.type)"
        >
          {{ t("comment.actions.reply") }}
        </button>
      </div>

      <!-- 回复输入框 -->
      <div
        v-if="replyingTo?.id === item.id && replyingTo?.type === item.type"
        class="mt-3 pt-3 border-t border-ui-border/20"
      >
        <DocsCommentInputPreview
          v-model="reply"
          :permalink="commentStore.permalink"
        />
        <div class="flex justify-end gap-2 mt-2">
          <UButton size="sm" variant="ghost" @click="cancelReply">
            {{ t("common.cancel") }}
          </UButton>
          <UButton
            size="sm"
            :disabled="!reply.trim() || commentStore.submitting"
            :loading="commentStore.submitting"
            @click="handleReply"
          >
            {{ t("comment.actions.send") }}
          </UButton>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import dayjs from 'dayjs';
import { useCommentStore } from '~/stores/comment';

const commentStore = useCommentStore();
const { user, loggedIn } = useUserSession();
const { t } = useI18n();

const replyingTo = ref<{ id: number; type: string } | null>(null);
const reply = ref('');

const toggleReply = (id: number, type: string) => {
  if (replyingTo.value?.id === id && replyingTo.value?.type === type) {
    replyingTo.value = null;
  } else {
    replyingTo.value = { id, type };
  }
  reply.value = '';
};

const cancelReply = () => {
  replyingTo.value = null;
  reply.value = '';
};

const handleReply = async () => {
  if (!replyingTo.value) return;
  const success = await commentStore.submitReply(
    replyingTo.value.id,
    replyingTo.value.type,
    reply.value
  );
  if (success) {
    cancelReply();
  }
};

const scrollToElement = (id: number, type: string) => {
  const el = document.getElementById(`${type}-${id}`);
  if (el) {
    el.scrollIntoView({ behavior: 'smooth', block: 'center' });
    el.classList.add('highlight-flash');
    setTimeout(() => el.classList.remove('highlight-flash'), 1000);
  }
};
</script>

<style scoped>
.highlight-flash {
  background-color: color-mix(in srgb, var(--ui-primary), transparent 90%);
  transition: background-color 0.3s ease;
}
</style>
```

</details>

<h3 id="5-3-输入预览组件">5.3 输入预览组件</h3>

<p><strong><code>components/docs/CommentInputPreview.vue</code></strong> 实现了带防抖的 Markdown 输入和预览。</p>

<details>
<summary>查看完整组件代码</summary>

```vue
<template>
  <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4 mb-6">
    <!-- 左侧预览 -->
    <div class="bg-ui-bg-elevated">
      <div class="text-xs font-mono text-ui-text-muted mb-2 tracking-wider">
        // {{ t("comment.input.preview") }}
      </div>
      <DocsMarkdownRenderer
        class="text-ui-text/90 text-base leading-relaxed"
        :content="localValue"
      />
    </div>

    <!-- 右侧输入 -->
    <div class="bg-ui-bg">
      <div class="text-xs font-mono text-ui-text-muted mb-2 tracking-wider">
        // {{ t("comment.input.input") }}
      </div>
      <UTextarea
        :model-value="localValue"
        autoresize
        :rows="5"
        variant="none"
        :placeholder="t('comment.input.placeholder')"
        class="w-full bg-transparent border-0 focus:ring-0 p-0 text-ui-text placeholder:text-ui-text-muted/50 font-mono text-sm"
        @update:model-value="(value) => handleInput(value)"
      />
    </div>
  </div>
</template>

<script lang="ts" setup>
import { useDebounceFn } from "@vueuse/core";
const { t } = useI18n();

const props = defineProps({
  modelValue: { type: String, default: "" }, // v-model 绑定的值
  debounceTime: { type: Number, default: 300 }, // 防抖延迟（毫秒），默认 300ms
  permalink: { type: String, required: true }, // 用于构建存储 key
  storageType: {
    type: String,
    default: "none",
    validator: (val: string) => ["session", "local", "none"].includes(val),
  },
});

const emit = defineEmits(["update:modelValue"]);

// 创建一个 ref 来存储本地输入值
const localValue = ref(props.modelValue);

// 监听父组件 prop 变化，同步到本地
watch(
  () => props.modelValue,
  (newVal) => {
    localValue.value = newVal;
  },
);

// 用防抖函数包装 emit
const debouncedEmit = useDebounceFn((value: string) => {
  emit("update:modelValue", value);
}, props.debounceTime);

// 当输入框的文本改变时
const handleInput = (value: string) => {
  localValue.value = value; // 立即更新预览
  debouncedEmit(value); // 防抖更新父组件
};
</script>
```

</details>

<h2 id="6-交互细节打磨">6. 交互细节打磨</h2>

<h3 id="6-1-防抖输入">6.1 防抖输入</h3>

<p>在 <code>CommentInputPreview</code> 中使用 <code>useDebounceFn</code> 实现用户停止输入 300ms 后才更新父组件，避免频繁请求。</p>

<h3 id="6-2-点击引用跳转并高亮">6.2 点击引用跳转并高亮</h3>

<p>如上代码所示，点击引用块时调用 <code>scrollToElement</code>，利用 <code>scrollIntoView</code> 平滑滚动到目标元素，并添加一个临时 CSS 类实现高亮。高亮采用 <code>color-mix</code> 生成半透明背景色，简洁现代。</p>

<h3 id="6-3-回复框的开关管理">6.3 回复框的开关管理</h3>

<p>每个条目独立控制回复框的展开/关闭，使用 <code>replyingTo</code> 记录目标，确保同时只能打开一个回复框，防止界面混乱。</p>

<h3 id="6-4-提交后自动刷新">6.4 提交后自动刷新</h3>

<p>提交评论或回复成功后，调用 <code>getCommentList</code> 刷新整个列表，确保数据一致性。</p>

<h2 id="7-总结与展望">7. 总结与展望</h2>

<p>至此，Moongate 博客拥有了一套功能完备、体验优雅的评论区系统。它不仅支持多级引用、扁平时间线、引用跳转高亮，还具备良好的响应式设计和用户体验。</p>

<p>未来计划：</p>

<ul>
<li>开源此评论系统，让更多开发者受益。</li>
<li>增加删除、编辑评论功能。</li>
<li>添加 @ 用户通知机制。</li>
</ul>

<p>通过本项目的实践，我们深刻体会到合理的数据设计和灵活的架构能为后续扩展打下坚实基础。希望这篇文章能为你自建评论区提供有价值的参考。如果你有任何问题或建议，欢迎在评论区留言。</p>
]]></content:encoded>
      <description><![CDATA[介绍了 Moongate 博客的评论区设计和实现，包括多级引用、扁平时间线、引用块跳转、用户认证、响应式设计等。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Security]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:comment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 集成 RSS 服务完全指南：从模块到手写的优雅之路]]></title>
      <link>https://moongate.top/docs/nuxt-rss-guide</link>
      <guid isPermaLink="true">48461e0d-b045-429d-92b8-36eac871d481</guid>
      <pubDate>Mon, 23 Feb 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="为什么需要-rss">为什么需要 RSS？</h2>

<p>在算法推荐泛滥的今天，RSS 依然是最纯粹的内容订阅方式。它让用户真正掌控自己获取信息的渠道，不受平台算法的干扰。为你的 Nuxt 博客添加 RSS 服务，不仅能提升用户体验，更是对开放互联网精神的致敬。</p>

<p>本文将带你从零开始，在 Nuxt 4 中实现一个<strong>完全可控、生产可用</strong>的 RSS 服务。我们将绕过第三方模块的潜在陷阱，亲手构建属于自己的 RSS 生成器。</p>

<blockquote>
<p>💡 <strong>前置要求</strong>：本文假设你已经熟悉 Nuxt 4 和 @nuxt/content v3 的基本使用，能够独立创建项目并配置 content 模块。如果你还不熟悉这些，建议先查阅官方文档。</p>
</blockquote>

<hr>

<h2 id="两种方案的对比">两种方案的对比</h2>

<h3 id="方案一-使用第三方模块-如-nuxt-feedme">方案一：使用第三方模块（如 <code>nuxt-feedme</code>）</h3>

<p><strong>看似简单</strong>：</p>

<pre><code class="language-ts">export default defineNuxtConfig({
  modules: [&quot;nuxt-feedme&quot;],
  feedme: {
    feeds: {
      &quot;/feed.xml&quot;: { type: &quot;rss2&quot; },
    },
  },
});
</code></pre>

<p><strong>实际可能遇到的坑</strong>：</p>

<ul>
<li>❌ 生产环境 API 404（模块试图调用不存在的开发接口）</li>
<li>❌ 文档老旧，与实际版本脱节</li>
<li>❌ 配置复杂，黑盒调试困难</li>
<li>❌ 钩子机制学习成本高</li>
<li>❌ 依赖更新可能导致兼容性问题</li>
</ul>

<h3 id="方案二-手写-rss-本文推荐">方案二：手写 RSS（本文推荐）</h3>

<p><strong>核心优势</strong>：</p>

<ul>
<li>✅ 完全可控，每一行代码都了然于心</li>
<li>✅ 无依赖，零兼容问题</li>
<li>✅ 调试简单，哪里错看哪里</li>
<li>✅ 性能极致，可按需优化</li>
<li>✅ 代码量少，维护成本极低</li>
</ul>

<hr>

<h2 id="rss-的本质-一句话说透">RSS 的本质：一句话说透</h2>

<pre><code class="language-ts">RSS = 获取数据 + 拼接 XML（或 JSON）
</code></pre>

<p>仅此而已。理解了这一点，你就掌握了 RSS 的全部奥秘。</p>

<hr>

<h2 id="完整实现步骤">完整实现步骤</h2>

<h3 id="1-环境配置">1. 环境配置</h3>

<p>在 <code>nuxt.config.ts</code> 中配置好运行时变量：</p>

<pre><code class="language-ts">export default defineNuxtConfig({
  modules: [&quot;@nuxt/content&quot;],
  runtimeConfig: {
    public: {
      siteUrl: process.env.SITE_URL || &quot;https://yourdomain.com&quot;,
      siteName: &quot;你的博客名称&quot;,
      siteDescription: &quot;博客描述&quot;,
    },
  },
});
</code></pre>

<h3 id="2-创建-minimarktree-转-html-工具函数">2. 创建 MinimarkTree 转 HTML 工具函数</h3>

<p>Nuxt Content v3 返回的 <code>doc.body.value</code> 是结构化的 MinimarkTree，需要转换为 HTML。函数会完整保留标签属性，确保图片、链接等元素正常显示。</p>

<pre><code class="language-ts">// utils/minimarkToHtml.ts

/**
 * 将 Nuxt Content v3 的 MinimarkTree 转换为 HTML 字符串
 * @param node - 文档 body 的 value 节点 (doc.body.value)
 * @returns HTML 字符串
 */
export function minimarkToHtml(node: any): string {
  if (!node) return &quot;&quot;;

  // 处理根节点
  if (node.type === &quot;minimark&quot; &amp;&amp; Array.isArray(node.value)) {
    return node.value.map(minimarkToHtml).join(&quot;&quot;);
  }

  // 文本节点
  if (typeof node === &quot;string&quot;) return node;

  // 数组节点
  if (Array.isArray(node)) {
    return node.map(minimarkToHtml).join(&quot;&quot;);
  }

  if (node &amp;&amp; typeof node === &quot;object&quot;) {
    // 元素节点（带标签）
    if (node.tag) {
      // 生成属性字符串
      const attrs = node.props
        ? &quot; &quot; +
          Object.entries(node.props)
            .map(
              ([key, val]) =&gt; `${key}=&quot;${String(val).replace(/&quot;/g, &quot;&amp;quot;&quot;)}&quot;`,
            )
            .join(&quot; &quot;)
        : &quot;&quot;;

      const children = (node.children || []).map(minimarkToHtml).join(&quot;&quot;);

      // 自闭合标签
      if ([&quot;img&quot;, &quot;br&quot;, &quot;hr&quot;, &quot;input&quot;].includes(node.tag)) {
        return `&lt;${node.tag}${attrs} /&gt;`;
      }
      return `&lt;${node.tag}${attrs}&gt;${children}&lt;/${node.tag}&gt;`;
    }
  }

  return &quot;&quot;;
}
</code></pre>

<h3 id="3-创建-rss-2-0-生成器">3. 创建 RSS 2.0 生成器</h3>

<pre><code class="language-ts">// server/routes/feed.xml.ts
import { minimarkToHtml } from &quot;~/utils/minimarkToHtml&quot;;

export default defineEventHandler(async (event) =&gt; {
  const { siteName, siteDescription, siteUrl } = useRuntimeConfig().public;

  const docs = await queryCollection(event, &quot;docs&quot;)
    .order(&quot;date&quot;, &quot;DESC&quot;)
    .all();

  let rss = `&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;rss version=&quot;2.0&quot; xmlns:content=&quot;http://purl.org/rss/1.0/modules/content/&quot;&gt;
  &lt;channel&gt;
    &lt;title&gt;${siteName}&lt;/title&gt;
    &lt;link&gt;${siteUrl}&lt;/link&gt;
    &lt;description&gt;${siteDescription}&lt;/description&gt;
    &lt;language&gt;zh-CN&lt;/language&gt;
    &lt;lastBuildDate&gt;${new Date().toUTCString()}&lt;/lastBuildDate&gt;
`;

  for (const doc of docs) {
    let fullContent = &quot;&quot;;
    if (doc.body?.value) {
      try {
        fullContent = minimarkToHtml(doc.body.value);
      } catch (e) {
        console.error(&quot;转换失败:&quot;, e);
        fullContent = doc.description || &quot;&quot;;
      }
    }

    const link = `${siteUrl}${doc.path}`;
    const date = new Date(doc.date).toUTCString();

    rss += `
    &lt;item&gt;
      &lt;title&gt;&lt;![CDATA[${doc.title}]]&gt;&lt;/title&gt;
      &lt;link&gt;${link}&lt;/link&gt;
      &lt;guid isPermaLink=&quot;true&quot;&gt;${link}&lt;/guid&gt;
      &lt;pubDate&gt;${date}&lt;/pubDate&gt;
      &lt;description&gt;&lt;![CDATA[${doc.description || &quot;&quot;}]]&gt;&lt;/description&gt;
      &lt;content:encoded&gt;&lt;![CDATA[${fullContent}]]&gt;&lt;/content:encoded&gt;
    &lt;/item&gt;
`;
  }

  rss += `
  &lt;/channel&gt;
&lt;/rss&gt;`;

  setResponseHeader(event, &quot;content-type&quot;, &quot;application/xml; charset=utf-8&quot;);
  return rss;
});
</code></pre>

<h3 id="4-创建-atom-1-0-生成器">4. 创建 Atom 1.0 生成器</h3>

<p>Atom 是另一种 XML 格式的订阅标准，结构更规范：</p>

<pre><code class="language-ts">// server/routes/feed.atom.ts
import { minimarkToHtml } from &quot;~/utils/minimarkToHtml&quot;;

export default defineEventHandler(async (event) =&gt; {
  const { siteName, siteDescription, siteUrl } = useRuntimeConfig().public;

  const docs = await queryCollection(event, &quot;docs&quot;)
    .order(&quot;date&quot;, &quot;DESC&quot;)
    .all();

  const updated = docs[0]?.date
    ? new Date(docs[0].date).toISOString()
    : new Date().toISOString();

  let atom = `&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;feed xmlns=&quot;http://www.w3.org/2005/Atom&quot;&gt;
  &lt;title&gt;${siteName}&lt;/title&gt;
  &lt;subtitle&gt;${siteDescription}&lt;/subtitle&gt;
  &lt;link href=&quot;${siteUrl}/feed.atom&quot; rel=&quot;self&quot;/&gt;
  &lt;link href=&quot;${siteUrl}&quot; rel=&quot;alternate&quot;/&gt;
  &lt;id&gt;${siteUrl}&lt;/id&gt;
  &lt;updated&gt;${updated}&lt;/updated&gt;
  &lt;author&gt;
    &lt;name&gt;MoonGate&lt;/name&gt;
  &lt;/author&gt;
`;

  for (const doc of docs) {
    let content = &quot;&quot;;
    if (doc.body?.value) {
      try {
        content = minimarkToHtml(doc.body.value);
      } catch (e) {
        console.error(&quot;转换失败:&quot;, e);
        content = doc.description || &quot;&quot;;
      }
    }

    const link = `${siteUrl}${doc.path}`;
    const published = new Date(doc.date).toISOString();

    atom += `
  &lt;entry&gt;
    &lt;title&gt;${doc.title}&lt;/title&gt;
    &lt;link href=&quot;${link}&quot;/&gt;
    &lt;id&gt;${link}&lt;/id&gt;
    &lt;published&gt;${published}&lt;/published&gt;
    &lt;updated&gt;${published}&lt;/updated&gt;
    &lt;summary&gt;${doc.description || &quot;&quot;}&lt;/summary&gt;
    &lt;content type=&quot;html&quot;&gt;&lt;![CDATA[${content}]]&gt;&lt;/content&gt;
  &lt;/entry&gt;
`;
  }

  atom += `\n&lt;/feed&gt;`;

  setResponseHeader(
    event,
    &quot;content-type&quot;,
    &quot;application/atom+xml; charset=utf-8&quot;,
  );
  return atom;
});
</code></pre>

<h3 id="5-创建-json-feed-1-1-生成器">5. 创建 JSON Feed 1.1 生成器</h3>

<p>JSON Feed 是现代化的订阅格式，结构清晰：</p>

<pre><code class="language-ts">// server/routes/feed.json.ts
import { minimarkToHtml } from &quot;~/utils/minimarkToHtml&quot;;

export default defineEventHandler(async (event) =&gt; {
  const { siteUrl } = useRuntimeConfig().public;

  const docs = await queryCollection(event, &quot;docs&quot;)
    .order(&quot;date&quot;, &quot;DESC&quot;)
    .all();

  const feed = {
    version: &quot;https://jsonfeed.org/version/1.1&quot;,
    title: &quot;MoonGate&quot;,
    home_page_url: siteUrl,
    feed_url: `${siteUrl}/feed.json`,
    description: &quot;Where Moon Meets Code&quot;,
    language: &quot;zh-CN&quot;,
    authors: [
      {
        name: &quot;MoonGate&quot;,
        url: siteUrl,
      },
    ],
    items: await Promise.all(
      docs.map(async (doc) =&gt; {
        let contentHtml = &quot;&quot;;
        if (doc.body?.value) {
          try {
            contentHtml = minimarkToHtml(doc.body.value);
          } catch (e) {
            console.error(&quot;转换失败:&quot;, e);
            contentHtml = doc.description || &quot;&quot;;
          }
        }

        return {
          id: `${siteUrl}${doc.path}`,
          url: `${siteUrl}${doc.path}`,
          title: doc.title,
          content_html: contentHtml,
          summary: doc.description || &quot;&quot;,
          date_published: new Date(doc.date).toISOString(),
          language: &quot;zh-CN&quot;,
          tags: doc.tags || [],
        };
      }),
    ),
  };

  setResponseHeader(
    event,
    &quot;content-type&quot;,
    &quot;application/feed+json; charset=utf-8&quot;,
  );
  return feed;
});
</code></pre>

<h3 id="6-添加缓存优化-可选">6. 添加缓存优化（可选）</h3>

<p>使用 Nuxt 内置的缓存处理器，减轻服务器压力：</p>

<pre><code class="language-ts">export default defineCachedEventHandler(
  async (event) =&gt; {
    // ... 上面的代码
  },
  {
    maxAge: 60 * 60, // 缓存 1 小时
    name: &quot;feed-cache&quot;,
    getKey: () =&gt; &quot;static&quot;, // 所有用户共享缓存
  },
);
</code></pre>

<h3 id="7-在网页中引入-rss-订阅">7. 在网页中引入 RSS 订阅</h3>

<p>在 <code>app.vue</code> 或布局文件中添加自动发现链接：</p>

<pre><code class="language-vue">// app.vue
&lt;script setup&gt;
const { siteName } = useRuntimeConfig().public;

useHead({
  link: [
    {
      rel: &quot;alternate&quot;,
      type: &quot;application/rss+xml&quot;,
      title: siteName,
      href: &quot;/feed.xml&quot;,
    },
    {
      rel: &quot;alternate&quot;,
      type: &quot;application/atom+xml&quot;,
      title: siteName,
      href: &quot;/feed.atom&quot;,
    },
    {
      rel: &quot;alternate&quot;,
      type: &quot;application/json&quot;,
      title: siteName,
      href: &quot;/feed.json&quot;,
    },
  ],
});
&lt;/script&gt;
</code></pre>

<hr>

<h2 id="验证与测试">验证与测试</h2>

<h3 id="本地测试">本地测试</h3>

<pre><code class="language-bash"># 启动服务
pnpm dev

# 测试三种格式
curl http://localhost:3000/feed.xml
curl http://localhost:3000/feed.atom
curl http://localhost:3000/feed.json

# 检查响应头
curl -I http://localhost:3000/feed.xml
</code></pre>

<hr>

<h2 id="常见问题排查">常见问题排查</h2>

<h3 id="q1-rss-显示-object-object">Q1: RSS 显示 <code>[object Object]</code></h3>

<p><strong>原因</strong>：没有将 <code>doc.body</code> 正确转换为 HTML。</p>

<p><strong>解决</strong>：使用本文提供的 <code>minimarkToHtml</code> 函数。</p>

<h3 id="q2-链接是相对路径-没有域名">Q2: 链接是相对路径，没有域名</h3>

<p><strong>原因</strong>：拼接 URL 时遗漏了 <code>siteUrl</code>。</p>

<p><strong>解决</strong>：确保使用 <code>${siteUrl}${doc.path}</code>。</p>

<h3 id="q3-日期格式错误">Q3: 日期格式错误</h3>

<p><strong>原因</strong>：直接使用了 ISO 字符串。</p>

<p><strong>解决</strong>：RSS 2.0 用 <code>new Date(date).toUTCString()</code>，Atom 和 JSON 用 <code>.toISOString()</code>。</p>

<h3 id="q4-生产环境-404">Q4: 生产环境 404</h3>

<p><strong>原因</strong>：<code>server/routes/</code> 下的文件未正确部署。</p>

<p><strong>解决</strong>：检查构建输出是否包含 <code>.output/server/</code> 目录。</p>

<h3 id="q5-json-feed-在浏览器中显示不全">Q5: JSON Feed 在浏览器中显示不全</h3>

<p><strong>原因</strong>：浏览器插件或开发者工具为了性能做了预览截断。</p>

<p><strong>解决</strong>：直接用 RSS 阅读器测试，或使用 <code>curl</code> 查看完整内容。</p>

<hr>

<h2 id="为什么手写比用模块更好">为什么手写比用模块更好？</h2>

<table>
<thead>
<tr>
<th>维度</th>
<th>第三方模块</th>
<th>手写方案</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>代码量</strong></td>
<td>配置复杂，还要写钩子</td>
<td>每个文件约 40 行，简单明了</td>
</tr>

<tr>
<td><strong>依赖</strong></td>
<td>多个间接依赖</td>
<td>零依赖</td>
</tr>

<tr>
<td><strong>学习成本</strong></td>
<td>高（要懂黑盒逻辑）</td>
<td>低（懂 RSS 格式即可）</td>
</tr>

<tr>
<td><strong>调试难度</strong></td>
<td>高（报错看不懂）</td>
<td>极低（哪里错看哪里）</td>
</tr>

<tr>
<td><strong>生产稳定性</strong></td>
<td>容易踩坑</td>
<td>稳定可靠</td>
</tr>

<tr>
<td><strong>维护成本</strong></td>
<td>依赖作者更新</td>
<td>自己掌控</td>
</tr>
</tbody>
</table>
<p><strong>记住</strong>：RSS 的本质就是“查数据 + 拼 XML/JSON”，没有任何复杂逻辑需要模块来封装。</p>

<hr>

<h2 id="进阶优化">进阶优化</h2>

<h3 id="1-分页限制">1. 分页限制</h3>

<pre><code class="language-ts">const docs = await queryCollection(event, &quot;docs&quot;)
  .order(&quot;date&quot;, &quot;DESC&quot;)
  .limit(20) // 只取最近 20 篇
  .all();
</code></pre>

<h3 id="2-自定义命名空间-rss-2-0">2. 自定义命名空间（RSS 2.0）</h3>

<pre><code class="language-ts">xmlns: media = &quot;http://search.yahoo.com/mrss/&quot;; // 支持媒体内容
xmlns: dc = &quot;http://purl.org/dc/elements/1.1/&quot;; // 支持 Dublin Core
</code></pre>

<h3 id="3-添加更多元数据">3. 添加更多元数据</h3>

<pre><code class="language-ts">// 在 JSON Feed 中添加
&quot;authors&quot;: [{ &quot;name&quot;: &quot;作者名&quot;, &quot;url&quot;: &quot;个人主页&quot; }],
&quot;language&quot;: &quot;zh-CN&quot;,
&quot;tags&quot;: [&quot;技术&quot;, &quot;前端&quot;]
</code></pre>

<hr>

<h2 id="结语">结语</h2>

<p>技术世界总是充满各种“开箱即用”的解决方案，但有时候，<strong>亲手构建一个简单功能所获得的理解和掌控感，远胜于使用复杂的第三方模块</strong>。</p>

<p>RSS 作为一个诞生了 20 多年的简单协议，其魅力就在于透明和可控。通过本文的手写方案，你不仅为博客添加了实用的功能，更深入理解了 Web 的本质。</p>

<p>现在，去享受自己动手的成果吧！🎉</p>
]]></content:encoded>
      <description><![CDATA[手把手教你绕过第三方模块的坑，亲手构建完全可控的 RSS/Atom/JSON Feed 服务。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[SEO]]></category>
      <dc:subject>P1</dc:subject>
      <dc:relation><![CDATA[series:ecosystem]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 评论区完美支持 Markdown：从解析、高亮到安全渲染]]></title>
      <link>https://moongate.top/docs/nuxt-comment-markdown-guide</link>
      <guid isPermaLink="true">85853cc1-2371-4b61-a6ef-90fda07a3116</guid>
      <pubDate>Sat, 21 Feb 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>内含与 Nuxt Content 配色保持一致的技巧，让你的评论区和文档浑然一体</p>
</blockquote>

<hr>

<h2 id="适用版本">📦 适用版本</h2>

<p>本文基于以下版本编写，请确保你的项目版本与之匹配：</p>

<table>
<thead>
<tr>
<th>依赖</th>
<th>版本</th>
<th>备注</th>
</tr>
</thead>

<tbody>
<tr>
<td>Nuxt</td>
<td><strong>v4</strong></td>
<td>核心框架</td>
</tr>

<tr>
<td>Nuxt Content</td>
<td><strong>v3</strong></td>
<td>文档内容管理</td>
</tr>

<tr>
<td>Nuxt UI</td>
<td><strong>v4</strong></td>
<td>提供 <code>useColorMode</code></td>
</tr>

<tr>
<td>marked</td>
<td><strong>v15+</strong></td>
<td>Markdown 解析器</td>
</tr>

<tr>
<td>shiki</td>
<td><strong>v3+</strong></td>
<td>代码高亮引擎</td>
</tr>

<tr>
<td>isomorphic-dompurify</td>
<td><strong>v2+</strong></td>
<td>XSS 防护</td>
</tr>
</tbody>
</table>

<blockquote>
<p>💡 如果你使用其他版本，核心思路仍可参考，但具体 API 可能需要调整。</p>
</blockquote>

<hr>

<details>
<summary>评论区原理</summary>

在开始之前，先聊聊评论区的本质。

很多人（包括我一开始）觉得评论区很复杂——要处理嵌套、要实时更新、要防攻击……但实际上，**一个最小可用的评论区，核心就是最简单的增删改查**：

- **增**：用户提交评论，存到数据库（`INSERT`）
- **删**：用户删除自己的评论（`DELETE`）
- **改**：编辑评论（`UPDATE`，可选）
- **查**：加载文档下的所有评论（`SELECT`）

没有算法、没有实时推送、没有复杂的机制——**就是最基础的后端操作 + 前端展示**。

> 评论区没那么可怕，它只是一个长得像对话框的 CRUD 而已。

本文就是在“增删改查”的基础上，给你的评论加上 **Markdown 渲染** 能力。如果你连基础的评论功能都还没做，可以先花 30 分钟搭一个简单的版本，再回来看本文。

</details>

<hr>

<details>
<summary>前置要求</summary>

本文默认读者已经具备以下能力：

- ✅ **能独立完成评论的基础 CRUD**（数据表设计、API 编写、前端展示）
- ✅ **熟悉 Vue / Nuxt 组件开发**（知道 `props`、`ref`、`watch` 怎么用）
- ✅ **了解 Markdown 基本语法**（知道 `**粗体**`、`` `代码` `` 是什么意思）
- ✅ **能自行查阅文档**（marked、Shiki、DOMPurify 的官网用法）

如果你还不具备这些，建议先补充基础：

- <a href="https://cn.vuejs.org/guide/introduction.html" target="_blank" rel="noopener noreferrer">Vue 3 中文官方文档</a>
- <a href="https://nuxt.zhcndoc.com/docs/4.x/getting-started/installation" target="_blank" rel="noopener noreferrer">Nuxt 4 中文官方文档</a>
- <a href="https://markdown.com.cn/intro.html" target="_blank" rel="noopener noreferrer">Markdown 中文官方文档</a>

**本文不会解释 SQL 怎么写、不会教 Vue 基础、不会重复官网 API——只讲“如何把评论区升级为 Markdown 渲染”。**

</details>

<hr>

<details>
<summary>为什么写这篇文档？</summary>

我在实现评论区 Markdown 功能时，搜遍全网发现：

- 官网文档：只给 API，不给实战
- 个人博客：要么复制粘贴，要么浅尝辄止
- 中文社区：全是其他平台的教程（WordPress、Typecho）

**没有一篇是专门针对 Nuxt 4 的、完整的、经过实战检验的评论区 Markdown 集成教程。**

所以我把自己折腾了七八个小时的过程写下来——包括踩过的坑、填过的土、以及那些“网上搜不到”的解决方案。希望能让后来的人少走些弯路。

</details>

<hr>

<details>
<summary>本文能给你什么</summary>

- ✅ **完整的 Markdown 渲染方案**（marked + Shiki + DOMPurify）
- ✅ **代码块高亮与文档配色统一**（深浅色自动切换）
- ✅ **XSS 防护的正确姿势**（不只是过滤标签）
- ✅ **两种性能方案对比**（预加载 vs 懒加载）
- ✅ **7 个实战踩坑记录**（`$` 陷阱、`watch` 监听、主题不匹配等）

</details>

<hr>

<h2 id="一-痛点与目标">一、痛点与目标</h2>

<p>许多 Nuxt 博主在搭建评论区时，会遇到以下问题：</p>

<ul>
<li>评论只能输入纯文本，无法贴代码、加粗、列表等。</li>
<li>即使勉强支持 Markdown，代码块样式与文档正文（通常由 Nuxt Content 渲染）不一致，显得格格不入。</li>
<li>担心 XSS 攻击，不敢直接渲染用户输入的 HTML。</li>
</ul>

<p><strong>本文目标</strong>：手把手教你为 Nuxt 博客评论区添加<strong>安全、美观、功能完整</strong>的 Markdown 渲染支持，并且代码块配色与文档（Nuxt Content v3）<strong>自动保持统一</strong>，深浅色模式无缝切换。</p>

<hr>

<h2 id="二-技术选型与原理">二、技术选型与原理</h2>

<table>
<thead>
<tr>
<th>需求</th>
<th>选型</th>
<th>理由</th>
</tr>
</thead>

<tbody>
<tr>
<td>Markdown → HTML</td>
<td><code>marked</code></td>
<td>轻量、快速、可扩展，支持 GFM，社区活跃</td>
</tr>

<tr>
<td>代码语法高亮</td>
<td><code>shiki</code></td>
<td>Nuxt Content 同款，主题丰富，输出稳定 HTML</td>
</tr>

<tr>
<td>XSS 防护</td>
<td><code>isomorphic-dompurify</code></td>
<td>SSR 兼容，过滤恶意标签和属性，保障安全</td>
</tr>

<tr>
<td>深浅色模式</td>
<td><code>useColorMode</code> (Nuxt UI)</td>
<td>自动监听系统/用户主题切换，动态更新渲染</td>
</tr>

<tr>
<td>代码块样式统一</td>
<td>CSS 变量 + 覆盖</td>
<td>复用 Nuxt UI 主题变量，使评论块与文档块视觉一致</td>
</tr>
</tbody>
</table>

<h3 id="为什么不用-prism-或-highlight-js">为什么不用 Prism 或 highlight.js？</h3>

<p>Shiki 与 Nuxt Content 内部使用的高亮器一致，可以直接复用其主题和语言包，确保代码块颜色与文档<strong>完全一致</strong>，无需额外维护两套配色。</p>

<blockquote>
<p><strong>主题与语言参考</strong>：Shiki 支持的主题和语言列表请查阅官方文档：<a href="https://shiki.zhcndoc.com/themes" target="_blank">主题列表</a> | <a href="https://shiki.zhcndoc.com/languages" target="_blank">语言列表</a>。你可以根据自己博客的实际配色需求，从中选择与 Nuxt Content 匹配的主题（例如 <code>material-theme-*</code> 系列）。</p>
</blockquote>

<hr>

<h2 id="三-逐步实现-附详细注释">三、逐步实现（附详细注释）</h2>

<h3 id="3-1-安装依赖">3.1 安装依赖</h3>

<pre><code class="language-bash">pnpm add -D shiki marked isomorphic-dompurify
# Nuxt UI v4 已内置 useColorMode，无需额外安装
</code></pre>

<h3 id="3-2-创建-shiki-插件-全局单例">3.2 创建 Shiki 插件（全局单例）</h3>

<p>Shiki 初始化较慢，且应只创建一次。我们通过 Nuxt 插件在客户端创建全局高亮器实例，供所有组件共享。</p>

<pre><code class="language-ts">// plugins/shiki.client.ts
import { createHighlighter } from &quot;shiki&quot;;

export default defineNuxtPlugin(async () =&gt; {
  // 预加载文档用到的主题和语言（可根据实际需求调整）
  // 主题列表：https://shiki.zhcndoc.com/themes
  // 语言列表：https://shiki.zhcndoc.com/languages
  const highlighter = await createHighlighter({
    themes: [&quot;material-theme-lighter&quot;, &quot;material-theme-palenight&quot;],
    langs: [
      &quot;javascript&quot;,
      &quot;typescript&quot;,
      &quot;html&quot;,
      &quot;css&quot;,
      &quot;vue&quot;,
      &quot;python&quot;,
      &quot;bash&quot;,
      &quot;json&quot;,
      &quot;markdown&quot;,
      &quot;xml&quot;,
      &quot;yaml&quot;,
      &quot;shell&quot;,
      &quot;diff&quot;,
    ],
  });

  return {
    provide: {
      shiki: highlighter, // 通过 $shiki 注入全局
    },
  };
});
</code></pre>

<h3 id="3-3-封装-markdown-渲染组件">3.3 封装 Markdown 渲染组件</h3>

<p>创建 <code>components/MarkdownRenderer.vue</code>，核心逻辑如下：</p>

<ul>
<li>使用 <code>marked.Renderer</code> 自定义代码块处理，交给 Shiki 高亮。</li>
<li>监听 <code>colorMode</code> 动态切换主题。</li>
<li>最后通过 DOMPurify 过滤输出。</li>
</ul>

<pre><code class="language-vue">&lt;template&gt;
  &lt;!-- eslint-disable-next-line vue/no-v-html --&gt;
  &lt;div v-html=&quot;renderedContent&quot; /&gt;
&lt;/template&gt;

&lt;script lang=&quot;ts&quot; setup&gt;
import { marked } from &quot;marked&quot;;
import DOMPurify from &quot;isomorphic-dompurify&quot;;

const props = defineProps({ content: { type: String, required: true } });

// 从 Nuxt 插件中获取全局 Shiki 高亮器实例（已在客户端插件中预加载主题和语言）
const { $shiki } = useNuxtApp();
const colorMode = useColorMode();

const renderedContent = ref(&quot;&quot;);

// 根据当前颜色模式动态选择 Shiki 主题，确保与文档代码块配色一致
const currentTheme = computed(() =&gt; {
  return colorMode.value === &quot;dark&quot;
    ? &quot;material-theme-palenight&quot; // 深色主题
    : &quot;material-theme-lighter&quot;; // 浅色主题
});

// 核心渲染函数：将用户输入的 Markdown 内容转换为安全的、高亮的 HTML
const renderContent = async () =&gt; {
  // 如果 Shiki 未就绪或内容为空，则直接显示原始内容（降级处理）
  if (!$shiki || !props.content) {
    renderedContent.value = props.content;
    return;
  }

  try {
    // ---------- 第一步：手动提取并高亮所有代码块 ----------
    let processed = props.content;
    // 正则匹配围栏代码块：```lang\n代码\n```（支持语言可选）
    const codeBlockRegex = /```([a-zA-Z0-9+#-]+)\n([\s\S]*?)```/g;
    const matches = [...processed.matchAll(codeBlockRegex)];

    for (const match of matches) {
      const [fullMatch, lang, code] = match;
      try {
        // 调用 Shiki 进行语法高亮，返回 HTML 字符串或包含 HTML 的对象
        const highlighted = $shiki.codeToHtml(code.trim(), {
          lang: lang || &quot;text&quot;, // 未指定语言时当作纯文本
          theme: currentTheme.value, // 使用当前主题
        });

        // 兼容 Shiki 不同版本的返回值（可能直接返回字符串，也可能返回 { html } 对象）
        const htmlStr =
          typeof highlighted === &quot;string&quot;
            ? highlighted
            : highlighted.html || highlighted.value || String(highlighted);

        // 用高亮后的 HTML 替换原始代码块（使用函数替换避免 $ 符号被转义）
        processed = processed.replace(fullMatch, () =&gt; htmlStr);
      } catch (e) {
        console.error(&quot;高亮失败:&quot;, e);
        // 高亮失败时保留原始代码块（不做高亮）
      }
    }

    // ---------- 第二步：将处理后的内容（代码块已替换）解析为 Markdown ----------
    const html = await marked.parse(processed, {
      breaks: true, // 将换行符转换为 &lt;br&gt;
      gfm: true, // 启用 GitHub 风格 Markdown（表格、删除线等）
    });

    // ---------- 第三步：使用 DOMPurify 过滤不安全内容，防止 XSS 攻击 ----------
    renderedContent.value = DOMPurify.sanitize(html, {
      // 明确允许的 HTML 标签（涵盖所有 Markdown 可能生成的标签）
      ALLOWED_TAGS: [
        &quot;p&quot;,
        &quot;br&quot;,
        &quot;strong&quot;,
        &quot;em&quot;,
        &quot;u&quot;,
        &quot;s&quot;,
        &quot;del&quot;,
        &quot;ins&quot;,
        &quot;span&quot;,
        &quot;div&quot;,
        &quot;h1&quot;,
        &quot;h2&quot;,
        &quot;h3&quot;,
        &quot;h4&quot;,
        &quot;h5&quot;,
        &quot;h6&quot;,
        &quot;ul&quot;,
        &quot;ol&quot;,
        &quot;li&quot;,
        &quot;a&quot;,
        &quot;blockquote&quot;,
        &quot;code&quot;,
        &quot;pre&quot;,
        &quot;table&quot;,
        &quot;thead&quot;,
        &quot;tbody&quot;,
        &quot;tr&quot;,
        &quot;th&quot;,
        &quot;td&quot;,
        &quot;hr&quot;,
        &quot;img&quot;,
        &quot;sub&quot;,
        &quot;sup&quot;,
      ],
      // 允许的属性（class/style 用于代码高亮样式，其他为链接、图片等常用属性）
      ALLOWED_ATTR: [
        &quot;class&quot;,
        &quot;style&quot;,
        &quot;href&quot;,
        &quot;lang&quot;,
        &quot;src&quot;,
        &quot;alt&quot;,
        &quot;title&quot;,
        &quot;target&quot;,
        &quot;rel&quot;,
      ],
      // 限制 URL 只能使用以下安全协议：
      // - http: / https: → 网页链接、图片链接（评论区核心需求）
      // - ftp: → 文件下载链接（极少出现，但保留无害）
      // - mailto: → 邮箱联系方式（偶尔有人留邮箱）
      // - tel: → 电话联系方式（虽少但保留）
      // - blob: → 临时文件/本地文件（为可能的图片上传预留）
      // - data: → base64 图片（用户直接贴 base64 图片时用）
      // 其他协议（如 javascript:、vbscript:、file: 等）一律拦截，防止 XSS 攻击
      ALLOWED_URI_REGEXP: /^(https?|ftp|mailto|tel|blob|data):/i,

      // 是否允许未在 ALLOWED_URI_REGEXP 中列出的协议：
      // - true  → 正则只作为“推荐列表”，未知协议可能被放行（不安全）
      // - false → 正则作为“强制列表”，只有列出的协议才允许（安全）
      // 评论区场景必须设置为 false，确保所有 URL 都经过协议白名单检查
      ALLOW_UNKNOWN_PROTOCOLS: false,
    });
  } catch (error) {
    console.error(&quot;渲染失败:&quot;, error);
    // 发生任何错误时，回退显示原始内容
    renderedContent.value = props.content;
  }
};

// 监听内容或主题变化，立即执行一次渲染，之后每次变化重新渲染
watch([() =&gt; props.content, () =&gt; colorMode.value], renderContent, {
  immediate: true,
});
&lt;/script&gt;

&lt;style scoped&gt;
/* 样式部分见 3.5 节，此处先省略 */
&lt;/style&gt;
</code></pre>

<h3 id="3-4-在评论区中使用">3.4 在评论区中使用</h3>

<p>在你的评论区组件（如 <code>CommentSection.vue</code>）中引入并使用：</p>

<pre><code class="language-vue">&lt;template&gt;
  &lt;div class=&quot;comments&quot;&gt;
    &lt;div v-for=&quot;comment in commentList&quot; :key=&quot;comment.id&quot;&gt;
      &lt;!-- 头像、用户名等 --&gt;
      &lt;MarkdownRenderer :content=&quot;comment.content&quot; /&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/template&gt;
</code></pre>

<h3 id="3-5-样式统一-让评论代码块与文档融为一体">3.5 样式统一：让评论代码块与文档融为一体</h3>

<p>Nuxt Content 默认代码块样式带有背景、边框和圆角。我们通过 CSS 变量（由 Nuxt UI 提供）覆盖评论区的 <code>&lt;pre&gt;</code> 和 <code>&lt;code&gt;</code> 样式，实现视觉统一。如果你未使用 Nuxt UI，可替换为具体的颜色值。</p>

<p>在 <code>MarkdownRenderer.vue</code> 的 <code>&lt;style scoped&gt;</code> 中添加：</p>

<pre><code class="language-css">&lt;style scoped&gt;
/* 代码块整体容器 */
:deep(pre) {
  padding: 1rem;
  background-color: var(--ui-bg-muted);  /* 不使用 !important，通过优先级覆盖 */
  border: 1px solid var(--ui-border);
  border-radius: var(--ui-radius);
  overflow-x: auto;
  margin: 1rem 0;
}

:deep(code) {
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 0.9em;
}

/* 行内代码样式 */
:deep(code:not(pre code)) {
  background-color: var(--ui-bg-muted);
  padding: 0.2em 0.4em;
  border-radius: var(--ui-radius-sm);
}

/* 移除 Shiki 可能自带的背景，避免双重背景 */
:deep(.shiki) {
  background-color: transparent !important; /* 这里仍需 !important 覆盖 Shiki 内联样式 */
}

/* 让 .line 元素正常显示，避免布局错乱 */
:deep(pre code .line) {
  display: contents !important;
}

/* 表格样式（与文档保持一致） */
:deep(table) {
  border-collapse: collapse;
  width: 100%;
  margin: 1rem 0;
}
:deep(th), :deep(td) {
  border: 1px solid var(--ui-border);
  padding: 0.5rem;
}
:deep(th) {
  background-color: var(--ui-bg-muted);
  font-weight: 600;
}

/* 引用块 */
:deep(blockquote) {
  border-left: 4px solid var(--ui-border);
  margin: 1rem 0;
  padding: 0.5rem 1rem;
  color: var(--ui-text-muted);
  background-color: var(--ui-bg-muted);
}

/* 列表 */
:deep(ul), :deep(ol) {
  padding-left: 2rem;
}

/* 链接 */
:deep(a) {
  color: var(--ui-primary);
  text-decoration: underline;
}
&lt;/style&gt;
</code></pre>

<blockquote>
<p><strong>提示</strong>：如果你的博客未使用 Nuxt UI，请检查文档代码块的实际样式（背景色、边框色、圆角等），然后将上述 <code>var(--ui-*)</code> 替换为对应的具体颜色值（如 <code>#f6f8fa</code>）。同时可酌情去掉 <code>!important</code>。</p>
</blockquote>

<hr>

<h2 id="四-踩坑与优化记录">四、踩坑与优化记录</h2>

<h3 id="4-1-主题名称不匹配">4.1 主题名称不匹配</h3>

<ul>
<li><strong>问题</strong>：按照 Nuxt Content 文档示例使用 <code>github-light/dark</code>，但实际默认主题可能是 <code>material-theme-lighter/palenight</code>，导致评论区配色与文档不一致。</li>
<li><strong>解决</strong>：打开浏览器检查文档代码块的 <code>&lt;pre&gt;</code> 标签，查看类名（如 <code>material-theme-lighter</code>），或在 <code>nuxt.config.ts</code> 中确认 <code>content.highlight.theme</code> 配置。然后在组件中替换为对应的主题 ID。</li>
</ul>

<h3 id="4-2-string-replace-的-陷阱">4.2 <code>String.replace</code> 的 <code>$</code> 陷阱</h3>

<ul>
<li><strong>问题</strong>：直接用 <code>replace(fullMatch, htmlStr)</code> 时，若 <code>htmlStr</code> 包含 <code>$</code>（如 Shiki 生成的样式），会被解释为特殊替换模式，导致 HTML 损坏。</li>
<li><strong>解决</strong>：使用 <code>replace(fullMatch, () =&gt; htmlStr)</code> 或 <code>replaceAll</code>，避免 <code>$</code> 被转义。</li>
</ul>

<h3 id="4-3-数据流断连">4.3 数据流断连</h3>

<ul>
<li><strong>问题</strong>：提交评论后列表不更新，或修改后的内容没存进数据库。</li>
<li><strong>解决</strong>：检查提交接口是否正确使用了处理后的 <code>contentToSave</code>，并确保评论列表组件监听了数据变化（如用 <code>refresh</code> 重新获取）。</li>
</ul>

<h3 id="4-4-性能优化-预加载-vs-懒加载">4.4 性能优化：预加载 vs 懒加载</h3>

<p>本文提供的方案（插件预加载主题和语言）适合大多数博客，因为评论中常见的语言通常有限。但如果你的博客涉及大量罕见语言，或对首屏加载体积极其敏感，可以考虑<strong>懒加载</strong>方案。</p>

<h4 id="方案一-预加载-当前方案">方案一：预加载（当前方案）</h4>

<ul>
<li>优点：代码块渲染速度最快，无额外异步延迟。</li>
<li>缺点：初始加载时会包含所有预置语言，体积稍大。</li>
</ul>

<h4 id="方案二-懒加载版本-基于手动正则提取-codetohtml">方案二：懒加载版本（基于手动正则提取 + <code>codeToHtml</code>）</h4>

<p>以下代码是经过实际验证的稳定方案，它不依赖任何 Nuxt 插件，直接在组件中使用 Shiki 的 <code>codeToHtml</code> 函数实现<strong>按需加载</strong>。该方案与方案一的核心区别在于：</p>

<ul>
<li><strong>无需创建全局插件</strong>，代码更轻量。</li>
<li>Shiki 的主题和语言在第一次使用时才加载，后续自动缓存，优化首屏体积。</li>
<li>保留了手动正则提取代码块的逻辑，确保参数类型安全，避免 marked 内部传递不确定对象的问题。</li>
</ul>

<pre><code class="language-vue">&lt;template&gt;
  &lt;!-- eslint-disable-next-line vue/no-v-html --&gt;
  &lt;div v-html=&quot;renderedContent&quot; /&gt;
&lt;/template&gt;

&lt;script lang=&quot;ts&quot; setup&gt;
import { marked } from &quot;marked&quot;;
import { codeToHtml } from &quot;shiki&quot;;
import DOMPurify from &quot;isomorphic-dompurify&quot;;

const props = defineProps({ content: { type: String, required: true } });
const colorMode = useColorMode();
const renderedContent = ref(&quot;&quot;);

// 根据当前颜色模式动态选择 Shiki 主题，确保与文档代码块配色一致
const currentTheme = computed(() =&gt; {
  return colorMode.value === &quot;dark&quot;
    ? &quot;material-theme-palenight&quot; // 深色主题（请根据你的实际主题替换）
    : &quot;material-theme-lighter&quot;; // 浅色主题（请根据你的实际主题替换）
});

// 核心渲染函数：将用户输入的 Markdown 内容转换为安全的、高亮的 HTML
const renderContent = async () =&gt; {
  if (!props.content) {
    renderedContent.value = &quot;&quot;;
    return;
  }

  try {
    // ---------- 第一步：手动提取并高亮所有代码块 ----------
    let processed = props.content;
    // 正则匹配围栏代码块：```lang\n代码\n```（支持语言可选）
    const codeBlockRegex = /```([a-zA-Z0-9+#-]+)\n([\s\S]*?)```/g;
    const matches = [...processed.matchAll(codeBlockRegex)];

    for (const match of matches) {
      const [fullMatch, lang, code] = match;
      try {
        // 调用 Shiki 进行语法高亮（懒加载，按需加载主题和语言）
        const highlighted = await codeToHtml(code.trim(), {
          lang: lang || &quot;text&quot;, // 未指定语言时当作纯文本
          theme: currentTheme.value, // 使用当前主题
        });

        // 兼容 Shiki 不同版本的返回值（可能直接返回字符串，也可能返回 { html } 对象）
        const htmlStr =
          typeof highlighted === &quot;string&quot;
            ? highlighted
            : highlighted.html || highlighted.value || String(highlighted);

        // 用高亮后的 HTML 替换原始代码块（使用函数替换避免 $ 符号被转义）
        processed = processed.replace(fullMatch, () =&gt; htmlStr);
      } catch (e) {
        console.error(&quot;代码块高亮失败:&quot;, e);
        // 高亮失败时保留原始代码块（不做高亮）
      }
    }

    // ---------- 第二步：将处理后的内容（代码块已替换）解析为 Markdown ----------
    const html = await marked.parse(processed, {
      breaks: true, // 将换行符转换为 &lt;br&gt;
      gfm: true, // 启用 GitHub 风格 Markdown（表格、删除线等）
    });

    // ---------- 第三步：使用 DOMPurify 过滤不安全内容，防止 XSS 攻击 ----------
    renderedContent.value = DOMPurify.sanitize(html, {
      // 明确允许的 HTML 标签（涵盖所有 Markdown 可能生成的标签）
      ALLOWED_TAGS: [
        &quot;p&quot;,
        &quot;br&quot;,
        &quot;strong&quot;,
        &quot;em&quot;,
        &quot;u&quot;,
        &quot;s&quot;,
        &quot;del&quot;,
        &quot;ins&quot;,
        &quot;span&quot;,
        &quot;div&quot;,
        &quot;h1&quot;,
        &quot;h2&quot;,
        &quot;h3&quot;,
        &quot;h4&quot;,
        &quot;h5&quot;,
        &quot;h6&quot;,
        &quot;ul&quot;,
        &quot;ol&quot;,
        &quot;li&quot;,
        &quot;a&quot;,
        &quot;blockquote&quot;,
        &quot;code&quot;,
        &quot;pre&quot;,
        &quot;table&quot;,
        &quot;thead&quot;,
        &quot;tbody&quot;,
        &quot;tr&quot;,
        &quot;th&quot;,
        &quot;td&quot;,
        &quot;hr&quot;,
        &quot;img&quot;,
        &quot;sub&quot;,
        &quot;sup&quot;,
      ],
      // 允许的属性（class/style 用于代码高亮样式，其他为链接、图片等常用属性）
      ALLOWED_ATTR: [
        &quot;class&quot;,
        &quot;style&quot;,
        &quot;href&quot;,
        &quot;lang&quot;,
        &quot;src&quot;,
        &quot;alt&quot;,
        &quot;title&quot;,
        &quot;target&quot;,
        &quot;rel&quot;,
      ],
      // 限制 URL 只能使用以下安全协议：
      // - http: / https: → 网页链接、图片链接（评论区核心需求）
      // - ftp: → 文件下载链接（极少出现，但保留无害）
      // - mailto: → 邮箱联系方式（偶尔有人留邮箱）
      // - tel: → 电话联系方式（虽少但保留）
      // - blob: → 临时文件/本地文件（为可能的图片上传预留）
      // - data: → base64 图片（用户直接贴 base64 图片时用）
      // 其他协议（如 javascript:、vbscript:、file: 等）一律拦截，防止 XSS 攻击
      ALLOWED_URI_REGEXP: /^(https?|ftp|mailto|tel|blob|data):/i,

      // 是否允许未在 ALLOWED_URI_REGEXP 中列出的协议：
      // - true  → 正则只作为“推荐列表”，未知协议可能被放行（不安全）
      // - false → 正则作为“强制列表”，只有列出的协议才允许（安全）
      // 评论区场景必须设置为 false，确保所有 URL 都经过协议白名单检查
      ALLOW_UNKNOWN_PROTOCOLS: false,
    });
  } catch (error) {
    console.error(&quot;Markdown 渲染失败:&quot;, error);
    // 发生任何错误时，回退显示原始内容
    renderedContent.value = props.content;
  }
};

// 监听内容或主题变化，立即执行一次渲染，之后每次变化重新渲染
watch([() =&gt; props.content, () =&gt; colorMode.value], renderContent, {
  immediate: true,
});
&lt;/script&gt;
</code></pre>

<h4 id="与方案一-插件预加载-的对比">与方案一（插件预加载）的对比</h4>

<table>
<thead>
<tr>
<th>特性</th>
<th>方案一（预加载插件）</th>
<th>方案二（懒加载 <code>codeToHtml</code>）</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>Shiki 实例创建</strong></td>
<td>通过插件全局创建一次，预加载所有主题和语言</td>
<td>直接在组件中调用 <code>codeToHtml</code>，按需加载</td>
</tr>

<tr>
<td><strong>初始加载体积</strong></td>
<td>包含所有预置语言，稍大</td>
<td>仅包含核心，语言在用到时才加载</td>
</tr>

<tr>
<td><strong>首次高亮速度</strong></td>
<td>无额外延迟</td>
<td>首次出现某语言时需等待加载（之后缓存）</td>
</tr>

<tr>
<td><strong>代码复杂度</strong></td>
<td>需要维护插件文件</td>
<td>组件内完成，无需额外文件</td>
</tr>

<tr>
<td><strong>适用场景</strong></td>
<td>评论语言种类固定，对渲染速度要求极高</td>
<td>语言种类多，追求首屏性能优化</td>
</tr>
</tbody>
</table>

<h3 id="使用说明">使用说明</h3>

<ol>
<li>删除原有的 <code>plugins/shiki.client.ts</code> 文件（如果存在）。</li>
<li>确保安装了 <code>shiki</code>、<code>marked</code>、<code>isomorphic-dompurify</code>。</li>
<li>根据你的博客实际配色，修改 <code>currentTheme</code> 中的主题 ID（参考 <a href="https://shiki.zhcndoc.com/themes" target="_blank">Shiki 主题列表</a>）。</li>
<li>将上述组件保存为 <code>MarkdownRenderer.vue</code>，并在评论区引入使用。</li>
</ol>

<p>该方案已在生产环境中验证，能稳定处理代码块高亮、主题切换、XSS 防护，并实现语言按需加载。</p>

<hr>

<h2 id="五-总结与扩展">五、总结与扩展</h2>

<p>至此，你拥有了一个功能完备、安全可靠的评论区，支持：</p>

<ul>
<li>完整的 Markdown 语法（标题、列表、表格、引用、图片等）</li>
<li>代码块语法高亮（与文档配色一致）</li>
<li>深浅色模式自动切换</li>
<li>XSS 防护</li>
</ul>

<p>在此基础上，你还可以继续扩展：</p>

<ul>
<li>添加评论回复功能</li>
<li>支持表情符号（如 <code>marked-emoji</code> 插件）</li>
<li>实时预览（Markdown 编辑器）</li>
</ul>

<hr>

<h2 id="附录-常见问题">附录：常见问题</h2>

<p><strong>Q：我用的不是 Nuxt UI，如何实现主题切换？</strong></p>

<p>A：可以使用 <code>@vueuse/core</code> 的 <code>usePreferredDark</code> 手动监听系统主题，动态改变 Shiki 的 <code>theme</code> 参数。</p>

<p><strong>Q：如何支持更多编程语言？</strong></p>

<p>A：语言标识符请参考 <a href="https://shiki.zhcndoc.com/languages" target="_blank">Shiki 官方语言列表</a>。Shiki 会自动加载所需语言，无需额外配置。若使用插件预加载，只需在 <code>langs</code> 数组中添加对应 ID。</p>

<p><strong>Q：渲染速度慢怎么办？</strong></p>

<p>A：如果选择预加载方案，确保 Shiki 实例全局单例（插件方式已满足）。如果选择懒加载方案，首次加载某种语言时会有短暂延迟，但之后会缓存。若评论数量极大，可考虑对代码块渲染做虚拟滚动。</p>

<p><strong>Q：如何确认文档实际使用的主题？</strong></p>

<p>A：打开浏览器开发者工具，选中文档中的一个代码块，查看 <code>&lt;pre&gt;</code> 或 <code>&lt;code&gt;</code> 标签的类名，通常包含主题名称（如 <code>material-theme-palenight</code>）。也可在 <code>nuxt.config.ts</code> 中查看 <code>content.highlight.theme</code> 配置。</p>

<hr>

<p>本文以“评论区 Markdown 渲染”为核心，详细介绍了从选型到落地的全过程，并融入了与 Nuxt Content 配色统一的技巧。希望能帮到你，也欢迎在评论区留言交流！</p>
]]></content:encoded>
      <description><![CDATA[手把手教你为 Nuxt 博客评论区添加安全、美观、功能完整的 Markdown 渲染支持，代码块配色与文档（Nuxt Content）自动统一，深浅色模式无缝切换。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Security]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:comment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 中 URL 与状态双向绑定指南：从原理到实践]]></title>
      <link>https://moongate.top/docs/nuxt-url-state-guide</link>
      <guid isPermaLink="true">1d0af69a-a000-4735-9db1-c09708338403</guid>
      <pubDate>Thu, 19 Feb 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>本文完整记录了在 Nuxt 4 中实现 URL 与页面状态双向同步的全过程，涵盖分页、搜索、多选标签、等级筛选等复杂场景，并深入探讨 SSR 安全、组件拆分陷阱、键盘事件与移动端手势的协同。文末提供两种可直接复用的实现方案（手写 watch 与 Pinia 封装），并附上生产环境验证过的踩坑总结。</p>
</blockquote>

<hr>

<h2 id="系列导航">📚 系列导航</h2>

<ol>
<li><p><a href="./nuxt-url-state-guide">Nuxt 中 URL 与状态双向绑定的终极指南（原理篇）</a>
—— 讲解 URL 与状态双向同步的原理与手写方案。</p></li>

<li><p><a href="./nuxt-use-route-query-composables">手写一个更适合 Nuxt 的 useRouteQuery（封装篇）</a>
—— 将重复逻辑封装成开箱即用的 composable。</p></li>

<li><p><a href="./nuxt-docs-list-page-complete-guide">从零到一：构建一个功能完备的文档列表页（实战篇）</a>
—— 综合运用前两篇的知识，实现完整的文档列表页。</p></li>

<li><p><a href="./nuxt-go-fullstack-closed-loop">Nuxt + Go 全栈实践：从 URL 状态到后端 API 的完整闭环</a>
—— 将前端 URL 状态与 Go 后端 API 打通，形成完整的数据流闭环。</p></li>
</ol>

<hr>

<h2 id="引言-一个看似简单的需求">引言：一个看似简单的需求</h2>

<p>在开发文档列表页时，我们通常需要支持<strong>分页</strong>和<strong>搜索</strong>。为了让用户能够通过链接分享当前页面状态，我们很自然地把页码、搜索词放到 URL query 里，例如 <code>/docs?page=2&amp;search=nuxt</code>。</p>

<p>这个需求看似简单，但实现后常遇到两个头疼的问题：</p>

<ol>
<li><strong>点击浏览器后退按钮，URL 变了，页面数据却没变。</strong></li>
<li><strong>直接修改 URL 参数回车，数据更新了，但输入框显示的还是旧值。</strong></li>
</ol>

<p>这些问题根源在于 <strong>内部状态与 URL 不同步</strong>。本文将带你从原理到实战，完整解决这个问题，并分享一次因盲目相信官方模块而踩坑的真实经历。</p>

<hr>

<h2 id="一-常见错误尝试-引以为戒">一、常见错误尝试（引以为戒）</h2>

<p>在进入正解之前，我们先看看一些常见的错误写法，以及它们为什么不行。</p>

<h3 id="错误-1-只监听分页推路由-不同步搜索词">❌ 错误 1：只监听分页推路由，不同步搜索词</h3>

<pre><code class="language-ts">watch([() =&gt; pagination.page, () =&gt; pagination.size], () =&gt; {
  router.push({ query: { page: pagination.page, size: pagination.size } });
});
</code></pre>

<p><strong>问题</strong>：如果 URL 中还有 <code>search</code> 参数，当用户点击返回按钮时，<code>route.query</code> 的 <code>search</code> 变了，但内部的 <code>searchValue</code> 没有更新，导致数据获取时使用的是旧搜索词。</p>

<h3 id="错误-2-在-useasyncdata-中手动调用-refresh">❌ 错误 2：在 <code>useAsyncData</code> 中手动调用 <code>refresh</code></h3>

<pre><code class="language-ts">const { refresh } = useAsyncData(...)
watch(() =&gt; route.query, () =&gt; {
  refresh()  // 手动刷新
})
</code></pre>

<p><strong>问题</strong>：<code>refresh</code> 会强制重新执行 fetcher，但如果你的 fetcher 内部依赖的响应式变量没有更新，可能还是旧数据。而且手动调用容易产生重复请求，破坏数据流的单向性。</p>

<h3 id="错误-3-useasyncdata-的-watch-依赖不全">❌ 错误 3：<code>useAsyncData</code> 的 <code>watch</code> 依赖不全</h3>

<pre><code class="language-ts">watch: [() =&gt; pagination.page, () =&gt; pagination.size]; // 漏了 searchValue
</code></pre>

<p><strong>问题</strong>：<code>searchValue</code> 变化时，<code>useAsyncData</code> 不会自动重新获取，数据与 URL 不匹配。</p>

<h3 id="错误-4-忽略数组参数的处理">❌ 错误 4：忽略数组参数的处理</h3>

<pre><code class="language-ts">// 假设 URL 中有 ?tag=Nuxt,Vue
const tags = ref(route.query.tag?.split(&quot;,&quot;)); // 如果 tag 不存在，会报错
</code></pre>

<p><strong>问题</strong>：没有处理 <code>undefined</code> 或数组格式（如 <code>?tag=Nuxt&amp;tag=Vue</code>），且序列化时未考虑数组。</p>

<h3 id="错误-5-在子组件中重复调用-useresponsive">❌ 错误 5：在子组件中重复调用 <code>useResponsive</code></h3>

<p><strong>问题</strong>：在 SSR 环境下，<code>isMobile</code> 等环境敏感值如果在子组件内重复调用，可能导致服务端和客户端判断不一致，引发水合失败。</p>

<hr>

<h2 id="二-理想方案-双向同步闭环">二、理想方案：双向同步闭环</h2>

<h3 id="核心思想">核心思想</h3>

<ul>
<li><strong>URL 是唯一真实源</strong>：所有影响数据的状态（page, size, search, level, tags 等）都应与 URL 同步。</li>
<li><strong><code>useAsyncData</code> 的 <code>watch</code> 自动刷新</strong>：列全依赖，无需手动调用 <code>refresh</code>。</li>
</ul>

<h3 id="2-1-双向同步的流程图">2.1 双向同步的流程图</h3>

<pre><code class="language-text">┌─────────────────────────────────────────────────────────────────┐
│                        双向同步闭环                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   用户操作                        浏览器后退/前进/直接修改URL    │
│      │                                   │                      │
│      ▼                                   ▼                      │
│  修改内部状态                    ┌─────────────────┐            │
│  (ref)                          │ route.query 变化 │            │
│      │                          └────────┬────────┘            │
│      ▼                                   │                      │
│  watch(状态) 触发                 watch(route.query) 触发        │
│      │                                   │                      │
│      └─────────────┬─────────────────────┘                      │
│                    ▼                                            │
│            router.push 更新 URL                                 │
│                    │                                            │
│                    ▼                                            │
│         useAsyncData 自动重新获取数据                           │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
</code></pre>

<h3 id="2-2-手写-watch-的核心原理">2.2 手写 watch 的核心原理</h3>

<pre><code class="language-ts">// 1. 从 URL 初始化内部状态
const route = useRoute();
const router = useRouter();

const searchInput = ref(route.query.search?.toString() || &quot;&quot;);
const searchOption = ref(Number(route.query.option) || 1);
const page = ref(Number(route.query.page) || 1);
const size = ref(Number(route.query.size) || 10);
const level = ref(route.query.level?.toString() || &quot;&quot;);
const viewMode = ref(Number(route.query.viewMode) || 1);
const tags = ref&lt;string[]&gt;([]);

// 解析 URL 中的数组参数（支持逗号分隔或重复键名）
const parseTagsFromQuery = () =&gt; {
  const tagParam = route.query.tag;
  tags.value = tagParam
    ? Array.isArray(tagParam)
      ? tagParam
      : tagParam.split(&quot;,&quot;)
    : [];
};
parseTagsFromQuery();

// 2. Watch 1：URL → 内部状态（处理后退/直接访问）
watch(
  () =&gt; route.query,
  (q) =&gt; {
    searchInput.value = q.search?.toString() || &quot;&quot;;
    searchOption.value = Number(q.option) || 1;
    page.value = Number(q.page) || 1;
    size.value = Number(q.size) || 10;
    level.value = q.level?.toString() || &quot;&quot;;
    viewMode.value = Number(q.viewMode) || 1;
    parseTagsFromQuery();
  },
  { immediate: true },
);

// 3. Watch 2：内部状态 → URL（用户操作时同步）
watch([searchInput, searchOption, page, size, level, viewMode, tags], () =&gt; {
  const query: Record&lt;string, string&gt; = {};
  if (searchInput.value) query.search = searchInput.value;
  if (searchOption.value !== 1) query.option = String(searchOption.value);
  if (page.value !== 1) query.page = String(page.value);
  if (size.value !== 10) query.size = String(size.value);
  if (level.value) query.level = level.value;
  if (viewMode.value !== 1) query.viewMode = String(viewMode.value);
  if (tags.value.length) query.tag = tags.value.join(&quot;,&quot;);

  // 避免无意义的重复跳转
  if (JSON.stringify(route.query) !== JSON.stringify(query)) {
    router.push({ query });
  }
});

// 4. 数据获取：useAsyncData 自动刷新
const { data } = useAsyncData(
  &quot;docs&quot;,
  async () =&gt; {
    // 使用当前状态构建查询
    let query = queryCollection(&quot;docs&quot;).order(&quot;date&quot;, &quot;DESC&quot;);
    if (searchInput.value) {
      /* ... */
    }
    if (level.value) query = query.where(&quot;level&quot;, &quot;=&quot;, level.value);
    if (tags.value.length) {
      tags.value.forEach((tag) =&gt; {
        query = query.where(&quot;tags&quot;, &quot;LIKE&quot;, `%${tag}%`);
      });
    }
    return query
      .skip((page.value - 1) * size.value)
      .limit(size.value)
      .all();
  },
  {
    watch: [searchInput, searchOption, page, size, level, viewMode, tags], // 直接监听 ref
  },
);
</code></pre>

<p><strong>关键点</strong>：</p>

<ul>
<li>数组参数（<code>tags</code>）在解析时兼容逗号分隔和重复键名，序列化时统一用逗号分隔。</li>
<li><code>watch</code> 中直接使用 ref 本身，确保数组内部变化（如 <code>push</code>/<code>pop</code>）能被正确捕获。</li>
<li>只将非默认值的参数写入 URL，保持 URL 简洁。</li>
</ul>

<hr>

<h2 id="三-官方捷径-一次尝试与回归">三、官方捷径？—— 一次尝试与回归</h2>

<p>在完成手写版本后，我了解到 <code>@vueuse/router</code> 提供了 <code>useRouteQuery</code> 这个工具，它可以用更少的代码实现类似功能：</p>

<pre><code class="language-ts">import { useRouteQuery } from &quot;@vueuse/router&quot;;
const search = useRouteQuery(&quot;search&quot;, &quot;&quot;);
const searchOption = useRouteQuery(&quot;option&quot;, 1, { transform: Number });
const page = useRouteQuery(&quot;page&quot;, 1, { transform: Number });
const size = useRouteQuery(&quot;size&quot;, 10, { transform: Number });
</code></pre>

<p>于是我用它重构了代码，开发环境一切正常。然而在部署到生产环境后，页面却返回了 500 错误：</p>

<pre><code class="language-text">Server Error
Invalid value used as weak map key
</code></pre>

<p>经过数小时的排查，我发现只要移除 <code>useRouteQuery</code> 相关代码，问题就消失。虽然我尝试了各种方法（升级版本、清理缓存、简化项目），但最终未能彻底查明原因。考虑到项目上线的时间压力，我决定放弃 <code>useRouteQuery</code>，回归到手写方案。</p>

<p>这个经历让我意识到：<strong>在生产环境中，稳定可控的方案往往比“看起来简洁”的方案更重要</strong>。手写方案虽然代码稍多，但每一行都在自己的掌控之中，排查问题也更容易。</p>

<blockquote>
<p><strong>后续思考</strong>：官方 <code>useRouteQuery</code> 的 <code>WeakMap</code> 批量更新机制在 SSR 中可能引发跨请求污染，而手写方案完全避免了全局状态。后来我封装了一套更适合自己项目的 <code>useRouteQueryString</code>、<code>useRouteQueryNumber</code> 等函数，具体见 <a href="./nuxt-use-route-query-composables.md">手写一个更适合 Nuxt 的 useRouteQuery：简化 URL 状态同步</a>。</p>
</blockquote>

<hr>

<h2 id="四-ssr-安全深度剖析">四、SSR 安全深度剖析</h2>

<p>在 Nuxt 中，水合失败（Hydration Mismatch）是常见问题。为了保证 SSR 安全，必须遵循以下原则：</p>

<ol>
<li><p><strong>初始状态必须从 URL 同步读取</strong><br>
所有影响 DOM 的状态（如 <code>page</code>、<code>tags</code>）应在组件顶层从 <code>route.query</code> 初始化，而不是在 <code>onMounted</code> 中从 <code>localStorage</code> 或客户端 API 获取。这样服务端和客户端第一次渲染时看到的初始值完全一致。</p></li>

<li><p><strong>环境敏感值（如 <code>isMobile</code>）只在根组件计算，通过 props 传递</strong><br>
如果在子组件中直接调用 <code>useResponsive</code>，由于服务端无法获取真实的屏幕宽度（<code>ssrWidth</code> 只是一个近似值），可能导致 <code>isMobile</code> 在服务端为 <code>false</code>，客户端为 <code>true</code>，从而引发 <code>v-if</code> 分支差异。正确做法是在根组件中计算一次，然后通过 props 向下传递。</p></li>

<li><p><strong>累积列表逻辑仅在客户端且 <code>page &gt; 1</code> 时执行</strong><br>
移动端无限滚动时，如果在水合阶段就合并数据，会导致服务端和客户端列表长度不一致。通过条件 <code>if (isMobile.value &amp;&amp; page.value &gt; 1)</code> 可以保证水合时不会执行合并，数据长度与服务端一致。</p></li>

<li><p><strong><code>useAsyncData</code> 的 <code>watch</code> 直接使用 ref</strong><br>
使用 <code>() =&gt; tags.value</code> 这种 getter 形式可能无法正确追踪数组内部变化，直接传入 <code>tags</code> 可确保 <code>tags</code> 数组的任何变化都能触发数据重新获取。</p></li>
</ol>

<hr>

<h2 id="五-与组件拆分时的陷阱">五、与组件拆分时的陷阱</h2>

<p>当你将页面拆分为多个子组件时，如果某个子组件需要 <code>isDesktop</code> 值，请务必从父组件传入，而不是在子组件内部再次调用 <code>useResponsive</code>。例如：</p>

<h3 id="父组件-index-vue">父组件（index.vue）</h3>

<pre><code class="language-vue">&lt;template&gt;
  &lt;TagFilter :is-desktop=&quot;isDesktop&quot; ... /&gt;
&lt;/template&gt;
&lt;script setup&gt;
const { isDesktop } = useResponsive();
&lt;/script&gt;
</code></pre>

<h3 id="子组件-tagfilter-vue">子组件（TagFilter.vue）</h3>

<pre><code class="language-vue">&lt;script setup&gt;
const props = defineProps([&quot;isDesktop&quot;]);
// 内部使用 props.isDesktop，不再调用 useResponsive
&lt;/script&gt;
</code></pre>

<p>这样可以保证服务端和客户端对 <code>isDesktop</code> 的判断一致，避免水合失败。</p>

<hr>

<h2 id="六-扩展场景-与键盘事件-移动端手势协同">六、扩展场景：与键盘事件、移动端手势协同</h2>

<p>URL 状态同步不仅适用于分页、搜索等基础操作，还能与用户交互无缝结合。</p>

<h3 id="6-1-键盘左右键翻页">6.1 键盘左右键翻页</h3>

<pre><code class="language-ts">useEventListener(&quot;keydown&quot;, (e) =&gt; {
  if (e.key === &quot;ArrowLeft&quot; &amp;&amp; hasPrevPage.value) {
    e.preventDefault();
    page.value -= 1; // page 变化会自动触发 URL 更新和数据刷新
  }
  // ...
});
</code></pre>

<h3 id="6-2-移动端无限滚动">6.2 移动端无限滚动</h3>

<pre><code class="language-ts">useSwipe({
  onUp: () =&gt; {
    if (hasNextPage.value) page.value += 1;
  },
});
</code></pre>

<p>这些交互只需修改 <code>page</code>、<code>tags</code> 等 ref，URL 和数据会自动同步，无需额外代码。</p>

<hr>

<h2 id="七-方案对比与选型建议">七、方案对比与选型建议</h2>

<table>
<thead>
<tr>
<th>方案</th>
<th>代码量</th>
<th>可维护性</th>
<th>SSR 安全</th>
<th>适用场景</th>
</tr>
</thead>

<tbody>
<tr>
<td>手写 watch</td>
<td>中等</td>
<td>高</td>
<td>✅ 安全</td>
<td>单页面，追求绝对控制</td>
</tr>

<tr>
<td>Pinia 封装</td>
<td>较多</td>
<td>最高</td>
<td>✅ 安全</td>
<td>多页面复用，工程化项目</td>
</tr>

<tr>
<td><code>useRouteQuery</code></td>
<td>最少</td>
<td>一般</td>
<td>⚠️需验证</td>
<td><strong>简单场景，但建议先测试</strong></td>
</tr>
</tbody>
</table>

<h3 id="pinia-封装示例-可折叠">Pinia 封装示例（可折叠）</h3>

<details>
<summary>点击展开 Pinia 方案代码</summary>

```ts
// stores/urlQuery.ts
import { defineStore } from "pinia";

export const useUrlQueryStore = defineStore("urlQuery", () => {
  const route = useRoute();
  const router = useRouter();

  const search = ref(route.query.search?.toString() || "");
  const option = ref(Number(route.query.option) || 1);
  const page = ref(Number(route.query.page) || 1);
  const size = ref(Number(route.query.size) || 10);
  const level = ref(route.query.level?.toString() || "");
  const viewMode = ref(Number(route.query.viewMode) || 1);
  const tags = ref<string[]>([]);

  const parseTags = () => {
    const tagParam = route.query.tag;
    tags.value = tagParam
      ? Array.isArray(tagParam)
        ? tagParam
        : tagParam.split(",")
      : [];
  };
  parseTags();

  watch(
    () => route.query,
    (q) => {
      search.value = q.search?.toString() || "";
      option.value = Number(q.option) || 1;
      page.value = Number(q.page) || 1;
      size.value = Number(q.size) || 10;
      level.value = q.level?.toString() || "";
      viewMode.value = Number(q.viewMode) || 1;
      parseTags();
    },
  );

  const pushQuery = () => {
    const query: Record<string, string> = {};
    if (search.value) query.search = search.value;
    if (option.value !== 1) query.option = String(option.value);
    if (page.value !== 1) query.page = String(page.value);
    if (size.value !== 10) query.size = String(size.value);
    if (level.value) query.level = level.value;
    if (viewMode.value !== 1) query.viewMode = String(viewMode.value);
    if (tags.value.length) query.tag = tags.value.join(",");

    if (JSON.stringify(route.query) !== JSON.stringify(query)) {
      router.push({ query });
    }
  };

  watch([search, option, page, size, level, viewMode, tags], () => pushQuery());

  return { search, option, page, size, level, viewMode, tags };
});
```

</details>

<hr>

<h2 id="八-与-localstorage-的对比">八、与 localStorage 的对比</h2>

<table>
<thead>
<tr>
<th>特性</th>
<th>URL Query</th>
<th>localStorage</th>
</tr>
</thead>

<tbody>
<tr>
<td>可分享</td>
<td>✅ 直接复制链接</td>
<td>❌ 无法分享</td>
</tr>

<tr>
<td>后退/前进</td>
<td>✅ 天然支持</td>
<td>❌ 需手动监听</td>
</tr>

<tr>
<td>SSR 可用</td>
<td>✅ 是</td>
<td>❌ 否</td>
</tr>

<tr>
<td>适用场景</td>
<td>分页、搜索、筛选</td>
<td>用户偏好（如主题）</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="九-一点感悟">九、一点感悟</h2>

<p>这次经历让我更深刻地体会到：<strong>在生产环境中，稳定性和可维护性往往比代码的简洁性更重要</strong>。手写方案虽然需要多写几行代码，但每一行都在自己的掌控之中，排查问题也更直接。同时，我也学会了在遇到诡异问题时，要有耐心逐步缩小范围，最终找到适合自己的解决方案。</p>

<blockquote>
<p>💡 <strong>如果你希望进一步简化代码</strong>，可以参考我封装的开箱即用版 <code>useRouteQueryString</code>、<code>useRouteQueryNumber</code>、<code>useRouteQueryArray</code>，详见 <a href="./nuxt-use-route-query-composables">《手写一个更适合 Nuxt 的 useRouteQuery》</a>。</p>
</blockquote>

<hr>

<h2 id="十-结语">十、结语</h2>

<p>本文从 URL 状态同步的常见问题出发，介绍了错误做法，给出了手写 watch 和 Pinia 两种稳定可靠的方案，并深入探讨了 SSR 安全、数组参数处理、组件拆分陷阱等实际工程中的难点。希望这些内容能帮助你在自己的项目中少走弯路。</p>

<blockquote>
<p>如果你需要持久化用户偏好（如主题、语言），可以参考我的文档<a href="./nuxt-state-persistence-guide">《Nuxt 4 中安全实现状态持久化》</a>。</p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[深入探讨 Nuxt 中 URL 与状态双向绑定的原理，解决后退按钮数据不刷新、输入框与 URL 不一致等常见问题。从错误尝试到正确实践，提供手写 watch 和 Pinia 两种稳定可靠的 SSR 安全方案，并对比与 localStorage 的适用场景。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[State Management]]></category>
      <dc:subject>P3</dc:subject>
      <dc:relation><![CDATA[series:url-state]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 图片引用：<NuxtImg> 替代原生 <img> 的一次实践]]></title>
      <link>https://moongate.top/docs/nuxt-image-best-practice</link>
      <guid isPermaLink="true">b798b8c6-f74b-4cf3-a5d9-de54c0e29669</guid>
      <pubDate>Tue, 17 Feb 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="从一次诡异的图片-404-错误说起">从一次诡异的图片 404 错误说起</h2>

<p>如果你在 Nuxt 项目中使用原生 <code>&lt;img&gt;</code> 标签引用图片，比如：</p>

<pre><code class="language-vue">&lt;img src=&quot;/images/ali-pay.jpg&quot; alt=&quot;支付宝赞赏码&quot; /&gt;
</code></pre>

<p>有没有遇到过这样的情况：</p>

<ul>
<li>在<strong>页面里</strong>直接写，图片正常显示 ✅</li>
<li>把同样的代码<strong>封装到组件里</strong>，图片突然不显示了 ❌</li>
<li>打开开发者工具，发现请求的 URL 变成了 <code>http://localhost:3000/&amp;/images/ali-pay.jpg</code></li>
<li>控制台报错：<code>No match found for location with path &quot;/&amp;/images/ali-pay.jpg&quot;</code></li>
</ul>

<p>我就在最近踩了这个坑。更诡异的是：</p>

<ul>
<li>强制 <code>&lt;details&gt;</code> 默认展开也报错 ❌</li>
<li>整个页面刷新后才正常显示 ✅</li>
<li>换成 <code>&lt;NuxtImg&gt;</code> 组件后，问题消失 ✅</li>
</ul>

<p>这篇文档不是要深挖这个错误的技术根源（因为可能涉及你的具体配置），而是要告诉你一个更重要的结论：</p>

<p><strong>在 Nuxt 项目中，永远优先使用 <code>&lt;NuxtImg&gt;</code> 而不是原生 <code>&lt;img&gt;</code>。</strong></p>

<hr>

<h2 id="原生-img-的隐患">📊 原生 <code>&lt;img&gt;</code> 的隐患</h2>

<table>
<thead>
<tr>
<th>场景</th>
<th>原生 <code>&lt;img&gt;</code></th>
<th><code>&lt;NuxtImg&gt;</code></th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>项目部署在子目录</strong>（如 <code>https://a.com/blog/</code>）</td>
<td>需要手动拼接 <code>baseURL</code></td>
<td>✅ 自动处理</td>
</tr>

<tr>
<td><strong>动态路由页面中使用</strong>（如 <code>[...slug].vue</code>）</td>
<td>可能路径解析错误</td>
<td>✅ 稳定可靠</td>
</tr>

<tr>
<td><strong>组件内使用</strong></td>
<td>可能受组件上下文影响</td>
<td>✅ 始终如一</td>
</tr>

<tr>
<td><strong>图片优化</strong></td>
<td>无</td>
<td>✅ 支持格式转换、尺寸调整、懒加载</td>
</tr>

<tr>
<td><strong>开发服务器热重载</strong></td>
<td>可能缓存问题</td>
<td>✅ 优化良好</td>
</tr>
</tbody>
</table>
<p>换句话说，原生 <code>&lt;img&gt;</code> 不是“不能用”，而是有太多“可能出问题”的场景，尤其是当你的项目稍微复杂一点时。</p>

<hr>

<h2 id="对比实验">🧪 对比实验</h2>

<h3 id="原生-img-组件版">❌ 原生 <code>&lt;img&gt;</code> 组件版</h3>

<pre><code class="language-vue">&lt;template&gt;
  &lt;details&gt;
    &lt;summary&gt;请我喝杯咖啡 ☕️&lt;/summary&gt;
    &lt;img src=&quot;/images/ali-pay.jpg&quot; alt=&quot;支付宝&quot; class=&quot;w-32&quot; /&gt;
  &lt;/details&gt;
&lt;/template&gt;
</code></pre>

<p><strong>结果</strong>：请求 <code>/&amp;/images/ali-pay.jpg</code> → 404</p>

<h3 id="nuxtimg-组件版">✅ <code>&lt;NuxtImg&gt;</code> 组件版</h3>

<pre><code class="language-vue">&lt;template&gt;
  &lt;details&gt;
    &lt;summary&gt;请我喝杯咖啡 ☕️&lt;/summary&gt;
    &lt;NuxtImg src=&quot;/images/ali-pay.jpg&quot; alt=&quot;支付宝&quot; class=&quot;w-32&quot; /&gt;
  &lt;/details&gt;
&lt;/template&gt;
</code></pre>

<p><strong>结果</strong>：请求 <code>/images/ali-pay.jpg</code> → 200 ✅</p>

<p>唯一的区别就是用了 <code>&lt;NuxtImg&gt;</code> 替换 <code>&lt;img&gt;</code>。</p>

<hr>

<h2 id="安装和使用">🔧 安装和使用</h2>

<h3 id="1-安装模块">1. 安装模块</h3>

<pre><code class="language-bash"># 使用 npm
npm install @nuxt/image
# 使用 pnpm
pnpm add @nuxt/image
</code></pre>

<h3 id="2-添加到-nuxt-config-ts">2. 添加到 <code>nuxt.config.ts</code></h3>

<pre><code class="language-typescript">export default defineNuxtConfig({
  modules: [&quot;@nuxt/image&quot;],
});
</code></pre>

<h3 id="3-在组件中使用">3. 在组件中使用</h3>

<pre><code class="language-vue">&lt;!-- 基础用法 --&gt;
&lt;NuxtImg src=&quot;/images/ali-pay.jpg&quot; alt=&quot;支付宝&quot; /&gt;
&lt;!-- 指定宽度，自动优化 --&gt;
&lt;NuxtImg src=&quot;/images/ali-pay.jpg&quot; width=&quot;200&quot; height=&quot;200&quot; /&gt;
&lt;!-- 响应式图片 --&gt;
&lt;NuxtImg src=&quot;/images/ali-pay.jpg&quot; sizes=&quot;sm:100vw md:50vw lg:400px&quot; /&gt;
&lt;!-- 懒加载 --&gt;
&lt;NuxtImg src=&quot;/images/ali-pay.jpg&quot; loading=&quot;lazy&quot; /&gt;
</code></pre>

<hr>

<h2 id="为什么-nuxtimg-更可靠">🎯 为什么 <code>&lt;NuxtImg&gt;</code> 更可靠</h2>

<h3 id="1-自动处理-baseurl">1. 自动处理 <code>baseURL</code></h3>

<p>如果你在 <code>nuxt.config.ts</code> 中配置了：</p>

<pre><code class="language-typescript">export default defineNuxtConfig({
  app: {
    baseURL: &quot;/blog/&quot;,
  },
});
</code></pre>

<ul>
<li>原生 <code>&lt;img src=&quot;/images/ali-pay.jpg&quot;&gt;</code> 会请求 <code>/blog/images/ali-pay.jpg</code>？<strong>不会</strong>，它还是请求 <code>/images/ali-pay.jpg</code>，404。</li>
<li><code>&lt;NuxtImg src=&quot;/images/ali-pay.jpg&quot;&gt;</code> 会自动加上 <code>/blog/</code> 前缀，请求正确地址。</li>
</ul>

<h3 id="2-内置图片优化服务">2. 内置图片优化服务</h3>

<p><code>@nuxt/image</code> 会在开发环境启动一个图片优化中间件，在生产环境生成优化后的图片：</p>

<ul>
<li>支持 WebP 等现代格式（自动根据浏览器选择）</li>
<li>支持图片裁剪、缩放</li>
<li>支持响应式图片（根据屏幕尺寸加载不同大小）</li>
<li>支持懒加载</li>
</ul>

<h3 id="3-构建时处理">3. 构建时处理</h3>

<p>生产构建时，<code>@nuxt/image</code> 会：</p>

<ul>
<li>将图片复制到输出目录</li>
<li>生成哈希化的文件名（<code>ali-pay.abc123.jpg</code>）</li>
<li>避免路径冲突和缓存问题</li>
</ul>

<h3 id="4-官方维护-社区验证">4. 官方维护，社区验证</h3>

<p>作为 Nuxt 官方模块，它经过了大量项目的测试，适配各种部署场景（Vercel、Netlify、自托管等）。</p>

<hr>

<h2 id="最佳实践总结">💡 最佳实践总结</h2>

<h3 id="推荐做法">✅ 推荐做法</h3>

<ol>
<li><strong>所有内部图片</strong>：使用 <code>&lt;NuxtImg&gt;</code> 或 <code>&lt;NuxtPicture&gt;</code></li>
<li><strong>外部图片</strong>（如 CDN）：配置 <code>provider</code> 后同样使用 <code>&lt;NuxtImg&gt;</code></li>
<li><strong>结合 <code>sizes</code> 属性</strong>：实现响应式图片，提升性能</li>
<li><strong>开启懒加载</strong>：对长页面的图片设置 <code>loading=&quot;lazy&quot;</code></li>
</ol>

<h3 id="避免的做法">❌ 避免的做法</h3>

<ol>
<li><strong>直接使用原生 <code>&lt;img&gt;</code></strong>，除非你有特殊理由</li>
<li><strong>手动拼接 <code>baseURL</code></strong>，交给 <code>&lt;NuxtImg&gt;</code> 处理</li>
<li><strong>引用 <code>public</code> 目录外的图片</strong>，保持项目结构清晰</li>
</ol>

<hr>

<h2 id="额外收获-图片优化带来的性能提升">🎁 额外收获：图片优化带来的性能提升</h2>

<p>除了解决路径问题，<code>@nuxt/image</code> 还能显著提升网站性能：</p>

<table>
<thead>
<tr>
<th>优化项</th>
<th>效果</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>格式转换</strong></td>
<td>自动将 JPEG/PNG 转为 WebP（节省 30-50% 体积）</td>
</tr>

<tr>
<td><strong>尺寸调整</strong></td>
<td>只加载当前视口需要的图片大小</td>
</tr>

<tr>
<td><strong>懒加载</strong></td>
<td>减少初始加载时间</td>
</tr>

<tr>
<td><strong>预连接</strong></td>
<td>可配置 CDN 预连接，加速加载</td>
</tr>
</tbody>
</table>
<p>用 Lighthouse 测试一下，你会发现图片相关分数明显提高。</p>

<hr>

<h2 id="结语">📝 结语</h2>

<p>那个让我研究了半小时的 <code>/&amp;/</code> 报错，最后被一个官方模块轻松解决。这让我意识到：</p>

<p><strong>在 Nuxt 项目里，能用官方模块解决的问题，就尽量不要自己手写。</strong></p>

<p><code>@nuxt/image</code> 不只是“图片组件”，它是 Nuxt 官方提供的一整套图片处理方案。安装它，不仅能避免各种诡异的路径问题，还能免费获得图片优化、响应式支持等高级功能。</p>

<p>如果你还没用上，现在就去装一个：</p>

<pre><code class="language-bash">npx nuxi@latest module add image
</code></pre>

<p>然后：</p>

<pre><code class="language-vue">&lt;NuxtImg src=&quot;/images/your-image.jpg&quot; /&gt;
</code></pre>

<p>世界清净了。</p>

<hr>

<p><strong>遇到过的坑</strong>：图片路径 <code>/&amp;/</code> 错误、组件内图片不显示、部署子目录后图片 404<br>
<strong>用过的方案</strong>：原生 <code>&lt;img&gt;</code>、显式 import、<code>&lt;NuxtImg&gt;</code><br>
<strong>最终的答案</strong>：永远优先用 <code>&lt;NuxtImg&gt;</code></p>
]]></content:encoded>
      <description><![CDATA[介绍了为什么在 Nuxt 项目中，永远优先使用 <NuxtImg> 而不是原生 <img>。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Image Optimization]]></category>
      <category><![CDATA[Performance]]></category>
      <dc:subject>P1</dc:subject>
      <dc:relation><![CDATA[series:performance]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 4 集成 Drizzle ORM (PostgreSQL) 完整教程]]></title>
      <link>https://moongate.top/docs/nuxt-drizzle-postgresql</link>
      <guid isPermaLink="true">9f906ca5-0c33-4467-ac12-092e5e204a99</guid>
      <pubDate>Mon, 16 Feb 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="适用版本">适用版本</h2>

<table>
<thead>
<tr>
<th>依赖</th>
<th>版本</th>
<th>备注</th>
</tr>
</thead>

<tbody>
<tr>
<td>Drizzle ORM</td>
<td><strong>v1.0.0-alpha.x</strong></td>
<td>本文基于 alpha.10，后续版本 API 可能微调</td>
</tr>

<tr>
<td>pg</td>
<td><strong>v8</strong></td>
<td>PostgreSQL 驱动</td>
</tr>
</tbody>
</table>

<blockquote>
<p>⚠️ <strong>注意</strong>：Drizzle ORM 目前仍处于 alpha 阶段，如果你使用更新版本，建议参考<a href="https://orm.drizzle.org.cn/" target="_blank">官方文档</a>。</p>
</blockquote>

<details>
<summary>📋教程范围与前置要求</summary>

本教程专注 **PostgreSQL + Nuxt 4** 的 Drizzle ORM 集成。如果您使用 MySQL/SQLite，驱动和类型会有差异，请参考官方文档相应部分。

**前置要求**：

- 已有一个 Nuxt 4 项目（`npm create nuxt@latest <project-name>`）
- 本地已安装 PostgreSQL（或使用云数据库）
- 了解 TypeScript 基础

</details>

<hr>

<h2 id="核心区别-drizzle-官方文档-vs-nuxt-集成">⚠️ 核心区别：Drizzle 官方文档 vs Nuxt 集成</h2>

<table>
<thead>
<tr>
<th>对比维度</th>
<th>Drizzle 官方文档</th>
<th>本教程（Nuxt 4）</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>项目类型</strong></td>
<td>普通 Node.js 项目</td>
<td>Nuxt 4（基于 Nitro 服务器）</td>
</tr>

<tr>
<td><strong>目录结构</strong></td>
<td>自由定义</td>
<td>严格遵循 <code>server/</code> 目录规范</td>
</tr>

<tr>
<td><strong>数据库连接</strong></td>
<td>直接导出 <code>db</code> 实例</td>
<td>通过 <code>server/db/index</code> 导出 <code>useDB()</code></td>
</tr>

<tr>
<td><strong>Schema 组织</strong></td>
<td>通常单个文件</td>
<td>建议拆分多文件，并<strong>必须包含关系定义</strong></td>
</tr>

<tr>
<td><strong>运行环境</strong></td>
<td>手动执行脚本</td>
<td>通过 API 路由触发，由 Nitro 管理</td>
</tr>
</tbody>
</table>
<p><strong>关键点</strong>：<strong>完全照搬官方文档会在 Nuxt 中失败</strong>，因为 Nuxt 的服务端目录结构和自动导入机制与普通 Node 项目不同。</p>

<hr>

<h2 id="第一步-安装依赖">📦 第一步：安装依赖</h2>

<pre><code class="language-bash"># 生产依赖
pnpm add drizzle-orm pg
# 开发依赖
pnpm add -D drizzle-kit @types/pg dotenv
</code></pre>

<table>
<thead>
<tr>
<th>包名</th>
<th>作用</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>drizzle-orm</code></td>
<td>ORM 核心，提供类型安全的查询</td>
</tr>

<tr>
<td><code>pg</code></td>
<td>PostgreSQL 驱动</td>
</tr>

<tr>
<td><code>drizzle-kit</code></td>
<td>迁移工具，自动生成 SQL</td>
</tr>

<tr>
<td><code>@types/pg</code></td>
<td>TypeScript 类型（用于 pg）</td>
</tr>

<tr>
<td><code>dotenv</code></td>
<td>开发时从 <code>.env</code> 加载环境变量</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="第二步-环境变量与配置">🔐 第二步：环境变量与配置</h2>

<h3 id="1-创建-env-文件-必须加入-gitignore">1. 创建 <code>.env</code> 文件（<strong>必须加入 <code>.gitignore</code></strong>）</h3>

<pre><code class="language-env"># .env
NUXT_DATABASE_URL=postgresql://postgres:yourpassword@localhost:5432/yourdb
</code></pre>

<blockquote>
<p>确保 URL 格式正确：<code>postgresql://用户名:密码@主机:端口/数据库名</code></p>
</blockquote>

<h3 id="2-配置-nuxt-config-ts">2. 配置 <code>nuxt.config.ts</code></h3>

<pre><code class="language-ts">export default defineNuxtConfig({
  runtimeConfig: {
    databaseUrl: process.env.NUXT_DATABASE_URL, // 无默认值，强制从环境变量读取
  },
  // ... 其他配置
});
</code></pre>

<blockquote>
<p><strong>重要</strong>：<code>databaseUrl</code> 必须从环境变量读取，不留默认值，避免生产环境误连本地数据库。</p>
</blockquote>

<hr>

<h2 id="第三步-组织-schema-文件-核心">📁 第三步：组织 Schema 文件（核心）</h2>

<p>Nuxt 项目中，建议将所有数据库相关文件放在 <code>server/db/</code> 下。<strong>必须同时包含表定义和关系定义</strong>。</p>

<h3 id="目录结构">目录结构</h3>

<pre><code class="language-text">server/
├── db/
│   ├── schema/
│   │   ├── users.ts
│   │   ├── comments.ts
│   │   ├── relations.ts
│   │   └── index.ts          # 统一导出
│   └── index.ts              # 数据库连接
</code></pre>

<h3 id="3-1-定义表-以-users-和-comments-为例">3.1 定义表（以 users 和 comments 为例）</h3>

<p><strong><code>server/db/schema/users.ts</code></strong></p>

<pre><code class="language-ts">import {
  pgTable,
  serial,
  varchar,
  boolean,
  timestamp,
} from &quot;drizzle-orm/pg-core&quot;;

export const users = pgTable(&quot;users&quot;, {
  id: serial(&quot;id&quot;).primaryKey(),
  githubId: varchar(&quot;github_id&quot;, { length: 39 }).notNull().unique(),
  username: varchar(&quot;username&quot;, { length: 100 }).notNull(),
  isAdmin: boolean(&quot;is_admin&quot;).default(false),
  createdAt: timestamp(&quot;created_at&quot;, { withTimezone: true }).defaultNow(),
});

export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
</code></pre>

<p><strong><code>server/db/schema/comments.ts</code></strong></p>

<pre><code class="language-ts">import {
  pgTable,
  serial,
  integer,
  text,
  varchar,
  timestamp,
} from &quot;drizzle-orm/pg-core&quot;;
import { users } from &quot;./users&quot;;

export const comments = pgTable(&quot;comments&quot;, {
  id: serial(&quot;id&quot;).primaryKey(),
  userId: integer(&quot;user_id&quot;).references(() =&gt; users.id, {
    onDelete: &quot;set null&quot;,
  }),
  content: text(&quot;content&quot;).notNull(),
  permalink: varchar(&quot;permalink&quot;, { length: 255 }).notNull(),
  parentId: integer(&quot;parent_id&quot;).references((): any =&gt; comments.id, {
    onDelete: &quot;cascade&quot;,
  }),
  createdAt: timestamp(&quot;created_at&quot;, { withTimezone: true }).defaultNow(),
});

export type Comment = typeof comments.$inferSelect;
export type NewComment = typeof comments.$inferInsert;
</code></pre>

<h3 id="3-2-定义关系-relations">3.2 定义关系（relations）</h3>

<p><strong><code>server/db/schema/relations.ts</code></strong></p>

<pre><code class="language-ts">import { relations } from &quot;drizzle-orm&quot;;
import { users } from &quot;./users&quot;;
import { comments } from &quot;./comments&quot;;

// 评论 -&gt; 用户（多对一）
export const commentsRelations = relations(comments, ({ one }) =&gt; ({
  user: one(users, {
    fields: [comments.userId],
    references: [users.id],
  }),
}));

// 用户 -&gt; 评论（一对多）
export const usersRelations = relations(users, ({ many }) =&gt; ({
  comments: many(comments),
}));
</code></pre>

<h3 id="3-3-统一导出">3.3 统一导出</h3>

<p><strong><code>server/db/schema/index.ts</code></strong></p>

<pre><code class="language-ts">export * from &quot;./users&quot;;
export * from &quot;./comments&quot;;
export * from &quot;./relations&quot;;
</code></pre>

<hr>

<h2 id="第四步-创建数据库连接工具">🔌 第四步：创建数据库连接工具</h2>

<p>Nuxt 中，数据库连接应放在 <code>server/db/</code> 下以便导入。</p>

<p><strong><code>server/db.ts</code></strong></p>

<pre><code class="language-ts">import { drizzle } from &quot;drizzle-orm/node-postgres&quot;;
import { Pool } from &quot;pg&quot;;
import * as schema from &quot;../db/schema&quot;; // 导入完整的 schema

const config = useRuntimeConfig();

const pool = new Pool({
  connectionString: config.databaseUrl,
});

// 导出函数，每次调用获取新连接（防止连接泄漏）
export const useDB = () =&gt; drizzle(pool, { schema });
</code></pre>

<blockquote>
<p><strong>注意</strong>：必须传入完整的 <code>schema</code> 对象（包含表和关系），否则无法使用 <code>with</code> 等关系查询。</p>
</blockquote>

<hr>

<h2 id="第五步-验证配置">🧪 第五步：验证配置</h2>

<p>创建测试 API 路由，确保一切正常。</p>

<p><strong><code>server/api/test/db.get.ts</code></strong></p>

<pre><code class="language-ts">import { sql } from &quot;drizzle-orm&quot;;

export default defineEventHandler(async (event) =&gt; {
  try {
    const db = useDB();
    const result = await db.execute(sql`SELECT 1+1 as result`);
    return { success: true, data: result.rows[0] };
  } catch (error) {
    console.error(&quot;DB connection failed:&quot;, error);
    return { success: false, error: String(error) };
  }
});
</code></pre>

<p>访问 <code>http://localhost:3000/api/test/db</code>，若返回 <code>{ result: 2 }</code> 则连接成功。</p>

<hr>

<h2 id="第六步-数据库迁移">📜 第六步：数据库迁移</h2>

<h3 id="6-1-配置-drizzle-config-ts">6.1 配置 <code>drizzle.config.ts</code></h3>

<p>在项目根目录创建：</p>

<pre><code class="language-ts">import &quot;dotenv/config&quot;;
import { defineConfig } from &quot;drizzle-kit&quot;;

export default defineConfig({
  out: &quot;./server/db/migrations&quot;,
  schema: &quot;./server/db/schema/index.ts&quot;, // 指向统一导出文件
  dialect: &quot;postgresql&quot;,
  dbCredentials: {
    url: process.env.NUXT_DATABASE_URL!,
  },
});
</code></pre>

<h3 id="6-2-生成迁移文件">6.2 生成迁移文件</h3>

<pre><code class="language-bash">npx drizzle-kit generate
</code></pre>

<p>这会在 <code>server/db/migrations</code> 生成 SQL 文件。</p>

<h3 id="6-3-执行迁移">6.3 执行迁移</h3>

<pre><code class="language-bash">npx drizzle-kit migrate
</code></pre>

<p><strong>开发环境</strong>也可以直接用 <code>push</code> 快速同步：</p>

<pre><code class="language-bash">npx drizzle-kit push
</code></pre>

<blockquote>
<p><strong>生产环境</strong>：必须使用 <code>generate</code> + <code>migrate</code>，并将生成的 SQL 文件纳入版本控制，以便回滚和审核。</p>
</blockquote>

<hr>

<h2 id="第七步-在-api-中使用-drizzle">🛠️ 第七步：在 API 中使用 Drizzle</h2>

<h3 id="查询示例-带关系">查询示例（带关系）</h3>

<p><strong><code>server/api/comments.get.ts</code></strong></p>

<pre><code class="language-ts">import { comments } from &quot;~/server/db/schema&quot;; // 需要显式导入表定义
import { eq, desc } from &quot;drizzle-orm&quot;;

export default defineEventHandler(async (event) =&gt; {
  const query = getQuery(event);
  const permalink = query.permalink as string;

  const db = useDB();
  const result = await db.query.comments.findMany({
    where: eq(comments.permalink, permalink),
    orderBy: [desc(comments.createdAt)],
    with: {
      user: {
        columns: { username: true },
      },
    },
  });

  return { success: true, data: result };
});
</code></pre>

<h3 id="插入示例">插入示例</h3>

<p><strong><code>server/api/comments.post.ts</code></strong></p>

<pre><code class="language-ts">import { comments, type NewComment } from &quot;~/server/db/schema&quot;;
import { useDB } from &quot;~~/server/db&quot;;

export default defineEventHandler(async (event) =&gt; {
  const body = await readBody(event);
  const db = useDB();

  const newComment: NewComment = {
    userId: body.userId,
    content: body.content,
    permalink: body.permalink,
  };

  const [inserted] = await db.insert(comments).values(newComment).returning();
  return { success: true, data: inserted };
});
</code></pre>

<hr>

<h2 id="常见错误与解决方案">🐛 常见错误与解决方案</h2>

<h3 id="错误-1-cannot-read-properties-of-undefined-reading-referencedtable">错误 1：<code>Cannot read properties of undefined (reading 'referencedTable')</code></h3>

<p><strong>原因</strong>：初始化 <code>drizzle</code> 时没有传入完整 schema（缺少 relations）。
<strong>解决</strong>：确保 <code>useDB()</code> 中 <code>drizzle(pool, { schema })</code> 的 <code>schema</code> 对象包含了 <code>relations</code> 导出。</p>

<h3 id="错误-2-usedb-未定义">错误 2：<code>useDB()</code> 未定义</h3>

<p><strong>原因</strong>：文件未放在 <code>server/utils/</code> 下，或 Nuxt 自动导入失效（需重启 dev）。
<strong>解决</strong>：检查文件路径，重启 <code>pnpm dev</code>。</p>

<h3 id="错误-3-db-query-comments-findmany-不存在">错误 3：<code>db.query.comments.findMany</code> 不存在</h3>

<p><strong>原因</strong>：没有启用 Drizzle 的关系查询 API，需要传入 schema 并确保 <code>drizzle-orm</code> 版本支持。
<strong>解决</strong>：确认 <code>useDB()</code> 返回的是带有 <code>query</code> 属性的实例（即传入了 schema）。</p>

<h3 id="错误-4-迁移时找不到表">错误 4：迁移时找不到表</h3>

<p><strong>原因</strong>：<code>drizzle.config.ts</code> 中的 <code>schema</code> 路径错误，或指向的文件没有导出所有表。
<strong>解决</strong>：确保路径正确，且 <code>schema/index.ts</code> 导出了所有表。</p>

<h3 id="错误-5-生产环境数据库连接失败">错误 5：生产环境数据库连接失败</h3>

<p><strong>原因</strong>：环境变量未正确设置，或连接字符串格式错误。
<strong>解决</strong>：在服务器上检查 <code>NUXT_DATABASE_URL</code> 是否正确，并确保网络可达。</p>

<hr>

<h2 id="最佳实践总结">💡 最佳实践总结</h2>

<ol>
<li><strong>永远不要提交 <code>.env</code></strong>。</li>
<li><strong>schema 必须包含 relations</strong>，否则无法使用 <code>with</code> 查询。</li>
<li><strong>数据库连接函数放在 <code>server/utils/</code></strong>，利用 Nuxt 自动导入。</li>
<li><strong>在 API 中显式导入表定义</strong>（如 <code>import { users } from '~/server/db/schema'</code>）。</li>
<li><strong>生产环境使用迁移文件</strong>，禁止用 <code>push</code>。</li>
<li><strong>测试环境与开发环境分离</strong>，用不同的数据库 URL。</li>
</ol>

<hr>

<h2 id="最终验证">🎯 最终验证</h2>

<p>完成以上步骤后，你应该能够：</p>

<ul>
<li>通过 <code>pnpm dev</code> 启动项目，访问测试 API 得到 <code>{ result: 2 }</code></li>
<li>使用 <code>drizzle-kit generate/migrate</code> 管理数据库变更</li>
<li>在 API 中正确查询带关联的数据</li>
<li>在生产环境中通过环境变量连接数据库</li>
</ul>

<p>如果遇到任何问题，请对照每一步仔细检查。记住：<strong>Drizzle 的官方文档是通用指南，Nuxt 集成需要根据其目录结构和自动导入机制进行调整</strong>。这篇教程已为你铺平道路，祝你顺利！</p>
]]></content:encoded>
      <description><![CDATA[本教程专注于 Drizzle ORM 在 PostgreSQL 数据库上的 Nuxt 4 集成。如果您使用的是 MySQL、SQLite 等其他数据库，部分配置（如连接驱动、数据类型）会有所不同，请参考 Drizzle 官方文档相应部分。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[PostgreSQL]]></category>
      <category><![CDATA[ORM]]></category>
      <dc:subject>P2</dc:subject>
      <dc:relation><![CDATA[series:backend]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 4 集成 GitHub 登录：从原理到实践（开发 + 生产环境完整版）]]></title>
      <link>https://moongate.top/docs/nuxt-oauth-github</link>
      <guid isPermaLink="true">87fa418e-f74e-4d83-8d79-01d44761b3eb</guid>
      <pubDate>Sun, 15 Feb 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<p>本文详细讲解在 Nuxt 4 应用中集成 GitHub OAuth 登录的完整过程，涵盖 OAuth 2.0 核心原理、nuxt-auth-utils 模块的工作机制、<strong>开发环境与生产环境的差异化配置</strong>、从零到一的实现步骤，以及<strong>生产环境部署时容易踩的坑和解决方案</strong>。</p>

<blockquote>
<p>适用版本</p>
</blockquote>

<ul>
<li><p>Nuxt 4</p></li>

<li><p>nuxt-auth-utils v0.4</p></li>

<li><p>Node: v20+</p></li>
</ul>

<hr>

<h2 id="一-oauth-2-0-核心原理-为什么第三方登录能-认识-用户">一、OAuth 2.0 核心原理（为什么第三方登录能“认识”用户）</h2>

<h3 id="1-1-四个角色">1.1 四个角色</h3>

<p>OAuth 流程涉及四个参与者：</p>

<table>
<thead>
<tr>
<th>角色</th>
<th>技术名词</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>资源所有者</strong></td>
<td>Resource Owner</td>
<td>拥有 GitHub 账号的用户</td>
</tr>

<tr>
<td><strong>客户端应用</strong></td>
<td>Client</td>
<td>需要访问用户 GitHub 信息的应用（即你的 Nuxt 应用）</td>
</tr>

<tr>
<td><strong>授权服务器</strong></td>
<td>Authorization Server</td>
<td>GitHub 的身份验证与授权端点</td>
</tr>

<tr>
<td><strong>资源服务器</strong></td>
<td>Resource Server</td>
<td>GitHub 的 API 服务器，存储用户数据</td>
</tr>
</tbody>
</table>
<p>在 GitHub 的实现中，授权服务器与资源服务器属同一实体，但逻辑职责分离。</p>

<h3 id="1-2-授权码模式核心流程">1.2 授权码模式核心流程</h3>

<p>OAuth 2.0 授权码模式是最安全的流程，核心思想是：客户端应用<strong>绝不接触用户密码</strong>，而是通过一次性的授权码换取代表用户身份的访问令牌。</p>

<ol>
<li><strong>引导用户</strong>：应用将用户重定向到 GitHub 授权页，附带 <code>client_id</code>、<code>redirect_uri</code> 和 <code>state</code>。</li>
<li><strong>用户授权</strong>：用户在 GitHub 登录并确认授权。</li>
<li><strong>返回授权码</strong>：GitHub 将用户重定向回应用的回调地址，并在 URL 中附带授权码。</li>
<li><strong>换取令牌</strong>：应用后端使用 <code>client_id</code> + <code>client_secret</code> + 授权码向 GitHub 换取 <code>access_token</code>。</li>
<li><strong>获取用户信息</strong>：后端使用 <code>access_token</code> 调用 GitHub API 获取用户数据。</li>
</ol>

<h3 id="1-3-关键概念">1.3 关键概念</h3>

<ul>
<li><strong>client_id</strong>：应用的公开标识，用于识别应用。</li>
<li><strong>client_secret</strong>：应用的私密密钥，用于后端安全通信，<strong>严禁暴露</strong>。</li>
<li><strong>redirect_uri</strong>：授权成功后 GitHub 重定向的地址，必须与注册时完全一致。</li>
<li><strong>scope</strong>：权限范围，指定应用可访问的用户信息（如公开资料、邮箱等）。</li>
<li><strong>state</strong>：防 CSRF 的随机字符串，在请求和回调中保持一致。</li>
</ul>

<hr>

<h2 id="二-github-oauth-完整交互时序">二、GitHub OAuth 完整交互时序</h2>

<pre><code class="language-mermaid">sequenceDiagram
participant 用户 as 用户 (浏览器)
participant 前端 as 应用前端 (Nuxt)
participant 后端 as 应用后端 (Nuxt Server)
participant GitHubAuth as GitHub 授权服务器
participant GitHubAPI as GitHub 资源服务器
用户-&gt;&gt;前端: 1. 点击“GitHub登录”
前端-&gt;&gt;后端: 2. 跳转到 /api/auth/github
后端-&gt;&gt;GitHubAuth: 3. 302重定向到 GitHub (带 client_id, redirect_uri, state)
GitHubAuth--&gt;&gt;用户: 4. 显示授权页面
用户-&gt;&gt;GitHubAuth: 5. 登录GitHub账号并点击“Authorize”
GitHubAuth-&gt;&gt;后端: 6. 302重定向回调地址 (带授权码 &amp; state)
后端-&gt;&gt;GitHubAuth: 7. 用授权码 + client_secret 请求 Access Token
GitHubAuth--&gt;&gt;后端: 8. 返回 Access Token
后端-&gt;&gt;GitHubAPI: 9. 用 Access Token 请求用户信息 (GET /user)
GitHubAPI--&gt;&gt;后端: 10. 返回用户数据 (id, login, avatar_url...)
后端-&gt;&gt;后端: 11. 用 session 密码加密用户数据，存入 Cookie
后端-&gt;&gt;用户: 12. 302重定向回首页 (附带加密 Cookie)
用户-&gt;&gt;前端: 13. 访问首页，浏览器自动携带 Cookie
前端-&gt;&gt;后端: 14. Vue组件通过 useUserSession() 请求 /api/_auth/session
后端-&gt;&gt;后端: 15. 解密 Cookie，验证身份
后端--&gt;&gt;前端: 16. 返回用户数据
前端--&gt;&gt;用户: 17. 页面显示“欢迎，用户名”
</code></pre>

<h3 id="各步骤原理">各步骤原理</h3>

<ul>
<li><strong>步骤 2</strong>：<code>/api/auth/github</code> 由 <code>nuxt-auth-utils</code> 提供，构造 GitHub 授权 URL 并返回 302 重定向。</li>
<li><strong>步骤 3</strong>：重定向 URL 包含 <code>client_id</code>、<code>redirect_uri</code> 和自动生成的 <code>state</code>。</li>
<li><strong>步骤 6</strong>：回调中携带授权码和 <code>state</code>，后端验证 <code>state</code> 一致性。</li>
<li><strong>步骤 7</strong>：后端通过 <code>client_secret</code> 换取 <code>access_token</code>，该步骤在服务器间进行，密钥不暴露。</li>
<li><strong>步骤 11</strong>：使用 <code>NUXT_SESSION_PASSWORD</code> 加密用户数据，存入 <code>HttpOnly</code> 的 <code>nuxt-session</code> Cookie。</li>
<li><strong>步骤 14</strong>：<code>useUserSession()</code> 实际调用 <code>/api/_auth/session</code>，后端解密 Cookie 返回用户信息。</li>
</ul>

<hr>

<h2 id="三-nuxt-auth-utils-工作原理">三、nuxt-auth-utils 工作原理</h2>

<h3 id="3-1-session-存储-加密-cookie">3.1 Session 存储：加密 Cookie</h3>

<ul>
<li>调用 <code>setUserSession(event, data)</code> 时，模块利用 <code>NUXT_SESSION_PASSWORD</code> 对数据进行加密，生成 <code>nuxt-session</code> Cookie。</li>
<li>Cookie 属性：<code>HttpOnly</code>（防 XSS）、<code>SameSite=Lax</code>（防 CSRF）、<code>Secure</code>（生产环境强制 HTTPS）。</li>
<li>后续请求自动携带该 Cookie，后端通过 <code>getUserSession(event)</code> 解密还原数据。</li>
</ul>

<p><strong>优点</strong>：无需数据库，适合 Serverless 部署；数据加密防篡改。<br>
<strong>缺点</strong>：Cookie 大小限制 4KB；无法主动全局使所有 session 失效。</p>

<h3 id="3-2-前端-useusersession">3.2 前端 useUserSession</h3>

<ul>
<li>组件挂载时自动调用 <code>/api/_auth/session</code> 获取当前用户数据。</li>
<li>返回 <code>loggedIn</code>（计算属性，等价于 <code>!!user.value</code>）和 <code>user</code>（响应式数据）。</li>
<li>登录状态变化时自动更新。</li>
</ul>

<h3 id="3-3-为何区分-loggedin-和-user">3.3 为何区分 <code>loggedIn</code> 和 <code>user</code></h3>

<ul>
<li>模板中可直接用 <code>v-if=&quot;loggedIn&quot;</code> 表达登录状态，避免手动判断 <code>user</code> 是否为空。</li>
</ul>

<hr>

<h2 id="四-开发环境配置-本地运行">四、开发环境配置（本地运行）</h2>

<h3 id="4-1-安装-nuxt-auth-utils-模块">4.1 安装 nuxt-auth-utils 模块</h3>

<pre><code class="language-bash">npx nuxi@latest module add auth-utils
</code></pre>

<h3 id="4-2-环境变量配置-使用-env-example-模板">4.2 环境变量配置：使用 <code>.env.example</code> 模板</h3>

<ol>
<li>在项目根目录创建 <code>.env.example</code> 文件，并<strong>提交到代码仓库</strong>：</li>
</ol>

<pre><code class="language-bash">   # 至少 32 位随机字符串（本地开发用）
   NUXT_SESSION_PASSWORD=your-local-32-char-dev-password
   # GitHub OAuth 凭证（需创建独立的 GitHub OAuth App）
   NUXT_OAUTH_GITHUB_CLIENT_ID=your_dev_app_client_id
   NUXT_OAUTH_GITHUB_CLIENT_SECRET=your_dev_app_client_secret
</code></pre>

<ol>
<li><strong>本地开发时</strong>，复制一份为 <code>.env</code> 并填入真实值：</li>
</ol>

<pre><code class="language-bash">   cp .env.example .env
</code></pre>

<p><strong>并将 <code>.env</code> 添加到 <code>.gitignore</code></strong>（确保不会误提交）：</p>

<pre><code class="language-bash">   .env
</code></pre>

<p><strong>原理</strong>：<code>.env.example</code> 作为文档，告诉其他开发者需要哪些环境变量；<code>.env</code> 包含真实敏感信息，仅存在于本地。</p>

<h3 id="4-3-在-github-创建开发环境的-oauth-app">4.3 在 GitHub 创建开发环境的 OAuth App</h3>

<ol>
<li><p>登录 GitHub → <strong>Settings</strong> → <strong>Developer settings</strong> → <strong>OAuth Apps</strong> → <strong>New OAuth App</strong>。</p></li>

<li><p>填写：</p>

<ul>
<li><strong>Application name</strong>：例如 <code>myapp-dev</code>（明确标识为开发环境）</li>
<li><strong>Homepage URL</strong>：<code>http://localhost:3000</code></li>
<li><strong>Authorization callback URL</strong>：<code>http://localhost:3000/api/auth/github</code></li>
</ul></li>

<li><p>注册后复制 <strong>Client ID</strong> 和 <strong>Client Secret</strong> 填入 <code>.env</code> 文件。</p></li>
</ol>

<h3 id="4-4-配置-nuxt-config-ts">4.4 配置 nuxt.config.ts</h3>

<pre><code class="language-ts">export default defineNuxtConfig({
  modules: [&quot;nuxt-auth-utils&quot;],
  runtimeConfig: {
    oauth: {
      github: {
        clientId: &quot;&quot;, // 留空，由环境变量注入
        clientSecret: &quot;&quot;,
      },
    },
  },
});
</code></pre>

<p><strong>原理</strong>：<code>runtimeConfig</code> 自动将 <code>NUXT_OAUTH_GITHUB_*</code> 注入对应字段，无需硬编码。</p>

<h3 id="4-5-创建服务端路由处理回调">4.5 ### 创建服务端路由处理回调</h3>

<p>创建<code>server/api/auth/github.get.ts</code>：</p>

<pre><code class="language-ts">export default defineOAuthGitHubEventHandler({
  // 使用模块内置的 GitHub OAuth 处理器
  async onSuccess(event, { user }) {
    await setUserSession(event, {
      user: {
        githubId: String(user.id),
        login: user.login,
        name: user.name,
        avatarUrl: user.avatar_url,
        email: user.email, // 注意：可能为 null
      },
      loggedInAt: Date.now(),
    });

    // 重定向回首页（或来源页）
    return sendRedirect(event, &quot;/&quot;);
  },
  onError(event, error) {
    console.error(&quot;GitHub OAuth error:&quot;, error);
    return sendRedirect(event, &quot;/?auth_error=true&quot;);
  },
});
</code></pre>

<p><strong>注意</strong>：以上代码使用了 <code>requireNuxtAuthSession</code>，但实际 <code>nuxt-auth-utils</code> 模块的 API 可能略有不同，请以<a href="https://github.com/Atinux/nuxt-auth-utils" target="_blank">官方文档</a>为准。如果模块提供了专门的 <code>defineOAuthGitHubEventHandler</code>，建议直接使用。</p>

<p><strong>原理</strong>：<code>defineOAuthGitHubEventHandler</code> 封装了授权码交换和 token 获取；<code>setUserSession</code> 加密数据存入 Cookie。</p>

<h3 id="4-6-前端登录按钮">4.6 前端登录按钮</h3>

<pre><code class="language-vue">&lt;script setup&gt;
const { loggedIn, user, clear } = useUserSession();
const loginWithGitHub = () =&gt; {
  // 跳转到 GitHub 授权页
  navigateTo(&quot;/api/auth/github&quot;, { external: true });
};
&lt;/script&gt;
&lt;template&gt;
  &lt;div&gt;
    &lt;div v-if=&quot;loggedIn&quot;&gt;
      &lt;img :src=&quot;user.avatarUrl&quot; class=&quot;w-8 h-8 rounded-full&quot; /&gt;
      &lt;span&gt;{{ user.name || user.login }}&lt;/span&gt;
      &lt;button @click=&quot;clear&quot;&gt;登出&lt;/button&gt;
    &lt;/div&gt;
    &lt;button v-else @click=&quot;loginWithGitHub&quot;&gt;GitHub 登录&lt;/button&gt;
  &lt;/div&gt;
&lt;/template&gt;
</code></pre>

<p><strong>原理</strong>：<code>useUserSession</code> 自动获取用户信息；<code>navigateTo(..., { external: true })</code> 触发外部重定向；<code>clear()</code> 清除 session Cookie。</p>

<h3 id="4-7-启动开发服务器">4.7 启动开发服务器</h3>

<pre><code class="language-bash">pnpm dev
</code></pre>

<p>访问 <code>http://localhost:3000</code>，点击登录应能正常跳转到 GitHub 授权页。</p>

<h2 id="五-进阶功能-登录后重定向回来源页">五、进阶功能：登录后重定向回来源页</h2>

<h3 id="5-1-问题的由来">5.1 问题的由来</h3>

<p>默认实现中，登录成功后用户被重定向到首页 /。但更符合用户体验的做法是：用户从哪个页面点击登录，登录后就应该回到哪个页面。例如从 /en/docs/123 点击登录，成功后应回到同一页面。</p>

<h3 id="5-2-技术难点-github-回调会丢弃自定义参数">5.2 技术难点：GitHub 回调会丢弃自定义参数</h3>

<p>GitHub 在回调时只会保留 code 和 state 两个参数，你附加的任何自定义查询参数（如 ?redirect=/en/docs）都会被丢弃。因此无法通过 URL 参数直接传递来源页。</p>

<h3 id="5-3-解决方案-使用-session-存储来源页">5.3 解决方案：使用 session 存储来源页</h3>

<h4 id="5-3-1-创建存储来源页的-api">5.3.1 创建存储来源页的 API</h4>

<pre><code class="language-ts">// server/api/store-redirect.post.ts
export default defineEventHandler(async (event) =&gt; {
  const { redirect } = await readBody(event);

  // 校验 redirect 是否为内部路径（防止开放重定向）
  if (!redirect || typeof redirect !== &quot;string&quot; || !redirect.startsWith(&quot;/&quot;)) {
    return { ok: false };
  }

  await setUserSession(event, { redirect });
  return { ok: true };
});
</code></pre>

<p>如果你担心有人恶意传 /\/\/evil.com 这种试图绕过检测的路径，可以加一个更严格的 URL 解析校验：</p>

<pre><code class="language-ts">import { parseURL } from &quot;ufo&quot;;

const { redirect } = await readBody(event);

// 解析路径，确保是内部路径
const parsed = parseURL(redirect);
if (!redirect || !parsed.pathname || parsed.host) {
  return { ok: false };
}
</code></pre>

<blockquote>
<p>但说实话，startsWith(&ldquo;/&rdquo;) 对 99.9% 的场景已经足够。这个补充纯属过度设计，不搞也行。</p>
</blockquote>

<h4 id="5-3-2-修改前端登录函数">5.3.2 修改前端登录函数</h4>

<pre><code class="language-vue">&lt;script setup&gt;
const { loggedIn } = useUserSession();
const route = useRoute();

const loginWithGitHub = async () =&gt; {
  // 将当前完整路径保存到 session
  await $fetch(&quot;/api/store-redirect&quot;, {
    method: &quot;POST&quot;,
    body: { redirect: route.fullPath },
  });
  navigateTo(&quot;/api/auth/github&quot;, { external: true });
};
&lt;/script&gt;
</code></pre>

<h4 id="5-3-3-修改回调路由以读取来源页">5.3.3 修改回调路由以读取来源页</h4>

<pre><code class="language-ts">// server/api/auth/github.get.ts
export default defineOAuthGitHubEventHandler({
  async onSuccess(event, { user }) {
    // 获取之前存储的来源页
    const session = await getUserSession(event);
    let redirect = (session.redirect as string) || &quot;/&quot;;

    // 清理 session 中的 redirect（避免下次重复使用）
    await setUserSession(event, { ...session, redirect: undefined });

    // 存入用户信息
    await setUserSession(event, {
      user: {
        githubId: String(user.id),
        login: user.login,
        name: user.name,
        avatarUrl: user.avatar_url,
        email: user.email,
      },
      loggedInAt: Date.now(),
    });

    // 重定向回来源页
    return sendRedirect(event, redirect);
  },

  async onError(event, error) {
    console.error(&quot;GitHub OAuth error:&quot;, error);
    return sendRedirect(event, &quot;/login?error=true&quot;);
  },
});
</code></pre>

<p><strong>安全说明</strong>：在 store-redirect.post.ts 中增加了路径校验，防止开放重定向漏洞。</p>

<h3 id="5-4-水合问题的处理">5.4 水合问题的处理</h3>

<p>当网站支持国际化路径前缀（如 <code>/zh</code>、<code>/en</code>）时，登录后重定向到来源页还能<strong>自动解决水合不匹配问题</strong>。</p>

<h4 id="5-4-1-水合不匹配的产生原因">5.4.1 水合不匹配的产生原因</h4>

<ol>
<li>用户在 <code>/en/docs/123</code> 点击登录</li>
<li>登录成功后，若直接重定向到首页 <code>/</code>（无语言前缀）</li>
<li>服务器返回的是默认语言（如中文）的 HTML</li>
<li>但客户端期望的 hydration 内容是英文（用户来源页面是 <code>/en/...</code>）</li>
<li>导致水合警告：<code>Hydration completed but contains mismatches</code></li>
</ol>

<h4 id="5-4-2-为什么重定向到来源页能解决">5.4.2 为什么重定向到来源页能解决</h4>

<ul>
<li>用户从 <code>/en/docs/123</code> 来，登录后回到 <code>/en/docs/123</code></li>
<li>服务器根据 URL 中的 <code>/en</code> 前缀渲染对应的<strong>英文版本</strong></li>
<li>客户端 hydration 时，DOM 结构与 URL 完全匹配</li>
<li>水合警告自然消失</li>
</ul>

<hr>

<h2 id="六-生产环境配置-服务器部署">六、生产环境配置（服务器部署）</h2>

<h3 id="6-1-核心差异-环境变量来源">6.1 核心差异：环境变量来源</h3>

<table>
<thead>
<tr>
<th>环境</th>
<th>配置文件</th>
<th>变量来源</th>
</tr>
</thead>

<tbody>
<tr>
<td>开发环境</td>
<td><code>.env</code> 文件</td>
<td>本地文件（已 gitignore）</td>
</tr>

<tr>
<td>生产环境</td>
<td><strong>无</strong> <code>.env</code> 文件</td>
<td>系统环境变量 / 托管平台 Secrets</td>
</tr>
</tbody>
</table>
<p><strong>Nuxt 4 的设计原则</strong>：生产环境不读取 <code>.env</code> 文件，所有环境变量必须通过运行环境提供（如 Vercel/Netlify 的环境变量面板、Linux 系统环境变量、Docker 环境变量等）。
<strong>千万不要将 <code>.env</code> 文件上传到服务器</strong>，也不要在构建过程中打包进去。</p>

<h3 id="6-2-创建生产环境的-github-oauth-app">6.2 创建生产环境的 GitHub OAuth App</h3>

<ol>
<li><p>登录 GitHub → 创建<strong>另一个</strong> OAuth App（与开发环境分开）。</p></li>

<li><p>填写：</p>

<ul>
<li><strong>Application name</strong>：例如 <code>myapp-prod</code></li>
<li><strong>Homepage URL</strong>：<code>https://你的域名</code></li>
<li><strong>Authorization callback URL</strong>：<code>https://你的域名/api/auth/github</code></li>
</ul></li>

<li><p>生成并保存 <strong>Client ID</strong> 和 <strong>Client Secret</strong>。</p></li>
</ol>

<h3 id="6-3-服务器环境变量配置-以-linux-pm2-为例">6.3 服务器环境变量配置（以 Linux + PM2 为例）</h3>

<h4 id="6-3-1-直接设置系统环境变量-临时方案">6.3.1 直接设置系统环境变量（临时方案）</h4>

<pre><code class="language-bash"># 编辑 /etc/profile 或 ~/.bashrc，添加：
export NUXT_SESSION_PASSWORD=your-production-32-char-password
export NUXT_OAUTH_GITHUB_CLIENT_ID=your_prod_client_id
export NUXT_OAUTH_GITHUB_CLIENT_SECRET=your_prod_client_secret
# 使环境变量生效
source ~/.bashrc
</code></pre>

<h4 id="6-3-2-在-pm2-配置文件中引用环境变量-推荐">6.3.2 在 PM2 配置文件中引用环境变量（<strong>推荐</strong>）</h4>

<p>创建 <code>ecosystem.config.js</code>（或通过 CI/CD 自动生成）：</p>

<pre><code class="language-javascript">module.exports = {
  apps: [
    {
      name: &quot;moongate&quot;,
      script: &quot;./server/index.mjs&quot;,
      instances: 1,
      exec_mode: &quot;fork&quot;,
      env: {
        NODE_ENV: &quot;production&quot;,
        NUXT_PUBLIC_SITE_URL: &quot;https://moongate.top&quot;,
        PORT: 3000,
        HOST: &quot;0.0.0.0&quot;,
        // 引用系统环境变量（更安全）
        NUXT_SESSION_PASSWORD: &quot;your-production-32-char-password&quot;,
        NUXT_OAUTH_GITHUB_CLIENT_ID: &quot;your_prod_client_id&quot;,
        NUXT_OAUTH_GITHUB_CLIENT_SECRET: &quot;your_prod_client_secret&quot;,
      },
    },
  ],
};
</code></pre>

<h3 id="6-4-ci-cd-自动化部署-github-actions-示例">6.4 CI/CD 自动化部署（GitHub Actions 示例）</h3>

<pre><code class="language-yaml">name: Deploy To Production

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: pnpm

      - name: Install dependencies
        run: pnpm install

      - name: Build
        run: pnpm build

      - name: Deploy via Rsync
        uses: burnett01/rsync-deployments@7.0.1
        with:
          switches: -avz --delete
          path: .output/
          remote_path: /var/www/my-site/
          remote_host: ${{ secrets.SERVER_HOST }}
          remote_user: ${{ secrets.SERVER_USER }}
          remote_key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Start service via SSH
        uses: appleboy/ssh-action@v1.0.0
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          envs: NUXT_SESSION_PASSWORD, NUXT_OAUTH_GITHUB_CLIENT_ID, NUXT_OAUTH_GITHUB_CLIENT_SECRET
          script: |
            cd /var/www/my-site

            cat &gt; ecosystem.config.js &lt;&lt; EOF
            module.exports = {
              apps: [{
                name: &quot;moongate&quot;,
                script: &quot;./server/index.mjs&quot;,
                instances: 1,
                exec_mode: &quot;fork&quot;,
                env: {
                  NODE_ENV: &quot;production&quot;,
                  NUXT_PUBLIC_SITE_URL: &quot;https://moongate.top&quot;,
                  PORT: 3000,
                  HOST: &quot;0.0.0.0&quot;,
                  NUXT_SESSION_PASSWORD: &quot;$(echo -n &quot;$NUXT_SESSION_PASSWORD&quot;)&quot;,
                  NUXT_OAUTH_GITHUB_CLIENT_ID: &quot;$(echo -n &quot;$NUXT_OAUTH_GITHUB_CLIENT_ID&quot;)&quot;,
                  NUXT_OAUTH_GITHUB_CLIENT_SECRET: &quot;$(echo -n &quot;$NUXT_OAUTH_GITHUB_CLIENT_SECRET&quot;)&quot;
                }
              }]
            }
            EOF

            pm2 restart ecosystem.config.js --update-env
</code></pre>

<p><strong>关键点</strong>：使用 <code>echo -n</code> 去除换行符，避免密码末尾被污染导致加密不匹配。</p>

<h3 id="6-5-验证生产环境">6.5 验证生产环境</h3>

<ul>
<li>访问 <code>https://你的域名.com/api/_auth/session</code>，应返回 <code>{&quot;user&quot;:null}</code> 或登录后的用户信息。</li>
<li>点击登录按钮，应能跳转到 GitHub 授权页，授权后返回首页并显示用户信息。</li>
</ul>

<hr>

<h2 id="七-生产环境常见错误-附根本原因与解决方案">七、生产环境常见错误（附根本原因与解决方案）</h2>

<h3 id="7-1-静态资源-404-css-js-无法加载">7.1 静态资源 404（CSS/JS 无法加载）</h3>

<p><strong>现象</strong>：页面无样式，控制台大量 <code>.css</code>、<code>.js</code> 请求 404。<br>
<strong>原因</strong>：</p>

<ul>
<li>Nuxt 构建后的静态文件（<code>.output/public/</code>）未正确同步到服务器。</li>
<li>或 PM2 未重启，服务仍在旧代码路径下运行。<br>
<strong>解决</strong>：</li>
<li>确认 <code>rsync</code> 路径是否正确：本地 <code>.output/</code> → 远程 <code>/var/www/my-site/</code>。</li>
<li>重启 PM2：<code>pm2 restart &lt;app-name&gt; --update-env</code>。</li>
</ul>

<h3 id="7-2-api-auth-session-返回-500">7.2 <code>/api/_auth/session</code> 返回 500</h3>

<p><strong>现象</strong>：登录后无法获取用户信息，接口报错。<br>
<strong>原因</strong>：</p>

<ul>
<li><code>NUXT_SESSION_PASSWORD</code> 未正确传递，或<strong>值末尾包含换行符</strong>（常见于 GitHub Actions 中直接 <code>echo</code> 变量）。</li>
<li><code>NUXT_SESSION_PASSWORD</code> 长度不足 32 位。<br>
<strong>解决</strong>：</li>
<li>使用 <code>echo -n</code> 去除换行符（见上方 CI/CD 示例）。</li>
<li>检查配置文件中的密码值是否干净。</li>
</ul>

<h3 id="7-3-oauth-回调成功但页面未登录">7.3 OAuth 回调成功但页面未登录</h3>

<p><strong>现象</strong>：GitHub 跳转回首页，但右上角仍显示“登录”。<br>
<strong>原因</strong>：</p>

<ul>
<li><code>setUserSession</code> 未执行（回调路由有误）。</li>
<li><code>NUXT_SESSION_PASSWORD</code> 与开发环境不一致，导致 Cookie 无法解密。<br>
<strong>解决</strong>：</li>
<li>检查 <code>server/api/auth/github.get.ts</code> 中的 <code>onSuccess</code> 是否被调用。</li>
<li>确认生产环境使用的密码与加密时一致。</li>
</ul>

<h3 id="7-4-redirect-uri-mismatch">7.4 <code>redirect_uri_mismatch</code></h3>

<p><strong>现象</strong>：GitHub 返回错误“The redirect_uri MUST match the registered callback URL”。<br>
<strong>原因</strong>：生产环境使用的回调 URL 未在 GitHub OAuth App 中注册。<br>
<strong>解决</strong>：</p>

<ul>
<li>登录 GitHub，进入生产环境 OAuth App 设置，将 <code>https://你的域名/api/auth/github</code> 添加到回调 URL 列表。</li>
</ul>

<h3 id="7-5-登录后-pinia-store-报错">7.5 登录后 Pinia store 报错</h3>

<p><strong>现象</strong>：控制台出现 <code>t.$pinia.state.value.xxx is undefined</code>。<br>
<strong>原因</strong>：在 Pinia store 初始化完成前，某个组件试图访问 store 属性（常见于登录后的重定向瞬间）。<br>
<strong>解决</strong>：</p>

<ul>
<li>在访问 store 属性前加防御性判断：<code>store?.xxx ?? defaultValue</code>。</li>
<li>或在插件中提前初始化 store。</li>
</ul>

<hr>

<h2 id="八-开发与生产环境最佳实践总结">八、开发与生产环境最佳实践总结</h2>

<table>
<thead>
<tr>
<th>维度</th>
<th>开发环境</th>
<th>生产环境</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>GitHub OAuth App</strong></td>
<td>一个独立 App（<code>myapp-dev</code>）</td>
<td>另一个独立 App（<code>myapp-prod</code>）</td>
</tr>

<tr>
<td><strong>环境变量文件</strong></td>
<td><code>.env</code>（已 gitignore）</td>
<td>无，由系统环境变量 / CI Secrets 提供</td>
</tr>

<tr>
<td><strong>回调 URL</strong></td>
<td><code>http://localhost:3000/api/auth/github</code></td>
<td><code>https://你的域名/api/auth/github</code></td>
</tr>

<tr>
<td><strong>部署方式</strong></td>
<td><code>pnpm dev</code></td>
<td>CI/CD + PM2</td>
</tr>

<tr>
<td><strong>来源页重定向</strong></td>
<td>同左</td>
<td>同左（session 方案通用）</td>
</tr>

<tr>
<td><strong>常见陷阱</strong></td>
<td>无</td>
<td>换行符污染、静态文件缺失、密码不一致</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="九-版本兼容性说明">九、版本兼容性说明</h2>

<blockquote>
<p>⚠️ <strong>注意</strong>：<code>nuxt-auth-utils</code> 模块的 API 可能随版本更新而变化。本文基于 <code>v0.4.x</code> 编写，如果你使用的是更新版本，建议查阅<a href="https://github.com/Atinux/nuxt-auth-utils" target="_blank">官方文档</a>确认具体用法。核心原理和流程不变。</p>
</blockquote>

<hr>

<h2 id="十-结语">十、结语</h2>

<p>本文完整呈现了在 Nuxt 4 中集成 GitHub OAuth 的流程，涵盖基础原理、开发配置、生产部署，以及登录后重定向回来源页的进阶实现。这一功能不仅提升了用户体验，还自然解决了国际化场景下的水合不匹配问题。</p>

<p>掌握这些内容后，开发者能够独立实现第三方登录功能，并具备在生产环境中排查和解决复杂问题的能力。希望这份文档能成为你技术工具箱中的一份可靠参考。</p>
]]></content:encoded>
      <description><![CDATA[Nuxt 4 集成 GitHub 登录：从原理到实践（开发 + 生产环境完整版）]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[OAuth]]></category>
      <category><![CDATA[Security]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P2</dc:subject>
      <dc:relation><![CDATA[series:backend]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 4 中安全实现状态持久化：根治水合失败指南]]></title>
      <link>https://moongate.top/docs/nuxt-state-persistence-guide</link>
      <guid isPermaLink="true">d455ccf1-d190-44a4-a1ce-e30feb3cca3f</guid>
      <pubDate>Wed, 11 Feb 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<h2 id="前言">前言</h2>

<p>我打算给博客加“视图模式”切换——让读者在“详细模式”（显示摘要）和“简洁模式”（只显示标题）间切换。功能很简单：用 <code>USelect</code> 绑定 Pinia 的 <code>viewMode</code>，再用 <code>v-if</code> 控制摘要显示。</p>

<p>本地开发一切正常。部署后却出现诡异现象：刷新页面时下拉菜单总是跳回“详细模式”，控制台报了一堆 <code>Hydration mismatch</code> 错误。我用过 <code>ClientOnly</code>，虽然不报错了，但刷新时出现短暂空白。我试过在 <code>onMounted</code> 里延迟读取，试过 <code>isHydrated</code> 标志，代码越来越复杂，bug 却还在。</p>

<p>直到我重新理解了 Nuxt 的 SSR 机制和 <code>useLocalStorage</code> 的原理，才真正解决了问题。</p>
</blockquote>

<h2 id="问题根源-两个世界的状态冲突">问题根源：两个世界的状态冲突</h2>

<p>Nuxt 的 SSR 流程：</p>

<ol>
<li><strong>服务端</strong>：在 Node.js 环境生成 HTML，无法访问 <code>localStorage</code></li>
<li><strong>客户端</strong>：在浏览器激活页面，可以正常访问所有 Web API</li>
</ol>

<p><strong>水合失败</strong>发生在：服务端用一个状态（如 <code>viewMode = 1</code>）渲染 HTML，客户端用另一个状态（如从 <code>localStorage</code> 读出的 <code>viewMode = 2</code>）激活，两边不一致，Vue 报错。</p>

<pre><code class="language-ts">// 错误示例：直接在组件顶层读取 localStorage
const viewMode = ref(localStorage.getItem(&quot;viewMode&quot;) === &quot;2&quot; ? 2 : 1);
// 服务端报错，客户端值不一致 → 水合失败
</code></pre>

<h2 id="两类状态-两种处理方式">两类状态，两种处理方式</h2>

<h3 id="1-配置型状态-ssr-安全">1. 配置型状态（SSR 安全）</h3>

<ul>
<li>主题、语言、搜索选项等</li>
<li>初始值来自 SSR 安全 API（<code>useColorMode</code>、<code>useI18n</code>）或固定默认值</li>
<li><strong>可用 Pinia + 持久化插件</strong></li>
</ul>

<pre><code class="language-ts">export const useSettingsStore = defineStore(
  &quot;settings&quot;,
  () =&gt; {
    const colorMode = useColorMode(); // SSR 安全
    const { locale } = useI18n(); // SSR 安全

    const preferences = ref({
      theme: colorMode.preference,
      language: locale.value,
      searchOption: 1,
    });

    return { preferences };
  },
  { persist: true },
); // 插件只在客户端恢复状态
</code></pre>

<h3 id="2-ui-状态-高风险">2. UI 状态（高风险）</h3>

<ul>
<li>侧边栏折叠、面板显示、视图模式等</li>
<li><strong>直接影响初始 DOM 结构</strong></li>
<li><strong>用 <code>useLocalStorage</code>，不要进 Pinia</strong></li>
</ul>

<h2 id="解决方案-uselocalstorage">解决方案：useLocalStorage</h2>

<pre><code class="language-vue">&lt;script setup&gt;
import { useLocalStorage } from &quot;@vueuse/core&quot;;
// 服务端返回默认值 1，客户端自动同步 localStorage
const viewMode = useLocalStorage(&quot;viewMode&quot;, 1);
&lt;/script&gt;
&lt;template&gt;
  &lt;USelect v-model=&quot;viewMode&quot; :items=&quot;viewModeOptions&quot; /&gt;
  &lt;UBlogPost
    v-for=&quot;post in posts&quot;
    :description=&quot;viewMode === 1 ? post.description : ''&quot;
  /&gt;
&lt;/template&gt;
</code></pre>

<p><strong>为什么能解决？</strong></p>

<p><code>useLocalStorage</code> 的核心是 <strong>依赖注入 + 可选链</strong>：</p>

<pre><code class="language-ts">// 简化版原理
function useLocalStorage(key, initialValue) {
  // 关键：window?.localStorage 在服务端是 undefined
  const storage = import.meta.client ? localStorage : null;
  return useStorage(key, initialValue, storage);
}
</code></pre>

<ul>
<li><strong>服务端</strong>：<code>storage = null</code>，直接返回 <code>initialValue</code>，无副作用</li>
<li><strong>客户端</strong>：<code>storage = localStorage</code>，自动读取并同步持久化值</li>
</ul>

<p>整个过程：</p>

<ul>
<li>服务端用默认值 1 渲染 HTML（带摘要）</li>
<li>客户端激活时，<code>useLocalStorage</code> 读到 localStorage 中的 2，更新视图</li>
<li>水合已完成，不会报错，用户看到最终状态</li>
</ul>

<h2 id="和-pinia-持久化的本质区别">和 Pinia 持久化的本质区别</h2>

<table>
<thead>
<tr>
<th>维度</th>
<th>Pinia 持久化</th>
<th>useLocalStorage</th>
</tr>
</thead>

<tbody>
<tr>
<td>设计模式</td>
<td>硬编码调用 <code>localStorage</code></td>
<td>依赖注入，环境感知</td>
</tr>

<tr>
<td>SSR 行为</td>
<td>正确配置时安全，但初始值只能用默认值</td>
<td>检测到无 storage → 安全降级</td>
</tr>

<tr>
<td>适用场景</td>
<td>配置型状态</td>
<td>UI 状态</td>
</tr>
</tbody>
</table>
<p><strong>核心检验标准</strong>：问自己“这个状态的初始值需要在服务端决定 DOM 结构吗？”</p>

<ul>
<li>是 → <code>useLocalStorage</code></li>
<li>否 → Pinia + 持久化插件（确保初始值 SSR 安全）</li>
</ul>

<h2 id="决策流程图">决策流程图</h2>

<pre><code class="language-mermaid">flowchart TD
    A[需要持久化的状态] --&gt; B{是否影响初始 DOM？&lt;br&gt;（如侧边栏、视图模式）}
    B -- 是 --&gt; C[useLocalStorage]
    B -- 否 --&gt; D{初始值能否在服务端安全获取？}
    D -- 是 --&gt; E[Pinia + 持久化插件]
    D -- 否 --&gt; C
</code></pre>

<h2 id="实战建议">实战建议</h2>

<ol>
<li><strong>不要在 Pinia store 顶层读 <code>localStorage</code></strong>，store 初始值必须 SSR 安全。</li>
<li><strong>UI 状态优先用 <code>useLocalStorage</code></strong>，简单可靠，无需进 store。</li>
<li><strong><code>ClientOnly</code> 不是万能药</strong>，会导致服务端空白，仅用于纯交互组件。</li>
<li><strong>务必测试生产构建</strong>：<code>pnpm build &amp;&amp; pnpm preview</code>，水合问题常在构建后暴露。</li>
</ol>

<h2 id="我的最终代码">我的最终代码</h2>

<pre><code class="language-vue">&lt;script setup&gt;
import { useLocalStorage } from &quot;@vueuse/core&quot;;
const viewMode = useLocalStorage(&quot;viewMode&quot;, 1);
const viewModeOptions = [
  { id: 1, name: &quot;详细模式&quot; },
  { id: 2, name: &quot;简洁模式&quot; },
];
&lt;/script&gt;
&lt;template&gt;
  &lt;USelect v-model=&quot;viewMode&quot; :items=&quot;viewModeOptions&quot; /&gt;
  &lt;UBlogPost
    v-for=&quot;post in posts&quot;
    :description=&quot;viewMode === 1 ? post.description : ''&quot;
  /&gt;
&lt;/template&gt;
</code></pre>

<p>没有 Pinia，没有 <code>isHydrated</code>，没有 <code>ClientOnly</code>。代码简洁，SSR 安全，水合通过。</p>

<h2 id="总结">总结</h2>

<p>根治水合失败的核心原则：<strong>让服务端和客户端第一次渲染的结果保持一致</strong>。</p>

<ul>
<li>UI 状态 → <code>useLocalStorage</code></li>
<li>配置状态 → Pinia + 持久化插件（确保初始值 SSR 安全）</li>
</ul>

<p>这不是技巧问题，而是对 SSR 架构的理解问题。选择正确的工具，代码自然简洁。</p>

<blockquote>
<p>如果你的状态需要通过链接分享（如分页、搜索、视图模式），可以阅读我的另一篇文档<a href="nuxt-url-state-guide" target="_blank">《Nuxt 中 URL 与状态双向绑定指南》</a>。</p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[介绍 Nuxt 4 中安全实现状态持久化的原理，以及如何用 useLocalStorage 解决 Nuxt 4 的 SSR 水合失败问题。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[State Management]]></category>
      <category><![CDATA[Hydration]]></category>
      <category><![CDATA[Performance]]></category>
      <dc:subject>P1</dc:subject>
      <dc:relation><![CDATA[series:performance]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 4 博客 Sitemap 配置完整指南]]></title>
      <link>https://moongate.top/docs/nuxt-sitemap-guide</link>
      <guid isPermaLink="true">f8358d9f-5548-4ad1-8432-b8a5ef31e70d</guid>
      <pubDate>Wed, 04 Feb 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="为什么需要-sitemap">为什么需要 Sitemap？</h2>

<p>Sitemap（站点地图）是一个 XML 文件，它告诉搜索引擎你的网站上有哪些页面、这些页面的重要性以及更新频率。对于个人博客而言，Sitemap 能：</p>

<ul>
<li><strong>加速新内容收录</strong>：新发布的文档可以在几小时到几天内被搜索引擎发现</li>
<li><strong>提高内容覆盖率</strong>：确保所有文档都被索引，避免“孤岛页面”</li>
<li><strong>优化爬虫效率</strong>：合理分配搜索引擎抓取资源</li>
</ul>

<h2 id="基础配置方案">基础配置方案</h2>

<h3 id="方案一-静态-sitemap-最简单">方案一：静态 Sitemap（最简单）</h3>

<p>适用于文档数量少、更新频率低的博客。</p>

<p>在 <code>public/</code> 目录创建 <code>sitemap.xml</code>：</p>

<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;urlset xmlns=&quot;http://www.sitemaps.org/schemas/sitemap/0.9&quot;&gt;
  &lt;url&gt;
    &lt;loc&gt;https://moongate.top/&lt;/loc&gt;
    &lt;priority&gt;1.0&lt;/priority&gt;
    &lt;changefreq&gt;daily&lt;/changefreq&gt;
  &lt;/url&gt;
  &lt;url&gt;
    &lt;loc&gt;https://moongate.top/about&lt;/loc&gt;
    &lt;priority&gt;0.5&lt;/priority&gt;
    &lt;changefreq&gt;monthly&lt;/changefreq&gt;
  &lt;/url&gt;
  &lt;!-- 手动添加其他文档 --&gt;
&lt;/urlset&gt;
</code></pre>

<p>在 <code>robots.txt</code> 中声明：</p>

<pre><code class="language-text">Sitemap: https://moongate.top/sitemap.xml
</code></pre>

<h3 id="方案二-动态-sitemap-推荐">方案二：动态 Sitemap（推荐）</h3>

<p>使用 Nuxt 3 服务器路由动态生成，适合经常更新的博客。</p>

<h2 id="完整实现步骤">完整实现步骤</h2>

<h3 id="1-创建动态-sitemap-端点">1. 创建动态 Sitemap 端点</h3>

<pre><code class="language-typescript">// server/routes/sitemap.xml.ts
export default defineEventHandler(async (event) =&gt; {
  const siteUrl = useRuntimeConfig().public.siteUrl;

  try {
    // 1. 获取文档数据
    const docs = await queryCollection(event, &quot;docs&quot;).select(&quot;path&quot;).all();
    const about = await queryCollection(event, &quot;about&quot;).select(&quot;path&quot;).all();

    // 2. 构建URL数组
    const urls = [
      `${siteUrl}`,
      `${siteUrl}/docs`,
      `${siteUrl}/about`,
      `${siteUrl}/404`,
      ...docs.map((doc) =&gt; `${siteUrl}${doc.path}`),
      ...about.map((about) =&gt; `${siteUrl}${about.path}`),
    ];

    // 3. 生成XML（关键修改：添加换行和缩进）
    const xmlLines = [
      '&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;',
      '&lt;urlset xmlns=&quot;http://www.sitemaps.org/schemas/sitemap/0.9&quot;&gt;',
    ];

    // 添加每个URL条目
    urls.forEach((url) =&gt; {
      xmlLines.push(&quot;  &lt;url&gt;&quot;);
      xmlLines.push(`    &lt;loc&gt;${url}&lt;/loc&gt;`);
      xmlLines.push(&quot;  &lt;/url&gt;&quot;);
    });

    // 闭合标签
    xmlLines.push(&quot;&lt;/urlset&gt;&quot;);

    // 组合成最终字符串（用换行符连接）
    const sitemap = xmlLines.join(&quot;\n&quot;);

    // 4. 设置响应头
    setResponseHeader(event, &quot;content-type&quot;, &quot;application/xml&quot;);
    setResponseHeader(event, &quot;Cache-Control&quot;, &quot;public, max-age=3600&quot;);

    return sitemap;
  } catch (error) {
    console.error(&quot;生成Sitemap失败:&quot;, error);

    // 失败时返回最小化版本（同样格式化）
    const fallbackSitemap = `&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;urlset xmlns=&quot;http://www.sitemaps.org/schemas/sitemap/0.9&quot;&gt;
  &lt;url&gt;
    &lt;loc&gt;${siteUrl}&lt;/loc&gt;
  &lt;/url&gt;
&lt;/urlset&gt;`;

    setResponseHeader(event, &quot;content-type&quot;, &quot;application/xml&quot;);
    return fallbackSitemap;
  }
});
</code></pre>

<h3 id="2-配置环境变量">2. 配置环境变量</h3>

<p>在 <code>nuxt.config.ts</code> 中：</p>

<pre><code class="language-typescript">export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      siteUrl: process.env.SITE_URL,
    },
  },
});
</code></pre>

<p>在 <code>.env</code> 文件中：</p>

<pre><code class="language-bash">NUXT_PUBLIC_SITE_URL=https://moongate.top
</code></pre>

<h3 id="3-配置-robots-txt">3. 配置 robots.txt</h3>

<pre><code class="language-typescript">// server/routes/robots.txt.ts
export default defineEventHandler((event) =&gt; {
  const siteUrl = useRuntimeConfig().public.siteUrl;

  return `User-agent: *
Allow: /
Allow: /*.css$
Allow: /*.js$

Sitemap: ${siteUrl}/sitemap.xml`;
});
</code></pre>

<h2 id="生产环境部署要点">生产环境部署要点</h2>

<h3 id="github-actions-配置">GitHub Actions 配置</h3>

<p>确保构建时传递环境变量：</p>

<pre><code class="language-yaml">- name: Build
  run: |
    NUXT_PUBLIC_SITE_URL=https://moongate.top \
    pnpm run build
</code></pre>

<h3 id="pm2-配置文件">PM2 配置文件</h3>

<pre><code class="language-javascript">// ecosystem.config.js
module.exports = {
  apps: [
    {
      name: &quot;moongate&quot;,
      script: &quot;./server/index.mjs&quot;,
      env: {
        NUXT_PUBLIC_SITE_URL: &quot;https://moongate.top&quot;, // 关键！
        NODE_ENV: &quot;production&quot;,
      },
    },
  ],
};
</code></pre>

<h2 id="验证与测试">验证与测试</h2>

<h3 id="1-本地验证">1. 本地验证</h3>

<pre><code class="language-bash"># 检查 XML 格式
curl http://localhost:3000/sitemap.xml | xmllint --format -

# 检查可访问性
curl -I http://localhost:3000/sitemap.xml
</code></pre>

<h3 id="2-在线工具验证">2. 在线工具验证</h3>

<ul>
<li><a href="https://search.google.com/search-console/sitemaps" target="_blank">Google Sitemap 测试工具</a></li>
<li><a href="https://www.xml-sitemaps.com/validate-xml-sitemap.html" target="_blank">XML 验证器</a></li>
</ul>

<h3 id="3-提交到搜索引擎">3. 提交到搜索引擎</h3>

<ol>
<li>Google Search Console → Sitemaps → 提交 URL</li>
<li>Bing Webmaster Tools → Sitemaps</li>
<li>百度搜索资源平台 → 链接提交 → Sitemap</li>
</ol>

<h2 id="常见问题解决">常见问题解决</h2>

<h3 id="q1-sitemap-返回-404">Q1: Sitemap 返回 404</h3>

<ul>
<li>检查文件路径：<code>server/routes/sitemap.xml.ts</code></li>
<li>确认 Nuxt 服务器路由配置正确</li>
</ul>

<h3 id="q2-url-不完整-缺少-https">Q2: URL 不完整（缺少 https://）</h3>

<pre><code class="language-typescript">// 正确：完整的 URL
`${siteUrl}/docs/${slug}`
// 错误：相对路径
`/docs/${slug}`;
</code></pre>

<h3 id="q3-生产环境-siteurl-为空">Q3: 生产环境 siteUrl 为空</h3>

<p>添加需要的环境变量</p>

<pre><code class="language-yaml">- name: Build
  run: |
    NUXT_PUBLIC_SITE_URL=https://moongate.top \
    pnpm run build
</code></pre>

<blockquote>
<p>这确保了 Nuxt 在构建时将 <code>public.siteUrl</code> 正确内嵌到客户端和服务端包中。</p>
</blockquote>

<p>添加ecosystem.config.js配置脚本</p>

<pre><code class="language-yaml">script: |
echo &quot;🚀 启动 Node.js SSR 服务...&quot;
cd /var/www/my-site

# 创建或更新 PM2 配置文件
cat &gt; ecosystem.config.js &lt;&lt; 'EOF'
module.exports = {
  apps: [{
    name: &quot;moongate&quot;,
    script: &quot;./server/index.mjs&quot;,
    instances: 1,
    exec_mode: &quot;fork&quot;,
    env: {
      NODE_ENV: &quot;production&quot;,
      NUXT_PUBLIC_SITE_URL: &quot;https://moongate.top&quot;,
      PORT: 3000,
      HOST: &quot;0.0.0.0&quot;
    }
  }]
}
EOF

echo &quot;📁 PM2 配置文件已生成&quot;

# 使用配置文件管理应用
if pm2 describe moongate &gt; /dev/null 2&gt;&amp;1; then
  echo &quot;🔄 重启现有应用（使用最新配置）...&quot;
  pm2 reload ecosystem.config.js --update-env
else
  echo &quot;🚀 启动新应用...&quot;
  pm2 start ecosystem.config.js
fi

# 保存 PM2 配置以便开机自启
pm2 save

echo &quot;✅ 服务启动完成&quot;
pm2 status moongate
</code></pre>

<h2 id="最佳实践总结">最佳实践总结</h2>

<ol>
<li><strong>使用动态生成</strong>：适合经常更新的博客</li>
<li><strong>包含所有重要页面</strong>：首页、文档页、分类页、关于页</li>
<li><strong>使用绝对 URL</strong>：始终包含 <code>https://</code></li>
<li><strong>设置合理缓存</strong>：<code>Cache-Control: public, max-age=3600</code></li>
<li><strong>监控索引状态</strong>：定期检查 Google Search Console</li>
<li><strong>保持更新</strong>：内容更新后及时更新 <code>lastmod</code> 字段</li>
<li><strong>验证格式</strong>：部署前使用 XML 验证工具检查</li>
</ol>

<h2 id="效果监测">效果监测</h2>

<p>部署 Sitemap 后，关注以下指标：</p>

<ol>
<li><strong>索引覆盖率</strong>（Google Search Console）</li>
<li><strong>爬行统计</strong>：成功 vs 错误的 URL 数量</li>
<li><strong>搜索表现</strong>：关键词排名和点击率变化</li>
<li><strong>收录速度</strong>：新文档从发布到被收录的时间</li>
</ol>

<p>通常配置正确的 Sitemap 能在 1-2 周内显著改善新内容的收录速度。</p>

<hr>

<p><em>本文基于 Nuxt 4 和实际部署经验编写，适用于使用 SSR 或静态生成的博客。根据你的具体需求调整数据源和 URL 结构。</em></p>
]]></content:encoded>
      <description><![CDATA[介绍了 Nuxt 4 博客 Sitemap 配置的基础知识、静态 Sitemap 和动态 Sitemap 的实现方法，并提供了生产环境部署要点。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[SEO]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:subject>P1</dc:subject>
      <dc:relation><![CDATA[series:ecosystem]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[GitHub Actions + Caddy 全自动部署动态网站（动态篇）]]></title>
      <link>https://moongate.top/docs/dynamic-site-auto-deploy</link>
      <guid isPermaLink="true">051f1e4a-1151-49c2-b61b-e3c43859332b</guid>
      <pubDate>Fri, 23 Jan 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共六篇，覆盖从静态网站到生产级 Docker 部署及服务集成的全流程：</p>

<ol>
<li><p><a href="./static-site-auto-deploy"><strong>静态网站自动化部署（静态篇）</strong></a>
—— 纯前端资源的自动化发布，Caddy 自动 HTTPS 和 SPA 路由支持。</p></li>

<li><p><a href="dynamic-site-auto-deploy" target="_blank"><strong>动态网站自动化部署（动态篇）</strong></a>
—— 后端服务进程管理、环境变量注入、数据库迁移，结合 Caddy 反向代理。</p></li>

<li><p><a href="docker-quickstart-auto-deploy" target="_blank"><strong>Docker 极简入门（入门篇）</strong></a>
—— 从零开始用 Docker + GitHub Actions 实现 CI/CD 流水线。</p></li>

<li><p><a href="docker-production-auto-deploy" target="_blank"><strong>Docker 生产级部署（进阶篇）</strong></a>
—— 多容器编排、健康检查、数据库迁移、自动 HTTPS，打造可靠的生产环境。</p></li>

<li><p><a href="./umami-integration-auto-deploy"><strong>自托管 Umami 分析服务与 Nuxt 4 项目集成指南（扩展篇）</strong></a>
—— 在现有 Docker 生产环境中集成 Umami 分析服务，实现自动化数据跟踪与安全加固。</p></li>

<li><p><a href="./vitepress-docker-existing-infrastructure-subdomain-deployment"><strong>VitePress 文档站接入已有 Docker 基础设施：子域名部署（扩展篇）</strong></a>
—— 将 VitePress 静态文档站作为子域名接入现有 Docker 基础设施，复用 Caddy 反向代理与网络。</p></li>
</ol>

<hr>

<blockquote>
<p><strong>适用技术栈</strong>：本教程以 <strong>Nuxt + Drizzle ORM + PostgreSQL</strong> 为例，完整展示一个现代化动态网站的自动化部署流程。整体架构与配置方法同样适用于其他 Node.js 框架（Express、NestJS）或其他语言的技术栈，只需替换对应的构建、迁移、进程管理命令即可。</p>
</blockquote>

<p>本教程将指导你搭建一套 <strong>“代码推送即发布”</strong> 的自动化部署系统，实现从本地 <code>git push</code> 到服务器服务热重启的全流程无人值守。</p>

<hr>

<h2 id="版本声明">📌 版本声明</h2>

<p>本文档所有工具均采用 <strong>2026 年最新稳定版</strong>，具体版本如下：</p>

<table>
<thead>
<tr>
<th>工具</th>
<th>版本</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td>Node.js</td>
<td>24.x</td>
<td>最新的主要版本，支持所有现代 JavaScript 特性</td>
</tr>

<tr>
<td>pnpm</td>
<td>10.x</td>
<td>高性能包管理器，与 Node.js 24 完美兼容</td>
</tr>

<tr>
<td>Caddy</td>
<td>2.8+</td>
<td>自动 HTTPS 的反向代理服务器</td>
</tr>

<tr>
<td>PostgreSQL</td>
<td>17 (alpine)</td>
<td>轻量级关系型数据库，alpine 版本镜像小巧</td>
</tr>

<tr>
<td>Drizzle ORM</td>
<td>0.30+</td>
<td>TypeScript 原生 ORM，支持迁移和类型安全查询</td>
</tr>

<tr>
<td>PM2</td>
<td>5+</td>
<td>生产级 Node.js 进程管理工具</td>
</tr>

<tr>
<td>GitHub Actions</td>
<td>最新</td>
<td>CI/CD 平台，所有 Action 均为当前最新版本（如 <code>checkout@v4</code>、<code>ssh-action@v1.0.0</code> 等）</td>
</tr>

<tr>
<td>阿里云 ACR</td>
<td>–</td>
<td>容器镜像服务，需使用固定密码进行认证</td>
</tr>
</tbody>
</table>

<blockquote>
<p><strong>注意</strong>：请根据你的项目实际需求调整具体版本号。若使用其他技术栈（如 Python、Java 等），请替换对应的运行时版本。</p>
</blockquote>

<hr>

<h2 id="系统架构与核心理念">🎯 系统架构与核心理念</h2>

<p>动态网站部署需要处理：</p>

<ol>
<li>运行时环境安装</li>
<li>项目依赖安装</li>
<li>数据库迁移（使用 Drizzle ORM）</li>
<li>应用进程管理（PM2）</li>
<li>反向代理与 HTTPS（Caddy）</li>
</ol>

<p>整个流程基于 <strong>声明式自动化</strong>，通过 GitHub Actions 串联所有步骤。</p>

<pre><code class="language-bash">开发者本地 (Local)
↓ [git push]
GitHub 仓库 (Repository)
↓ [触发]
GitHub Actions (CI/CD 管道)
├─ 检出代码
├─ 安装依赖
├─ 运行测试（可选）
├─ 构建项目（Nuxt 生成 .output）
├─ 同步代码至服务器
└─ 远程执行命令（依赖安装、迁移、重启）
阿里云服务器 (ECS)
├─ PM2 守护应用进程
├─ Caddy 反向代理 + 自动 HTTPS
└─ PostgreSQL 数据库
用户访问 → HTTPS → Caddy → 应用（.output/server/index.mjs）→ 数据库
</code></pre>

<hr>

<h2 id="前置准备">📦 前置准备</h2>

<ol>
<li><strong>一个 GitHub 仓库</strong>，包含你的动态网站源码，并已集成 Drizzle ORM。

<ul>
<li>确保项目包含 <code>drizzle.config.ts</code>、数据库 schema 文件（如 <code>server/db/schema.ts</code>）。</li>
<li><strong>注意 Drizzle 迁移目录可能不同</strong>：默认生成在 <code>drizzle</code> 目录，但你可能配置为 <code>.drizzle</code> 或其他名称。请根据你的 <code>drizzle.config.ts</code> 中的 <code>out</code> 字段确认实际目录，并在后续步骤中保持一致。</li>
<li><strong>迁移文件必须提前生成并提交到 Git</strong>：在本地运行 <code>pnpm drizzle-kit generate</code> 生成初始迁移文件，然后 <code>git add</code> 并提交。</li>
</ul></li>
<li><strong>一台云服务器</strong>（阿里云 ECS 等），建议 Ubuntu 24.04（确保支持最新软件包）。

<ul>
<li>安全组必须开放：<strong>SSH(22)</strong>、<strong>HTTP(80)</strong>、<strong>HTTPS(443)</strong> 端口。</li>
</ul></li>
<li><strong>一个域名</strong>（强烈推荐，用于自动 HTTPS），并已解析到服务器 IP。</li>
<li><strong>PostgreSQL 数据库</strong>（可安装在服务器上，或使用云数据库如阿里云 RDS）。无论数据库在哪，你需要一个可访问的连接字符串（<code>DATABASE_URL</code>）。</li>
</ol>

<hr>

<h2 id="第一部分-服务器初始化">🚀 第一部分：服务器初始化</h2>

<h3 id="1-1-登录并安装基础软件">1.1 登录并安装基础软件</h3>

<p>通过 SSH 登录你的云服务器。<strong>注意</strong>：下文假设用户名为 <code>ubuntu</code>，请根据你的实际用户名替换。</p>

<pre><code class="language-bash"># 更新系统包
sudo apt update &amp;&amp; sudo apt upgrade -y

# 安装 Caddy（使用官方仓库，确保最新版本）
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy

# 验证 Caddy 版本（需 &gt;= 2.6.0）
caddy version

# 安装 Node.js 24（当前最新版本）
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install -y nodejs
node --version  # 应输出 v24.x.x

# 启用 corepack 以使用 pnpm
sudo corepack enable
# 安装 pnpm 10 最新版
corepack prepare pnpm@latest --activate
pnpm --version  # 应输出 10.x.x

# 安装 PM2 全局进程管理（使用 sudo 确保权限）
sudo npm install -g pm2
# 或者用 pnpm（但注意全局路径）：
# sudo pnpm add -g pm2

# 安装 PostgreSQL 客户端（可选，用于调试）
sudo apt install -y postgresql-client

# 创建应用目录，并设置正确所有者（使用你的实际用户名）
sudo mkdir -p /var/www/my-dynamic-app
sudo chown -R ubuntu:ubuntu /var/www/my-dynamic-app
</code></pre>

<h3 id="1-2-配置-caddy-作为反向代理">1.2 配置 Caddy 作为反向代理</h3>

<p>编辑 Caddy 配置文件：</p>

<pre><code class="language-bash">sudo nano /etc/caddy/Caddyfile
</code></pre>

<p>写入以下内容（<strong>务必替换 <code>example.com</code> 为你的域名</strong>，Nuxt 默认运行在 3000 端口）：</p>

<pre><code class="language-bash">example.com, www.example.com {
    # 反向代理到本地 Nuxt 应用进程
    reverse_proxy 127.0.0.1:3000
    # 启用压缩
    encode gzip zstd
}
</code></pre>

<blockquote>
<p><strong>⚠️ 重要</strong>：Nuxt 应用在运行时必须监听 <code>127.0.0.1</code>（即 localhost），以确保只能通过 Caddy 访问。在 Nuxt 中默认监听所有接口，你需要在启动命令中指定 <code>HOST=127.0.0.1</code> 或通过环境变量控制。</p>
</blockquote>

<p>验证并重启 Caddy：</p>

<pre><code class="language-bash">sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl restart caddy
sudo systemctl status caddy
</code></pre>

<blockquote>
<p>如果使用域名，Caddy 会在首次 HTTPS 请求时自动申请 Let&rsquo;s Encrypt 证书。</p>
</blockquote>

<h3 id="1-3-准备数据库连接">1.3 准备数据库连接</h3>

<p>无论数据库在服务器本地还是云端，你都需要一个有效的连接字符串，格式如：</p>

<pre><code class="language-bash">postgresql://用户名:密码@主机:端口/数据库名
</code></pre>

<ul>
<li><strong>如果数据库在服务器本地</strong>（通过 apt 安装）：</li>
</ul>

<pre><code class="language-bash">  sudo apt install -y postgresql
  sudo systemctl start postgresql
  sudo systemctl enable postgresql
  sudo -u postgres psql
  # 在 psql 中执行：
  CREATE DATABASE mydb;
  CREATE USER myuser WITH ENCRYPTED PASSWORD 'mypassword';
  GRANT ALL PRIVILEGES ON DATABASE mydb TO myuser;
  \q
</code></pre>

<p>此时连接字符串为 <code>postgresql://myuser:mypassword@localhost:5432/mydb</code>。</p>

<ul>
<li><strong>如果使用云数据库</strong>（如阿里云 RDS），直接在控制台获取连接串。</li>
</ul>

<p>请确保你的数据库可以从服务器访问（如果是云数据库，需在安全组中放行服务器 IP）。</p>

<hr>

<h2 id="第二部分-配置-ssh-密钥对与-github-secrets">🔐 第二部分：配置 SSH 密钥对与 GitHub Secrets</h2>

<h3 id="2-1-在本地生成-ssh-密钥对">2.1 在本地生成 SSH 密钥对</h3>

<p>在你的<strong>本地电脑</strong>执行：</p>

<pre><code class="language-bash">ssh-keygen -t ed25519 -f ~/.ssh/id_github_actions_dynamic -N &quot;&quot;
</code></pre>

<h3 id="2-2-将公钥部署到服务器">2.2 将公钥部署到服务器</h3>

<p>复制公钥内容：</p>

<pre><code class="language-bash">cat ~/.ssh/id_github_actions_dynamic.pub
</code></pre>

<p>登录服务器，将公钥添加到 <code>~/.ssh/authorized_keys</code>：</p>

<pre><code class="language-bash">echo '你的公钥内容' &gt;&gt; ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
chmod 700 ~/.ssh
</code></pre>

<h3 id="2-3-将私钥配置为-github-secrets">2.3 将私钥配置为 GitHub Secrets</h3>

<p>复制私钥内容（<strong>务必包含 <code>-----BEGIN OPENSSH PRIVATE KEY-----</code> 和 <code>-----END OPENSSH PRIVATE KEY-----</code> 行，保持完整格式</strong>）：</p>

<pre><code class="language-bash">cat ~/.ssh/id_github_actions_dynamic
</code></pre>

<p>进入 GitHub 仓库 → <strong>Settings</strong> → <strong>Secrets and variables</strong> → <strong>Actions</strong>，点击 <strong>New repository secret</strong>，添加以下 Secrets：</p>

<table>
<thead>
<tr>
<th>Secret 名称</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>SERVER_HOST</code></td>
<td>服务器公网 IP</td>
</tr>

<tr>
<td><code>SERVER_USER</code></td>
<td>SSH 用户名（如 <code>ubuntu</code>）</td>
</tr>

<tr>
<td><code>SSH_PRIVATE_KEY</code></td>
<td>上面复制的私钥全文（保持换行）</td>
</tr>

<tr>
<td><code>DATABASE_URL</code></td>
<td>数据库连接字符串</td>
</tr>

<tr>
<td>其他环境变量</td>
<td>如 <code>NUXT_SESSION_PASSWORD</code>、<code>NUXT_OAUTH_GITHUB_CLIENT_ID</code> 等</td>
</tr>
</tbody>
</table>

<blockquote>
<p><strong>💡 提示</strong>：如果私钥内容在 GitHub Secrets 中粘贴后丢失换行，会导致 SSH 连接失败。请确保原样粘贴。</p>
</blockquote>

<hr>

<h2 id="第三部分-创建-github-actions-工作流">⚙️ 第三部分：创建 GitHub Actions 工作流</h2>

<p>在项目根目录创建 <code>.github/workflows/deploy.yml</code>。以下是一个完整的、适配 <strong>Node.js 24 + pnpm 10 + Nuxt + Drizzle ORM</strong> 的示例。</p>

<pre><code class="language-yaml">name: Deploy Dynamic App to Production

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      # 设置 Node.js 24 环境
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: &quot;24&quot; # 使用 Node.js 24
          cache: &quot;pnpm&quot; # 启用 pnpm 缓存

      # 启用 corepack 并安装 pnpm 10
      - name: Install pnpm
        run: |
          corepack enable
          corepack prepare pnpm@latest --activate
          pnpm --version  # 应输出 10.x

      # 安装依赖（包含所有依赖）
      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      # 可选：运行测试
      - name: Run tests
        run: pnpm test
        continue-on-error: true

      # 构建 Nuxt 应用（生成 .output 目录）
      - name: Build Nuxt app
        run: pnpm run build
        env:
          # 构建时可能需要的环境变量
          NUXT_PUBLIC_SITE_URL: ${{ secrets.NUXT_PUBLIC_SITE_URL }}
          DATABASE_URL: ${{ secrets.DATABASE_URL }}

      # 同步代码到服务器（排除不需要的文件）
      # 注意：burnett01/rsync-deployments 是一个社区维护的 action，你可以审查其源码：
      # https://github.com/burnett01/rsync-deployments
      - name: Deploy to Server via Rsync
        uses: burnett01/rsync-deployments@7.0.1
        with:
          switches: -avz --delete --exclude='.env' --exclude='node_modules' --exclude='.git'
          path: ./ # 同步整个项目（.output 是构建产物，需要同步）
          remote_path: /var/www/my-dynamic-app/
          remote_host: ${{ secrets.SERVER_HOST }}
          remote_user: ${{ secrets.SERVER_USER }}
          remote_key: ${{ secrets.SSH_PRIVATE_KEY }}

      # 在服务器上执行远程命令（核心步骤）
      - name: Remote execution
        uses: appleboy/ssh-action@v1.0.0
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          NUXT_SESSION_PASSWORD: ${{ secrets.NUXT_SESSION_PASSWORD }}
          NUXT_OAUTH_GITHUB_CLIENT_ID: ${{ secrets.NUXT_OAUTH_GITHUB_CLIENT_ID }}
          NUXT_OAUTH_GITHUB_CLIENT_SECRET: ${{ secrets.NUXT_OAUTH_GITHUB_CLIENT_SECRET }}
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          envs: DATABASE_URL, NUXT_SESSION_PASSWORD, NUXT_OAUTH_GITHUB_CLIENT_ID, NUXT_OAUTH_GITHUB_CLIENT_SECRET
          script: |
            set -e  # 遇到任何错误立即退出

            cd /var/www/my-dynamic-app

            # 创建环境变量文件（一次性写入，注意不要用单引号，否则变量不会展开）
            cat &gt; .env &lt;&lt; EOF
            DATABASE_URL=$DATABASE_URL
            NUXT_SESSION_PASSWORD=$NUXT_SESSION_PASSWORD
            NUXT_OAUTH_GITHUB_CLIENT_ID=$NUXT_OAUTH_GITHUB_CLIENT_ID
            NUXT_OAUTH_GITHUB_CLIENT_SECRET=$NUXT_OAUTH_GITHUB_CLIENT_SECRET
            NODE_ENV=production
            EOF

            # 设置 .env 文件权限，防止其他用户读取
            chmod 600 .env

            # 启用 corepack 并安装 pnpm（服务器上可能没有）
            export PNPM_HOME=~/.local/share/pnpm
            export PATH=$PNPM_HOME:$PATH
            if ! command -v pnpm &amp;&gt; /dev/null; then
              corepack enable
              corepack prepare pnpm@latest --activate
            fi

            # 安装生产依赖
            pnpm install --prod --frozen-lockfile

            # 执行数据库迁移
            # 注意：请根据你的 drizzle.config.ts 中的 out 目录调整
            # 默认是 drizzle，也可能是 .drizzle 或其他名称
            # 迁移需要 drizzle-kit，临时安装
            pnpm add -D drizzle-kit
            npx drizzle-kit migrate
            # 迁移完成后可卸载（可选）
            # pnpm remove drizzle-kit

            # 重启应用（使用 PM2）
            # 注意：如果使用 Nuxt，启动命令应为：
            # pm2 start .output/server/index.mjs --name &quot;my-app&quot; -- --host 127.0.0.1
            if pm2 describe my-app &gt; /dev/null 2&gt;&amp;1; then
              pm2 reload my-app
            else
              pm2 start npm --name &quot;my-app&quot; -- start
              pm2 save
              pm2 startup
            fi

            pm2 save
</code></pre>

<p><strong>关键说明</strong>：</p>

<ul>
<li><strong>Node.js 24 + pnpm 10</strong>：所有步骤都针对最新版本优化，包括 corepack 的启用和 pnpm 的安装方式。</li>
<li><strong>Nuxt 构建产物</strong>：<code>.output</code> 目录必须被同步，rsync 命令中<strong>没有排除</strong> <code>.output</code>。</li>
<li><strong>Drizzle 迁移目录</strong>：请根据你的 <code>drizzle.config.ts</code> 中的 <code>out</code> 字段确认迁移文件目录（可能是 <code>drizzle</code>、<code>.drizzle</code> 或自定义名称）。该目录必须被同步到服务器，因此不应在 rsync 中排除。</li>
<li><strong>环境变量</strong>：所有运行时需要的变量通过 GitHub Secrets 传递，并在远程脚本中写入 <code>.env</code>（注意此处使用 <code>&lt;&lt; EOF</code> 而非 <code>'EOF'</code>，确保变量正确展开）。</li>
<li><strong>PM2 启动命令</strong>：Nuxt 的启动入口是 <code>.output/server/index.mjs</code>，并通过 <code>--host 127.0.0.1</code> 确保只监听本地。</li>
<li><strong>服务器上的 pnpm</strong>：远程脚本中检测并安装 pnpm，确保服务器环境一致。</li>
<li><strong>迁移依赖</strong>：临时安装 <code>drizzle-kit</code> 执行迁移，之后可卸载，保持生产环境干净。</li>
</ul>

<hr>

<h2 id="第四部分-触发首次部署与验证">🧪 第四部分：触发首次部署与验证</h2>

<h3 id="4-1-提交并推送代码">4.1 提交并推送代码</h3>

<pre><code class="language-bash">git add .github/workflows/deploy.yml
git commit -m &quot;ci: 添加动态网站自动化部署&quot;
git push origin main
</code></pre>

<h3 id="4-2-监控部署过程">4.2 监控部署过程</h3>

<p>在 GitHub 仓库的 <strong>Actions</strong> 标签页查看运行状态。成功时所有步骤应为绿色。</p>

<h3 id="4-3-验证服务">4.3 验证服务</h3>

<ul>
<li>访问 <code>https://你的域名</code>，确认网站功能正常。</li>
<li>检查服务器进程：<code>pm2 status</code> 应显示 <code>nuxt-app</code> 为 <code>online</code>。</li>
<li>查看应用日志：<code>pm2 logs nuxt-app</code>。</li>
<li>查看 Caddy 日志：<code>sudo journalctl -u caddy -f</code>。</li>
</ul>

<hr>

<h2 id="第五部分-高级配置与问题排查">🔧 第五部分：高级配置与问题排查</h2>

<h3 id="5-1-drizzle-迁移的最佳实践">5.1 Drizzle 迁移的最佳实践</h3>

<ul>
<li><strong>迁移文件必须提交到 Git</strong>，确保 CI 和服务器能获取到相同的迁移历史。注意你的迁移目录可能是 <code>.drizzle</code>（以点开头），在 Git 中需要显式添加（<code>git add .drizzle</code>）。</li>
<li><strong>首次部署前</strong>，在本地运行 <code>pnpm drizzle-kit generate</code> 生成初始迁移文件并提交。后续每次修改 schema 后，同样在本地生成新迁移文件并提交。</li>
<li><strong>迁移目录的 rsync 排除</strong>：确保你的迁移目录<strong>不被 rsync 排除</strong>。检查 <code>--exclude</code> 参数中是否误排了类似 <code>.drizzle</code> 的目录。</li>
</ul>

<h3 id="5-2-nuxt-应用监听地址">5.2 Nuxt 应用监听地址</h3>

<p>确保 Nuxt 应用监听 <code>127.0.0.1</code> 而非 <code>0.0.0.0</code>。可以通过以下方式之一实现：</p>

<ul>
<li>在启动命令中指定：<code>pm2 start .output/server/index.mjs --name &quot;nuxt-app&quot; -- --host 127.0.0.1</code></li>
<li>或在 <code>nuxt.config.ts</code> 中配置：

<pre><code class="language-ts">
export default defineNuxtConfig({
nitro: {
  devServer: {
    host: &quot;127.0.0.1&quot;,
  },
},
});
</code></pre>
</li>
</ul>

<h3 id="5-3-环境变量安全">5.3 环境变量安全</h3>

<ul>
<li>不要在代码中硬编码敏感信息，全部通过 GitHub Secrets 注入。</li>
<li>服务器上的 <code>.env</code> 文件权限设为 <code>600</code>（已在脚本中执行 <code>chmod 600 .env</code>）。</li>
</ul>

<h3 id="5-4-pm2-开机自启的完整操作">5.4 PM2 开机自启的完整操作</h3>

<ol>
<li>首次部署成功后，登录服务器。</li>
<li>执行 <code>pm2 startup</code>，复制输出的带有 <code>sudo</code> 的命令（如 <code>sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u ubuntu --hp /home/ubuntu</code>）。</li>
<li>粘贴并执行该命令，输入服务器密码（如果需要）。</li>
<li>之后执行 <code>pm2 save</code> 确保当前进程列表被保存。</li>
</ol>

<h3 id="5-5-关键问题排查清单">5.5 关键问题排查清单</h3>

<p>| 现象                             | 可能原因                                                             | 解决方案                                                                                                       |</p>

<table>
<tbody>
<tr>
<td><strong>Actions 日志卡在 SSH 连接</strong></td>
<td>SSH 密钥格式错误、安全组未开放 22 端口、服务器 <code>sshd_config</code> 限制</td>
<td>检查 Secrets 中的私钥是否包含完整换行；检查安全组入方向规则；查看服务器 <code>/var/log/auth.log</code> 寻找原因</td>
<td></td>
</tr>

<tr>
<td><strong>Caddy 返回 502 Bad Gateway</strong></td>
<td>后端应用未运行、Caddy 配置端口错误、应用绑定地址不是 127.0.0.1</td>
<td>检查 <code>pm2 status</code>；确认 Caddyfile 中的端口；确保应用监听 <code>127.0.0.1</code></td>
<td></td>
</tr>

<tr>
<td><strong>应用启动失败</strong></td>
<td>依赖未安装、环境变量缺失、端口被占用、入口文件路径错误</td>
<td>登录服务器手动运行 <code>pnpm install</code>；检查 <code>.env</code> 文件；`netstat -tlnp</td>
<td>grep 3000<code>查看端口占用；确认</code>.output/server/index.mjs` 是否存在</td>
</tr>

<tr>
<td><strong>数据库迁移失败</strong></td>
<td>数据库连接串错误、迁移文件缺失、数据库服务未启动、drizzle-kit 未安装</td>
<td>检查 <code>DATABASE_URL</code> 是否正确；确认迁移目录（如 <code>.drizzle</code>）存在；检查数据库服务状态；确保 <code>drizzle-kit</code> 已安装</td>
<td></td>
</tr>

<tr>
<td><strong>迁移目录找不到</strong></td>
<td>rsync 排除了点开头的目录</td>
<td>检查 rsync 命令的 <code>--exclude</code> 参数，确保没有排除 <code>.drizzle</code> 或你的自定义迁移目录</td>
<td></td>
</tr>

<tr>
<td><strong>pnpm 命令未找到</strong></td>
<td>服务器未安装 pnpm，或 PATH 未设置</td>
<td>检查远程脚本中是否正确安装了 pnpm，并设置了 <code>PATH</code></td>
<td></td>
</tr>

<tr>
<td><strong>网站 HTTPS 证书未自动生成</strong></td>
<td>域名 DNS 未生效、Caddy 版本过旧、80/443 端口未开放</td>
<td>检查 DNS 解析；升级 Caddy 到最新版；检查安全组端口</td>
<td></td>
</tr>

<tr>
<td><strong>PM2 进程在服务器重启后未恢复</strong></td>
<td>未执行 <code>pm2 startup</code> 后的 sudo 命令</td>
<td>登录服务器，重新执行 <code>pm2 startup</code> 并根据提示运行 sudo 命令</td>
<td></td>
</tr>
</tbody>
</table>

<h3 id="5-6-查看日志">5.6 查看日志</h3>

<pre><code class="language-bash"># 查看应用日志
pm2 logs nuxt-app

# 查看 Caddy 日志
sudo journalctl -u caddy -f

# 查看系统认证日志（SSH 问题）
sudo tail -f /var/log/auth.log
</code></pre>

<hr>

<h2 id="总结-你现在拥有了什么">📈 总结：你现在拥有了什么</h2>

<ol>
<li><strong>全自动部署</strong>：一次 <code>git push</code>，从代码到服务更新全部自动化。</li>
<li><strong>最新工具链</strong>：Node.js 24 + pnpm 10，享受最新性能和特性。</li>
<li><strong>Nuxt 专属优化</strong>：正确处理 <code>.output</code> 构建产物和启动方式。</li>
<li><strong>数据库迁移集成</strong>：Drizzle ORM 的迁移在部署时自动执行，支持自定义迁移目录（如 <code>.drizzle</code>）。</li>
<li><strong>进程守护</strong>：PM2 保证应用持续运行，崩溃自动重启。</li>
<li><strong>自动 HTTPS</strong>：Caddy 自动申请和续期 SSL 证书。</li>
<li><strong>安全可控</strong>：所有敏感信息通过 GitHub Secrets 管理，服务器上的 <code>.env</code> 文件权限严格。</li>
<li><strong>跨技术栈适配</strong>：本教程的结构可轻松迁移到其他语言和框架。</li>
</ol>

<p><strong>下一步</strong>：如果你的项目需要更复杂的多容器编排（如应用、数据库、Redis 等），可以考虑迁移到 Docker 部署（参见本系列《进阶 Docker 篇》）。</p>
]]></content:encoded>
      <description><![CDATA[深入后端服务的进程管理、环境变量注入、数据库迁移，结合 Caddy 反向代理，打造完整的动态应用部署流水线。]]></description>
      <category><![CDATA[Caddy]]></category>
      <category><![CDATA[CI/CD]]></category>
      <dc:subject>P4</dc:subject>
      <dc:relation><![CDATA[series:deployment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[GitHub Actions + Caddy 静态网站自动化部署（静态篇）]]></title>
      <link>https://moongate.top/docs/static-site-auto-deploy</link>
      <guid isPermaLink="true">8ba35286-9f24-4067-81c0-32e3671d5284</guid>
      <pubDate>Thu, 22 Jan 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="系列导航">📚 系列导航</h2>

<p>本系列共六篇，覆盖从静态网站到生产级 Docker 部署及服务集成的全流程：</p>

<ol>
<li><p><a href="./static-site-auto-deploy"><strong>静态网站自动化部署（静态篇）</strong></a>
—— 纯前端资源的自动化发布，Caddy 自动 HTTPS 和 SPA 路由支持。</p></li>

<li><p><a href="dynamic-site-auto-deploy" target="_blank"><strong>动态网站自动化部署（动态篇）</strong></a>
—— 后端服务进程管理、环境变量注入、数据库迁移，结合 Caddy 反向代理。</p></li>

<li><p><a href="docker-quickstart-auto-deploy" target="_blank"><strong>Docker 极简入门（入门篇）</strong></a>
—— 从零开始用 Docker + GitHub Actions 实现 CI/CD 流水线。</p></li>

<li><p><a href="docker-production-auto-deploy" target="_blank"><strong>Docker 生产级部署（进阶篇）</strong></a>
—— 多容器编排、健康检查、数据库迁移、自动 HTTPS，打造可靠的生产环境。</p></li>

<li><p><a href="./umami-integration-auto-deploy"><strong>自托管 Umami 分析服务与 Nuxt 4 项目集成指南（扩展篇）</strong></a>
—— 在现有 Docker 生产环境中集成 Umami 分析服务，实现自动化数据跟踪与安全加固。</p></li>

<li><p><a href="./vitepress-docker-existing-infrastructure-subdomain-deployment"><strong>VitePress 文档站接入已有 Docker 基础设施：子域名部署（扩展篇）</strong></a>
—— 将 VitePress 静态文档站作为子域名接入现有 Docker 基础设施，复用 Caddy 反向代理与网络。</p></li>
</ol>

<hr>

<p>本教程将完整复现一个现代化静态网站从本地开发到自动化部署的全流程。你将搭建一套 <strong>“Git推送即发布”</strong> 的自动化系统，无需手动操作服务器。教程基于 <strong>Nuxt.js</strong> 静态生成，但核心流程适用于任何静态网站（如VitePress、Next.js SSG、Hugo等）。</p>

<blockquote>
<p><strong>最终效果</strong>：本地 <code>git push</code> → 自动构建、测试 → 安全同步至云服务器 → 网站即刻更新（HTTPS自动启用）。</p>
</blockquote>

<hr>

<h2 id="版本声明">📌 版本声明</h2>

<p>本文档所有工具均采用 <strong>2026 年最新稳定版</strong>，具体版本如下：</p>

<table>
<thead>
<tr>
<th>工具</th>
<th>版本</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td>Node.js</td>
<td>24.x</td>
<td>最新的主要版本，支持所有现代 JavaScript 特性</td>
</tr>

<tr>
<td>pnpm</td>
<td>10.x</td>
<td>高性能包管理器，与 Node.js 24 完美兼容</td>
</tr>

<tr>
<td>Caddy</td>
<td>2.8+</td>
<td>自动 HTTPS 的反向代理服务器</td>
</tr>

<tr>
<td>GitHub Actions</td>
<td>最新</td>
<td>CI/CD 平台，所有 Action 均为当前最新版本（如 <code>checkout@v4</code>、<code>ssh-action@v1.0.0</code> 等）</td>
</tr>

<tr>
<td>阿里云 ACR</td>
<td>–</td>
<td>容器镜像服务，需使用固定密码进行认证</td>
</tr>
</tbody>
</table>

<blockquote>
<p><strong>注意</strong>：请根据你的项目实际需求调整具体版本号。若使用其他技术栈（如 Python、Java 等），请替换对应的运行时版本。</p>
</blockquote>

<hr>

<h2 id="系统架构与核心理念">🎯 系统架构与核心理念</h2>

<p>这套方案的核心是 <strong>“声明式自动化”</strong>：你只需在代码仓库中声明“做什么”（配置文件），GitHub Actions 和 Caddy 就会自动执行“怎么做”。</p>

<pre><code class="language-text">开发者本地 (Local)
↓ [git push]
GitHub 仓库 (Repository)
↓ [触发]
GitHub Actions (CI/CD 管道)
↓ [构建、测试、同步]
阿里云服务器 (Alibaba Cloud ECS)
↓ [Caddy 提供 HTTPS 服务]
用户访问 (HTTPS Website)
</code></pre>

<h2 id="前置准备">📦 前置准备</h2>

<ol>
<li><strong>一个 GitHub 仓库</strong></li>
<li><strong>一台阿里云 ECS 实例</strong>（或任何具有公网 IP 的 Linux 服务器）

<ul>
<li>推荐系统：Ubuntu 22.04 / Alibaba Cloud Linux 3</li>
<li><strong>安全组必须开放</strong>：<strong>SSH(22)</strong>、<strong>HTTP(80)</strong>、<strong>HTTPS(443)</strong> 端口（请登录阿里云控制台检查确认）</li>
</ul></li>
<li><strong>一个域名</strong>（可选，但推荐。教程以 <code>example.com</code> 为例）</li>
</ol>

<hr>

<h2 id="第一部分-服务器初始化">🚀 第一部分：服务器初始化</h2>

<h3 id="1-1-登录并安装基础软件">1.1 登录并安装基础软件</h3>

<p>通过 SSH 登录你的云服务器（假设登录用户名为 <code>your-user</code>，后续步骤中请将 <code>$USER</code> 替换为实际用户名）。</p>

<pre><code class="language-bash"># 更新系统包
sudo apt update &amp;&amp; sudo apt upgrade -y

# 安装 Caddy（现代化的 Web 服务器，自动 HTTPS）
sudo apt install caddy

# 安装 Node.js 环境
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install -y nodejs

# 创建网站根目录，并将所有权交给当前登录用户（确保后续 rsync 有写入权限）
sudo mkdir -p /var/www/my-site
sudo chown -R $USER:$USER /var/www/my-site
</code></pre>

<h3 id="1-2-配置-caddy">1.2 配置 Caddy</h3>

<p>编辑 Caddy 的主配置文件，告诉它如何服务你的网站。</p>

<pre><code class="language-bash">sudo nano /etc/caddy/Caddyfile
</code></pre>

<p>粘贴以下配置，<strong>请务必将 <code>example.com</code> 替换为你的真实域名</strong>。如果没有域名，可以用 <code>http://你的服务器IP</code> 格式，但将无法享受自动 HTTPS。</p>

<pre><code class="language-bash">example.com, www.example.com {
    # 网站根目录（必须与后续自动化部署的目录一致）
    root * /var/www/my-site
    # 启用静态文件服务器
    file_server
    # 对单页应用(SPA)至关重要：使前端路由正常工作
    try_files {path} /index.html
    # 启用压缩
    encode gzip zstd
}
</code></pre>

<p>保存退出后（在 nano 中：<code>Ctrl+X</code>，然后 <code>Y</code>，再 <code>Enter</code>），重启 Caddy 使配置生效。</p>

<pre><code class="language-bash"># 检查配置语法
sudo caddy validate --config /etc/caddy/Caddyfile
# 重启服务
sudo systemctl restart caddy
# 检查服务状态
sudo systemctl status caddy
</code></pre>

<blockquote>
<p><strong>💡 提示</strong>：看到 <code>active (running)</code> 状态即表示 Caddy 已就绪。如果使用域名，Caddy 会在首次访问时<strong>自动申请并配置 Let&rsquo;s Encrypt SSL 证书</strong>。</p>
</blockquote>

<hr>

<h2 id="第二部分-配置-ssh-密钥对与-github-secrets">🔐 第二部分：配置 SSH 密钥对与 GitHub Secrets</h2>

<p>自动化部署的核心是让 GitHub Actions 能安全地连接到你的服务器。我们使用 SSH 密钥对进行认证。</p>

<h3 id="2-1-在本地生成-ssh-密钥对">2.1 在本地生成 SSH 密钥对</h3>

<p>在你的<strong>本地电脑</strong>（而非服务器）上执行：</p>

<pre><code class="language-bash"># 生成一对新的密钥，专用于自动化部署
ssh-keygen -t ed25519 -f ~/.ssh/id_github_actions -N &quot;&quot;
</code></pre>

<p>这将生成两个文件：</p>

<ul>
<li><strong>私钥</strong> (<code>~/.ssh/id_github_actions</code>)：<strong>绝密</strong>，相当于你的“钥匙”。</li>
<li><strong>公钥</strong> (<code>~/.ssh/id_github_actions.pub</code>)：可以公开，相当于“锁芯”。</li>
</ul>

<h3 id="2-2-将公钥部署到服务器">2.2 将公钥部署到服务器</h3>

<ol>
<li>复制公钥内容：</li>
</ol>

<pre><code class="language-bash">   cat ~/.ssh/id_github_actions.pub
</code></pre>

<ol>
<li><strong>登录你的云服务器</strong>，将公钥添加到授权列表：</li>
</ol>

<pre><code class="language-bash">   # 将上一步复制的公钥内容，粘贴到引号内，然后执行整条命令
   echo '你的公钥内容' &gt;&gt; ~/.ssh/authorized_keys
   # 设置正确的权限（非常重要！）
   chmod 600 ~/.ssh/authorized_keys
   chmod 700 ~/.ssh
</code></pre>

<h3 id="2-3-将私钥配置为-github-secrets">2.3 将私钥配置为 GitHub Secrets</h3>

<ol>
<li>查看私钥内容：</li>
</ol>

<pre><code class="language-bash">   cat ~/.ssh/id_github_actions
</code></pre>

<ol>
<li>进入你的 GitHub 仓库，点击 <strong>Settings</strong> → <strong>Secrets and variables</strong> → <strong>Actions</strong>。</li>
<li>点击 <strong>New repository secret</strong>，添加以下三个密钥：

<ul>
<li><strong><code>SERVER_HOST</code></strong>：你的云服务器<strong>公网 IP 地址</strong>。</li>
<li><strong><code>SERVER_USER</code></strong>：用于 SSH 登录的用户名（例如 <code>root</code>、<code>ubuntu</code> 或你在服务器上使用的用户名）。</li>
<li><strong><code>SSH_PRIVATE_KEY</code></strong>：粘贴你刚刚复制的<strong>完整私钥内容</strong>（包括 <code>-----BEGIN OPENSSH PRIVATE KEY-----</code> 和 <code>-----END OPENSSH PRIVATE KEY-----</code> 行）。</li>
</ul></li>
</ol>

<blockquote>
<p><strong>提示</strong>：如果你的网站构建时需要环境变量（如 <code>NUXT_PUBLIC_API_BASE</code>），请一并添加到 Secrets 中，后续会在构建步骤使用。</p>
</blockquote>

<hr>

<h2 id="第三部分-创建-github-actions-工作流">⚙️ 第三部分：创建 GitHub Actions 工作流</h2>

<p>这是自动化的“大脑”。在你的项目根目录创建文件：<code>.github/workflows/deploy.yml</code></p>

<pre><code class="language-yaml">name: Deploy to Production

on:
  push:
    branches: [main] # 仅在推送到 main 分支时触发

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      # 1. 拉取代码
      - name: Checkout code
        uses: actions/checkout@v4

      # 2. 设置 Node.js 环境 (以 Nuxt 项目为例)
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: &quot;24&quot; # 使用你项目所需的 Node 版本
          cache: &quot;pnpm&quot; # 启用依赖缓存，加速构建

      # 3. 安装依赖
      - name: Install dependencies
        run: pnpm install

      # 4. 构建静态网站（如需环境变量，通过 env 传入）
      - name: Build
        run: pnpm run generate # 或 pnpm run build（取决于项目配置）
        env:
          # 从 GitHub Secrets 读取构建所需变量
          NUXT_PUBLIC_API_BASE: ${{ secrets.NUXT_PUBLIC_API_BASE }}
          # 可根据需要添加更多变量

      # 5. 将构建产物同步到云服务器
      - name: Deploy to Server via Rsync
        uses: burnett01/rsync-deployments@7.0.1
        with:
          switches: -avz --delete # 递归、压缩、同步删除（保持两端完全一致）
          path: .output/public/ # Nuxt 静态文件输出目录
          remote_path: /var/www/my-site/ # 服务器目标目录，必须与 Caddyfile 中配置一致
          remote_host: ${{ secrets.SERVER_HOST }}
          remote_user: ${{ secrets.SERVER_USER }}
          remote_key: ${{ secrets.SSH_PRIVATE_KEY }}
</code></pre>

<p><strong>关键配置说明</strong>：</p>

<ul>
<li><code>path</code>: 你的静态网站构建输出目录。对于其他框架：

<ul>
<li>VitePress: <code>docs/.vitepress/dist/</code></li>
<li>Next.js (SSG): <code>out/</code></li>
<li>Vue CLI: <code>dist/</code></li>
<li>Hugo: <code>public/</code></li>
</ul></li>
<li><code>remote_path</code>: 必须与服务器上 Caddy 配置的 <code>root</code> 目录完全一致。</li>
<li><code>switches: --delete</code>: 确保服务器上的文件是构建结果的精确镜像，自动删除多余文件。</li>
<li>如果构建过程需要环境变量，务必在 <code>Build</code> 步骤的 <code>env</code> 中传入，并在 GitHub Secrets 中预先定义。</li>
</ul>

<hr>

<h2 id="第四部分-触发首次部署与验证">🧪 第四部分：触发首次部署与验证</h2>

<h3 id="4-1-提交并推送代码">4.1 提交并推送代码</h3>

<p>将工作流配置文件添加到 Git 并推送到仓库，触发首次自动化部署。</p>

<pre><code class="language-bash">git add .github/workflows/deploy.yml
git commit -m &quot;feat: 添加自动化部署工作流&quot;
git push origin main
</code></pre>

<h3 id="4-2-监控部署过程">4.2 监控部署过程</h3>

<ol>
<li>进入你的 GitHub 仓库，点击 <strong>Actions</strong> 标签页。</li>
<li>你会看到名为 “Deploy to Production” 的工作流正在运行。</li>
<li>点击进入，可以实时查看每个步骤的日志。</li>
<li>当所有步骤显示绿色对勾（✅），表示部署成功。</li>
</ol>

<h3 id="4-3-验证网站">4.3 验证网站</h3>

<ul>
<li>打开浏览器，访问你的域名（如 <code>https://example.com</code>）。</li>
<li>如果使用 IP，访问 <code>http://你的服务器IP</code>。</li>
<li>你应该能看到部署的网站，并且地址栏显示<strong>安全的 HTTPS 锁标</strong>（如果使用了域名）。</li>
</ul>

<hr>

<h2 id="第五部分-高级配置与问题排查">🔧 第五部分：高级配置与问题排查</h2>

<h3 id="5-1-处理静态资源-图标-图片">5.1 处理静态资源（图标、图片）</h3>

<p>确保项目的<strong>静态资源</strong>（如 <code>favicon.ico</code>、图片）放在正确的目录：</p>

<ul>
<li><strong>Nuxt <sup>3</sup>&frasl;<sub>4</sub></strong>: <code>/public/</code> 目录</li>
<li><strong>Nuxt 2</strong>: <code>/static/</code> 目录</li>
<li>构建后，这些文件会自动复制到 <code>.output/public/</code> 下。</li>
</ul>

<p><strong>推荐做法</strong>：在 <code>public/</code> 下创建子目录（如 <code>public/icons/</code>, <code>public/images/</code>）进行分类管理。</p>

<h3 id="5-2-关键问题排查清单">5.2 关键问题排查清单</h3>

<table>
<thead>
<tr>
<th>现象</th>
<th>可能原因</th>
<th>解决方案</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>Actions 日志卡在 SSH 连接</strong></td>
<td>1. SSH 密钥格式错误 <br>2. 安全组未开放 22 端口 <br>3. 服务器 <code>sshd_config</code> 配置限制</td>
<td>1. 检查私钥格式，确保在 GitHub Secrets 中完整、多行 <br>2. 检查阿里云安全组入方向规则 <br>3. 检查服务器 <code>/etc/ssh/sshd_config</code> 中的 <code>PermitRootLogin</code> 和 <code>AllowUsers</code> 设置</td>
</tr>

<tr>
<td><strong>网站可以 HTTP 访问，但 HTTPS 报错</strong></td>
<td>1. 域名 DNS 解析未生效或错误 <br>2. 安全组未开放 443 端口</td>
<td>1. 运行 <code>nslookup yourdomain.com</code> 检查 DNS 解析 <br>2. 检查安全组 443 端口规则</td>
</tr>

<tr>
<td><strong>网站显示 “404 Not Found”</strong></td>
<td>1. Caddy <code>root</code> 目录配置错误 <br>2. 文件未成功同步到服务器</td>
<td>1. 核对 <code>/etc/caddy/Caddyfile</code> 中的 <code>root</code> 路径与 <code>deploy.yml</code> 中的 <code>remote_path</code> <br>2. 登录服务器检查 <code>/var/www/my-site/</code> 目录下是否有文件</td>
</tr>

<tr>
<td><strong>构建失败 (Lint/Type Error)</strong></td>
<td>代码检查或类型错误</td>
<td>1. 本地运行 <code>pnpm run lint</code> 和 <code>pnpm run typecheck</code> 修复错误 <br>2. 或暂时在 <code>deploy.yml</code> 中注释掉相关检查步骤</td>
</tr>
</tbody>
</table>

<h3 id="5-3-查看服务器日志">5.3 查看服务器日志</h3>

<p>当遇到问题时，服务器日志是寻找线索的黄金位置。</p>

<pre><code class="language-bash"># 查看 Caddy 实时日志
sudo journalctl -u caddy -f

# 查看 SSH 认证日志（排查连接问题）
sudo tail -f /var/log/auth.log
</code></pre>

<hr>

<h2 id="总结-你现在拥有了什么">📈 总结：你现在拥有了什么？</h2>

<p>通过本教程，你已成功搭建了一套<strong>完全自动化、可追溯、可回滚</strong>的现代静态网站部署流水线：</p>

<ol>
<li><strong>自动化</strong>：只需 <code>git push</code>，后续所有流程自动完成。</li>
<li><strong>零配置 HTTPS</strong>：Caddy 自动管理 SSL 证书的申请和续期。</li>
<li><strong>环境一致</strong>：每次构建都在全新的 GitHub 运行器中进行，杜绝“在我机器上好好的”问题。</li>
<li><strong>安全可靠</strong>：基于 SSH 密钥认证，密钥安全存储在 GitHub Secrets。</li>
<li><strong>可回滚</strong>：如需回滚，只需在 Git 中检出旧版本并推送，Actions 会自动将服务器文件同步至旧状态。</li>
</ol>

<p><strong>从此，你可以专注于本地开发，将构建、测试、发布的重复劳动全部交给自动化流程。</strong></p>
]]></content:encoded>
      <description><![CDATA[专注于纯前端资源的自动化发布，利用 Caddy 自动 HTTPS 和 SPA 路由支持，实现“推送即发布”。]]></description>
      <category><![CDATA[Caddy]]></category>
      <category><![CDATA[CI/CD]]></category>
      <dc:subject>P4</dc:subject>
      <dc:relation><![CDATA[series:deployment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt i18n 的 `$tm` 函数：环境差异问题与解决方案]]></title>
      <link>https://moongate.top/docs/nuxt-i18n-tm-function-guide</link>
      <guid isPermaLink="true">87d4fd2c-9ef2-48c9-b0ac-a686f3c527f9</guid>
      <pubDate>Wed, 21 Jan 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p><strong>适用版本</strong>：<code>@nuxtjs/i18n</code> v10.x<br>
<em>如果你使用其他版本，核心思路仍可参考，但具体行为可能略有差异。</em></p>
</blockquote>

<h2 id="核心问题">核心问题</h2>

<p>在 Nuxt 4 + <code>@nuxtjs/i18n</code> v10 项目中，使用 <code>$tm</code> 或 <code>tm()</code> 获取结构化翻译数据（如数组、对象）时，可能会遇到：</p>

<ul>
<li><strong>开发环境 (<code>nuxt dev</code>)</strong>：一切正常，模板渲染正确。</li>
<li><strong>生产环境 (<code>nuxt build</code> 后运行)</strong>：报错 <code>Cannot read properties of undefined (reading 'source')</code>，或页面渲染异常。</li>
</ul>

<h2 id="问题根源">问题根源</h2>

<p>问题的根本原因在于 <strong>i18n 模块在开发环境和生产环境下对语言文件的编译处理方式不同</strong>：</p>

<ul>
<li><strong>开发环境</strong>：为了支持热更新和源码映射，模块会保留语言文件中的原始结构信息，字符串值被包装为 <code>{ loc: { source: &quot;实际文本&quot; } }</code> 的形式。</li>
<li><strong>生产环境</strong>：为了减小体积和提升性能，模块会移除这些包装，直接输出纯字符串值。</li>
</ul>

<p>这种差异导致在模板中访问数据时，如果写死了开发环境下的访问路径（例如 <code>item.name.loc.source</code>），生产环境就会因找不到 <code>loc</code> 属性而报错。</p>

<h2 id="解决方案">解决方案</h2>

<h3 id="方案一-快速修补-适合临时修复">方案一：快速修补（适合临时修复）</h3>

<p>在模板中根据环境动态选择访问路径：</p>

<pre><code class="language-vue">&lt;template&gt;
  &lt;div v-for=&quot;item in tm('navigationBar')&quot; :key=&quot;item.id&quot;&gt;
    &lt;NuxtLink
      :to=&quot;isDev ? item.link?.loc?.source : item.link&quot;
      rel=&quot;noopener noreferrer&quot;
    &gt;
      {{ isDev ? item.name?.loc?.source : item.name }}
    &lt;/NuxtLink&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script setup&gt;
const isDev = import.meta.env.DEV;
const { tm } = useI18n();
&lt;/script&gt;
</code></pre>

<p><strong>优点</strong>：直接了当，改动最小。<br>
<strong>缺点</strong>：每个使用 <code>tm</code> 的地方都要写判断，代码冗余。</p>

<hr>

<h3 id="方案二-封装统一适配函数-推荐">方案二：封装统一适配函数（推荐）</h3>

<p>创建一个组合式函数，自动处理环境差异，让模板代码保持简洁。</p>

<pre><code class="language-ts">// composables/useI18nSafe.ts
import { useI18n } from &quot;vue-i18n&quot;;

/**
 * 递归提取开发环境下的实际值（移除 loc.source 包装）
 */
function extractValue(value: any): any {
  if (!value || typeof value !== &quot;object&quot;) return value;

  // 处理被包装的字符串（开发环境特有）
  if (value.loc?.source !== undefined) {
    return value.loc.source;
  }

  // 处理数组
  if (Array.isArray(value)) {
    return value.map(extractValue);
  }

  // 处理对象
  const result: Record&lt;string, any&gt; = {};
  for (const key in value) {
    result[key] = extractValue(value[key]);
  }
  return result;
}

export function useI18nSafe() {
  const { tm: originalTm, ...rest } = useI18n();

  const tm = (key: string) =&gt; {
    const value = originalTm(key);
    // 仅开发环境需要提取，生产环境直接返回
    if (import.meta.env.DEV) {
      return extractValue(value);
    }
    return value;
  };

  return { tm, ...rest };
}
</code></pre>

<p>在组件中使用：</p>

<pre><code class="language-vue">&lt;script setup&gt;
const { tm } = useI18nSafe();
&lt;/script&gt;

&lt;template&gt;
  &lt;div v-for=&quot;item in tm('navigationBar')&quot; :key=&quot;item.id&quot;&gt;
    &lt;NuxtLink :to=&quot;item.link&quot; rel=&quot;noopener noreferrer&quot;&gt;{{
      item.name
    }}&lt;/NuxtLink&gt;
  &lt;/div&gt;
&lt;/template&gt;
</code></pre>

<p><strong>优点</strong>：模板代码与生产环境完全一致，无环境感知，维护简单。<br>
<strong>缺点</strong>：需要额外封装，但一次投入长期受益。</p>

<hr>

<h2 id="注意事项">注意事项</h2>

<ol>
<li><p><strong>仅字符串字段受影响</strong><br>
数字、布尔值、数组等类型不会被包装，因此无需特殊处理。</p></li>

<li><p><strong>递归提取</strong><br>
方案二中的 <code>extractValue</code> 会递归遍历所有层级，可处理深层嵌套对象。</p></li>

<li><p><strong>性能</strong><br>
开发环境下会有微小递归开销，不影响生产环境。</p></li>

<li><p><strong>该问题在 v10 中依然存在</strong><br>
不要误以为 v10 已修复。只要 i18n 模块为了开发体验保留 AST 信息，这种差异就可能存在。</p></li>
</ol>

<h2 id="总结">总结</h2>

<p><code>$tm</code> 环境差异是 <code>@nuxtjs/i18n</code> 模块为了兼顾开发体验和生产优化而产生的副作用。通过封装一个环境自适应的 <code>useI18nSafe</code> 组合式函数，可以优雅地解决此问题，让代码在不同环境下都能稳定运行。</p>

<p>如果你在使用中遇到其他问题，欢迎留言交流。</p>
]]></content:encoded>
      <description><![CDATA[介绍了 Nuxt.js 项目中使用 @nuxtjs/i18n 模块的 $tm 函数（或组合式 API 中的 tm()）时，一个常见的问题是：在开发环境 (nuxt dev) 下运行正常的代码，在生产环境构建 (nuxt build) 后运行会报错或渲染异常。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[i18n]]></category>
      <category><![CDATA[Engineering]]></category>
      <category><![CDATA[Performance]]></category>
      <dc:subject>P1</dc:subject>
      <dc:relation><![CDATA[series:i18n]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt Content + i18n终极集成方案：一套内容支持多语言的完整实现]]></title>
      <link>https://moongate.top/docs/nuxt-content-i18n-ultimate-integration</link>
      <guid isPermaLink="true">e7c84714-d1ba-4c6f-a56a-57c00edc3661</guid>
      <pubDate>Mon, 29 Dec 2025 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>适用版本：Nuxt 4、Nuxt Content v3、 Nuxt I18n v10<br>
如果你使用其他版本，核心思路仍可参考，但具体 API 可能需要调整。</p>
</blockquote>

<h2 id="概述">概述</h2>

<p>在构建多语言技术文档站点时，我们常面临一个矛盾：<strong>内容维护成本</strong>与<strong>多语言用户体验</strong>如何兼得？官方方案往往建议为每种语言维护独立的文档集合，但这对于独立开发者或小团队来说负担沉重。</p>

<p>本文介绍一种创新方案：<strong>只维护一套核心文档（如英文或中文），利用 <code>@nuxtjs/i18n</code> 的路由前缀功能，为用户提供完整的多语言界面体验</strong>。访问 <code>/en/article</code> 与 <code>/zh_cn/article</code> 将显示相同的文档内容，但界面语言、导航菜单等将根据URL前缀自动切换。</p>

<h2 id="核心优势">核心优势</h2>

<ul>
<li><strong>维护极简</strong>：只需维护单一语言的内容源</li>
<li><strong>体验完整</strong>：用户仍能获得地址栏、导航、界面完全本地化的体验</li>
<li><strong>技术解耦</strong>：Content 管内容，i18n 管路由与界面，各司其职</li>
<li><strong>无缝扩展</strong>：未来可为特定文档添加翻译，无需改造架构</li>
</ul>

<hr>

<h2 id="问题背景-当-content-遇上-i18n">问题背景：当 Content 遇上 i18n</h2>

<p><code>@nuxt/content</code> 模块本身提供了基础的国际化支持，但其设计初衷是<strong>内容与语言强绑定</strong>：</p>

<ul>
<li>每个语言独立的文件夹（<code>/content/en/</code>, <code>/content/zh_cn/</code>）</li>
<li>每个语言独立的内容集合配置</li>
<li>自动回退机制：当 <code>/es/article</code> 不存在时，跳回默认语言</li>
</ul>

<p>但这带来了两个实际问题：</p>

<ol>
<li><strong>内容同步压力</strong>：任何更新都需要在所有语言副本中重复</li>
<li><strong>架构复杂</strong>：需要配置多个内容集合，查询时需要额外逻辑</li>
</ol>

<p>而我们真正的需求往往是：</p>

<blockquote>
<p>“我只有精力维护一套技术文档，但希望网站支持多语言界面。”</p>
</blockquote>

<hr>

<h2 id="解决方案-路径转换中间层">解决方案：路径转换中间层</h2>

<p>核心思路是建立一个<strong>路径转换层</strong>，在用户访问时动态处理 URL：</p>

<pre><code class="language-text">graph LR
    A[用户访问&lt;br&gt;/ja/docs/nuxt-guide] --&gt; B{i18n 模块};
    B --&gt; C[识别语言前缀 ja];
    C --&gt; D[移除前缀 /ja];
    D --&gt; E[Content 查询 /docs/nuxt-guide];
    E --&gt; F[返回统一内容];
    F --&gt; G[界面元素使用 ja 语言包];
    G --&gt; H[用户看到日文界面&lt;br&gt;英文内容];
</code></pre>

<h3 id="3-实现细节-稳定查询路径">3. 实现细节：稳定查询路径</h3>

<p>以下是基于 Vue 3 与 Nuxt 3 的完整实现示例：</p>

<blockquote>
<p>路由层处理</p>
</blockquote>

<pre><code class="language-vue">&lt;UBlogPost
  v-for=&quot;item in fiterArticles&quot;
  :key=&quot;item.id&quot;
  :title=&quot;item.title&quot;
  :description=&quot;item.description&quot;
  :date=&quot;item.meta.date&quot;
  class=&quot;card cursor-pointer&quot;
  @click=&quot;navigateTo(locale === 'zh_cn' ? item.path : `/${locale}${item.path}`)&quot;
/&gt;
</code></pre>

<blockquote>
<p>内容层：由 @nuxt/content 处理（移除语言前缀）</p>
</blockquote>

<pre><code class="language-typescript">&lt;script lang=&quot;ts&quot; setup&gt;
import { withLeadingSlash } from &quot;ufo&quot;;
const { locale } = useI18n();
const route = useRoute();

// 核心：移除语言前缀，得到原始路径
// 例如：/en/docs/welcome -&gt; /docs/welcome
const slug = computed(() =&gt; {
  const path = withLeadingSlash(String(route.params.slug || &quot;/&quot;));
  // 移除语言前缀部分
  return path.replace(new RegExp(`^/(${locale.value})`), &quot;&quot;) || &quot;/&quot;;
});

// 稳定查询：永远只查询 'docs' 这个集合
const { data: page } = await useAsyncData(
  route.path,
  () =&gt; {
    return queryCollection(&quot;docs&quot;).path(`/docs${slug.value}`).first();
    // 注意：查询路径需要加上 '/docs' 前缀，以匹配 content/docs/ 下的文件
  },
  {
    // 设置 transform 确保数据一致性
    transform: (data) =&gt; {
      if (!data) return null;
      return data;
    },
  },
);
&lt;/script&gt;
</code></pre>

<h2 id="推荐的目录结构">📁 推荐的目录结构</h2>

<p>保持您的单语言内容结构：</p>

<pre><code class="language-text">content/
  docs/
    nuxt-content-guide.md
    getting-started.md
    i18n-config.md
  about.md
</code></pre>

<blockquote>
<p>docs文件夹是我专门为项目建立的，无需跟我一模一样</p>
</blockquote>

<h2 id="路由映射关系">🔄 路由映射关系</h2>

<table>
<thead>
<tr>
<th>访问 URL</th>
<th>i18n 处理</th>
<th>Content 查询</th>
<th>实际文件</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>/zh_cn/docs/nuxt-guide</code></td>
<td>识别为中文</td>
<td><code>/docs/nuxt-guide</code></td>
<td><code>content/docs/nuxt-guide.md</code></td>
</tr>

<tr>
<td><code>/en/docs/nuxt-guide</code></td>
<td>识别为英文</td>
<td><code>/docs/nuxt-guide</code></td>
<td><code>content/docs/nuxt-guide.md</code></td>
</tr>

<tr>
<td><code>/ja/docs/nuxt-guide</code></td>
<td>识别为日文</td>
<td><code>/docs/nuxt-guide</code></td>
<td><code>content/docs/nuxt-guide.md</code></td>
</tr>
</tbody>
</table>

<h2 id="总结">总结</h2>

<p>通过 <code>@nuxtjs/i18n</code> 的路由前缀与 <code>@nuxt/content</code> 的路径转换相结合，我们实现了：</p>

<ol>
<li><strong>维护成本最小化</strong> - 单一内容源</li>
<li><strong>用户体验最大化</strong> - 完整的多语言界面支持</li>
<li><strong>查询稳定性</strong> - 使用固定的内容集合查询，避免路径解析问题</li>
</ol>

<p>这种模式特别适合：</p>

<ul>
<li>技术文档、API参考</li>
<li>个人技术博客</li>
<li>初创公司产品文档</li>
<li>任何需要快速支持多语言但翻译资源有限的场景</li>
</ul>

<hr>

<p><em>本文采用所述方案编写，访问 <code>/en/docs/single-content-multilingual-routes</code> 或 <code>/zh_cn/docs/single-content-multilingual-routes</code> 可体验实际效果。</em></p>
]]></content:encoded>
      <description><![CDATA[本文介绍一种创新方案：只维护一套核心文档（如英文或中文），利用 @nuxtjs/i18n 的路由前缀功能，为用户提供完整的多语言界面体验。访问 /en/article 与 /zh_cn/article 将显示相同的文档内容，但界面语言、导航菜单等将根据URL前缀自动切换。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[i18n]]></category>
      <dc:subject>P1</dc:subject>
      <dc:relation><![CDATA[series:i18n]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[解决Nuxt Content渲染问题：从基础配置到渲染显示完整指南]]></title>
      <link>https://moongate.top/docs/nuxt-content-config-guide</link>
      <guid isPermaLink="true">25972ca7-bbc7-412b-820b-9be8ba604057</guid>
      <pubDate>Sun, 28 Dec 2025 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="概述">概述</h2>

<p>这篇文档记录了我从零开始配置 Nuxt Content 模块，渲染 Markdown 内容的完整过程。如果你也厌倦了在配置上耗费数小时却一行业务代码都没写的挫败感，这篇实战指南或许能帮你少走弯路。</p>

<blockquote>
<p><strong>适用版本</strong>：Nuxt Content v3<br>
如果你使用其他版本，核心思路仍可参考，但具体行为可能略有差异。</p>
</blockquote>

<h2 id="1-环境与项目初始化">1. 环境与项目初始化</h2>

<h3 id="1-1-创建-nuxt-项目">1.1 创建 Nuxt 项目</h3>

<pre><code class="language-bash"># 使用官方脚手架创建项目
pnpm create nuxt &lt;My-Project&gt;
cd &lt;My-Project&gt;

# 安装基础依赖（如果你使用 pnpm）
pnpm install
</code></pre>

<h3 id="1-2-安装多个必备模块">1.2 安装多个必备模块</h3>

<pre><code class="language-bash"># 安装 Nuxt Content 模块
pnpm add @nuxt/content

# 安装 better-sqlite3 模块
pnpm add better-sqlite3
</code></pre>

<h3 id="1-3-处理可能存在的better-sqlite3模块的兼容问题">1.3 处理可能存在的better-sqlite3模块的兼容问题</h3>

<p>在安装 <code>@nuxt/content</code> 时，其依赖的 <code>better-sqlite3</code> 是一个原生模块，在使用 pnpm 管理依赖时可能会遇到二进制文件路径解析问题。以下是两种解决方案，你可以根据项目环境和需求选择其中一种。</p>

<hr>

<h4 id="方案一-通过-pnpm-配置与重建-推荐大多数项目">方案一：通过 pnpm 配置与重建（推荐大多数项目）</h4>

<p>此方案通过显式允许构建 <code>better-sqlite3</code> 并重建其二进制文件，确保模块在 pnpm 的严格模式下正常工作。</p>

<ol>
<li><strong>创建或修改 <code>pnpm-workspace.yaml</code></strong><br>
在项目根目录创建该文件，内容如下：</li>
</ol>

<pre><code class="language-yaml">   onlyBuiltDependencies:
     - better-sqlite3
</code></pre>

<p>或者直接在安装时使用 <code>--allow-build</code> 参数，pnpm 会自动完成配置：</p>

<pre><code class="language-bash">   pnpm add better-sqlite3 --allow-build=better-sqlite3
</code></pre>

<ol>
<li><strong>重建 better-sqlite3</strong><br>
执行以下命令强制重新编译原生模块：</li>
</ol>

<pre><code class="language-bash">   pnpm rebuild better-sqlite3
</code></pre>

<ol>
<li><strong>启动开发服务器验证</strong></li>
</ol>

<pre><code class="language-bash">   pnpm run dev
</code></pre>

<blockquote>
<p>此方案保留了 pnpm 的所有优势，同时解决了原生模块的兼容性问题，适用于大多数 Nuxt 项目。</p>
</blockquote>

<hr>

<h4 id="方案二-启用-nuxt-content-的原生-sqlite-支持-需要-node-js-v22-5-0">方案二：启用 Nuxt Content 的原生 SQLite 支持（需要 Node.js v22.5.0+）</h4>

<p>从 <code>@nuxt/content</code> 的某个版本开始（具体请查阅对应版本的文档），你可以通过配置 <code>experimental.nativeSqlite</code> 选项直接启用原生 SQLite 绑定，无需手动处理 <code>better-sqlite3</code> 的 pnpm 兼容性问题。</p>

<ol>
<li><strong>确保 Node.js 版本 ≥ v22.5.0</strong><br>
检查当前 Node 版本：</li>
</ol>

<pre><code class="language-bash">   node -v
</code></pre>

<p>如果版本过低，请升级。</p>

<ol>
<li><strong>在 <code>nuxt.config.ts</code> 中添加配置</strong></li>
</ol>

<pre><code class="language-typescript">   export default defineNuxtConfig({
     modules: [&quot;@nuxt/content&quot;],
     content: {
       experimental: {
         nativeSqlite: true, // 启用原生 SQLite 支持
       },
     },
   });
</code></pre>

<ol>
<li><strong>启动项目</strong></li>
</ol>

<pre><code class="language-bash">   pnpm run dev
</code></pre>

<p>启用此选项后，Nuxt Content 会使用原生的 <code>better-sqlite3</code> 实现，通常在服务器端性能和稳定性上更优，尤其适合生产环境。但请注意，此功能为实验性，可能需要配合特定版本的 Nuxt Content 使用。</p>

<hr>

<p><strong>选择</strong>：方案一适用于任何 Node 版本，对 pnpm 项目通用；方案二更简洁，但需要较高版本的 Node 环境和对实验性特性的接受度。请根据实际情况选择。</p>

<h2 id="2-基础配置">2. 基础配置</h2>

<h3 id="2-1-配置-nuxt-config-ts">2.1 配置 <code>nuxt.config.ts</code></h3>

<pre><code class="language-typescript">export default defineNuxtConfig({
  modules: [&quot;@nuxt/content&quot;],
});
</code></pre>

<h3 id="2-2-创建内容目录和第一篇文档">2.2 创建内容目录和第一篇文档</h3>

<p>在项目根目录创建 <code>content</code> 文件夹，然后创建第一篇文档，文件暂命名为home.md</p>

<pre><code class="language-markdown"># 欢迎来到我的博客

这是我的第一篇使用 **Nuxt Content** 构建的文档。

## 特性亮点

- ✅ 支持 Markdown 语法
- ✅ 内置代码高亮
- ✅ 前端框架无缝集成

## 代码示例

```javascript
// 这是一个 JavaScript 示例
export default function greet(name) {
  console.log(`Hello, ${name}!`);
  return `Welcome to ${name}'s blog`;
}
</code></pre>

<h2 id="3-创建文档渲染页面">3. 创建文档渲染页面</h2>

<pre><code class="language-vue">&lt;!-- 在app/pages的目录下创建文件名为[...slug].vue的文件，将以下代码粘贴到此文件中 --&gt;
&lt;script setup lang=&quot;ts&quot;&gt;
const route = useRoute();

const { data: page } = await useAsyncData(&quot;page-&quot; + route.path, () =&gt; {
  return queryCollection(&quot;content&quot;).path(route.path).first();
});

console.log(page.value);

if (!page.value) {
  throw createError({
    statusCode: 404,
    statusMessage: &quot;Page not found&quot;,
    fatal: true,
  });
}
&lt;/script&gt;

&lt;template&gt;
  &lt;ContentRenderer v-if=&quot;page&quot; :value=&quot;page&quot; /&gt;
&lt;/template&gt;
</code></pre>

<h2 id="总结">总结</h2>

<h3 id="1-配置心得">1. 配置心得</h3>

<ol>
<li><strong>版本一致性</strong>：确保所有依赖版本兼容，特别是 Nuxt、Content 模块</li>
<li><strong>渐进式配置</strong>：不要一次性配置所有功能，先确保基础渲染正常，再逐步添加高级功能。</li>
<li><strong>善用官方文档</strong>：遇到问题时，首先查看各模块的官方文档，注意版本差异。</li>
</ol>

<h3 id="2-避坑指南">2. 避坑指南</h3>

<ul>
<li><strong>问题</strong>：<code>@nuxt/content</code> 与 <code>better-sqlite3</code> 原生模块在 pnpm 环境下冲突，导致安装或启动失败。

<ul>
<li><strong>原因</strong>：pnpm 的严格链接模式可能导致原生模块的二进制文件无法被正确加载，引发 <code>better-sqlite3</code> 相关错误。</li>
<li><strong>解决</strong>：有两种方式可处理该问题——

<ul>
<li><strong>方案一</strong>：通过 pnpm 配置显式允许构建并重建模块（详见上文 <strong>1.3 方案一</strong>）。</li>
<li><strong>方案二</strong>：升级 Node.js 至 <strong>v22.5.0 或更高</strong>，并在 <code>nuxt.config.ts</code> 中添加 <code>experimental: { nativeSqlite: true }</code>。此配置让 Nuxt Content 直接使用原生的 SQLite 绑定，从根本上避免路径解析问题，同时提升生产环境性能。<strong>推荐使用此方案</strong>，它同时解决了下文的生产环境数据丢失问题。</li>
</ul></li>
</ul></li>
<li><strong>问题</strong>：生产环境使用 <code>useAsyncData</code> 渲染内容时，数据偶发无法获取，刷新后内容丢失。

<ul>
<li><strong>原因</strong>：默认情况下，Nuxt Content 在服务端可能回退到 JavaScript 实现的 SQLite，其性能不足以应对生产环境的并发请求，导致数据库访问失败。</li>
<li><strong>解决</strong>：确保 Node.js 版本 <strong>≥ v22.5.0</strong>，并在 <code>nuxt.config.ts</code> 的 <code>content</code> 配置中启用 <code>experimental: { nativeSqlite: true }</code>。该选项强制使用原生的 <code>better-sqlite3</code>，大幅提升数据库操作的稳定性和性能，彻底解决数据丢失问题。</li>
</ul></li>
</ul>

<blockquote>
<p><strong>小结</strong>：两个常见问题的根本原因都与 SQLite 的实现方式有关。通过升级 Node 版本并开启 <code>experimental.nativeSqlite</code>，可以同时解决原生模块兼容性与生产环境数据丢失的问题，是当前最简洁有效的做法。</p>
</blockquote>

<h3 id="3-性能建议">3. 性能建议</h3>

<ol>
<li><strong>代码分割</strong>：利用 Nuxt 的自动代码分割功能</li>
<li><strong>图片优化</strong>：使用 <code>@nuxt/image</code> 模块优化内容中的图片</li>
</ol>

<hr>

<blockquote>
<p><strong>写在最后</strong>：虽然配置过程可能充满挑战，但一旦完成，你将获得一个强大、现代且完全可控的内容管理系统。每一次配置问题的解决，都是对现代前端工具链理解的深化。现在，去创建你的内容吧！</p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[记录了我从零开始配置 @nuxt/content 模块，渲染 Markdown 内容的完整过程。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Engineering]]></category>
      <category><![CDATA[Performance]]></category>
      <dc:subject>P2</dc:subject>
      <dc:relation><![CDATA[series:performance]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 4 国际化(i18n)完整配置：从基础设置到高级优化]]></title>
      <link>https://moongate.top/docs/nuxt-i18n-config-guide</link>
      <guid isPermaLink="true">ba590c8a-5f7f-40ab-8492-e071b8f8731f</guid>
      <pubDate>Thu, 11 Dec 2025 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<p>本文档基于 <code>@nuxtjs/i18n</code> 模块，版本为10.2.1，详细讲解在 Nuxt 4 项目中实现国际化的完整流程，并特别指出中文配置中的常见“痛点”及解决方案。</p>

<blockquote>
<p>⚠️ 重要提示：@nuxtjs/i18n v10 版本相对于 v8 有重大重构，配置方式、API 和路由策略均发生了较大变化。如果你之前使用过旧版本，请务必抛弃固有认知，以本文档和官方文档为准，避免因版本差异导致的配置错误。</p>
</blockquote>

<hr>

<details>
<summary>适用版本</summary>

- Nuxt: **v4**
- Nuxt i18n: **V10**

> 如果你用的是其他版本，核心思路可参考，但具体 API 可能需要调整。

</details>

<hr>

<h2 id="一-快速开始">一、快速开始</h2>

<h3 id="1-1-安装模块">1.1 安装模块</h3>

<pre><code class="language-bash">pnpm install @nuxt/i18n
</code></pre>

<h3 id="1-2-基础配置-nuxt-config-ts">1.2 基础配置 (<code>nuxt.config.ts</code>)</h3>

<pre><code class="language-typescript">export default defineNuxtConfig({
  modules: [&quot;@nuxtjs/i18n&quot;],

  i18n: {
    // 语言环境配置（核心）
    locales: [
      {
        code: &quot;zh_cn&quot;, // 程序内部标识符（URL路径使用）
        name: &quot;简体中文&quot;, // 显示名称
        language: &quot;zh-CN&quot;, // 用于HTML lang属性的标准语言标签
        file: &quot;zh_cn.json&quot;, // 对应的语言文件
      },
      {
        code: &quot;en&quot;,
        name: &quot;English&quot;,
        language: &quot;en-US&quot;,
        file: &quot;en-US.json&quot;,
      },
      {
        code: &quot;ja&quot;,
        name: &quot;日本語&quot;,
        language: &quot;ja-JP&quot;,
        file: &quot;ja-JP.json&quot;,
      },
    ],

    // 默认语言设置（必须与某个code完全匹配）
    defaultLocale: &quot;zh_cn&quot;,

    // 语言文件目录
    langDir: &quot;locales&quot;,

    // 路由策略
    strategy: &quot;prefix_except_default&quot;, // 推荐：默认语言无前缀

    // 浏览器语言检测
    detectBrowserLanguage: {
      useCookie: true,
      cookieKey: &quot;i18n_redirected&quot;,
      redirectOn: &quot;root&quot;,
    },
  },
});
</code></pre>

<h3 id="1-3-语言文件结构">1.3 语言文件结构</h3>

<p>创建语言文件目录和文件：</p>

<pre><code class="language-text">project-root/
├── i18n/
│   └── locales/
│       ├── zh_cn.json    # 简体中文
│       ├── en.json    # 英文
│       └── ja.json    # 日文
├── nuxt.config.ts
└── app.vue
</code></pre>

<h3 id="1-4-创建语言文件">1.4 创建语言文件</h3>

<h4 id="locales-zh-cn-json">locales/zh_cn.json</h4>

<pre><code class="language-json">{
  &quot;welcome&quot;: &quot;欢迎使用我们的应用&quot;,
  &quot;about&quot;: &quot;关于我们&quot;,
  &quot;user&quot;: {
    &quot;profile&quot;: &quot;用户资料&quot;,
    &quot;settings&quot;: &quot;设置&quot;
  }
}
</code></pre>

<h4 id="locales-en-json">locales/en.json</h4>

<pre><code class="language-json">{
  &quot;welcome&quot;: &quot;Welcome to our app&quot;,
  &quot;about&quot;: &quot;About Us&quot;,
  &quot;user&quot;: {
    &quot;profile&quot;: &quot;User Profile&quot;,
    &quot;settings&quot;: &quot;Settings&quot;
  }
}
</code></pre>

<h4 id="locales-ja-json">locales/ja.json</h4>

<pre><code class="language-json">{
  &quot;welcome&quot;: &quot;アプリへようこそ&quot;,
  &quot;about&quot;: &quot;会社概要&quot;,
  &quot;user&quot;: {
    &quot;profile&quot;: &quot;ユーザープロフィール&quot;,
    &quot;settings&quot;: &quot;設定&quot;
  }
}
</code></pre>

<h3 id="1-5-动态区域设置">1.5 动态区域设置</h3>

<pre><code class="language-vue">&lt;script setup lang=&quot;ts&quot;&gt;
import * as locales from &quot;@nuxt/ui/locale&quot;;

const { locale } = useI18n();
&lt;/script&gt;

&lt;template&gt;
  &lt;UApp :locale=&quot;locales[locale]&quot;&gt;
    &lt;NuxtPage /&gt;
  &lt;/UApp&gt;
&lt;/template&gt;
</code></pre>

<h3 id="1-6-动态方向设置">1.6 动态方向设置</h3>

<pre><code class="language-vue">&lt;script setup lang=&quot;ts&quot;&gt;
import * as locales from &quot;@nuxt/ui/locale&quot;;

const { locale } = useI18n();

const lang = computed(() =&gt; locales[locale.value].code);
const dir = computed(() =&gt; locales[locale.value].dir);

useHead({
  htmlAttrs: {
    lang,
    dir,
  },
});
&lt;/script&gt;

&lt;template&gt;
  &lt;UApp :locale=&quot;locales[locale]&quot;&gt;
    &lt;NuxtPage /&gt;
  &lt;/UApp&gt;
&lt;/template&gt;
</code></pre>

<h2 id="二-中文配置的特别痛点与解决方案">二、中文配置的特别痛点与解决方案</h2>

<h3 id="3-1-痛点一-语言标识符不一致">3.1 痛点一：语言标识符不一致</h3>

<p><strong>问题</strong>：中文有多种标识符格式（<code>zh</code>、<code>zh-CN</code>、<code>zh_CN</code>、<code>zh_cn</code>），容易混淆。</p>

<p><strong>解决方案</strong>：</p>

<ul>
<li><strong><code>code</code>字段</strong>：用于URL路径和程序内部标识，推荐使用 <strong><code>zh_cn</code></strong>（全小写下划线）</li>
</ul>

<pre><code class="language-typescript">{ code: 'zh_cn', name: '简体中文' }
</code></pre>

<p><strong><code>language</code>字段</strong>：用于HTML <code>lang</code>属性和SEO，使用标准 <strong><code>zh-CN</code></strong>（连字符格式）</p>

<pre><code class="language-typescript">{ code: 'zh_cn', language: 'zh-CN' }
</code></pre>

<p><strong><code>defaultLocale</code></strong>：必须与 <code>code</code> 值<strong>完全一致</strong></p>

<pre><code class="language-typescript">defaultLocale: &quot;zh_cn&quot;; // 正确
defaultLocale: &quot;zh-CN&quot;; // 错误！会导致配置不匹配
</code></pre>

<h3 id="3-2-痛点二-默认语言配置错误">3.2 痛点二：默认语言配置错误</h3>

<p><strong>问题</strong>：<code>defaultLocale</code> 设置错误导致只有中文页面报错。</p>

<p><strong>解决方案</strong>：</p>

<ol>
<li><strong>严格匹配</strong>：确保 <code>defaultLocale</code> 值与中文配置的 <code>code</code> 值<strong>一字不差</strong></li>
<li><strong>配置验证</strong>：</li>
</ol>

<pre><code class="language-typescript">// 正确的完整示例
locales: [
  { code: 'zh_cn', language: 'zh-CN', file: 'zh_cn.json' }
],
defaultLocale: 'zh_cn', // 必须与上面的code完全相同
strategy: 'prefix_except_default'
</code></pre>

<h3 id="3-3-痛点三-语言文件加载失败">3.3 痛点三：语言文件加载失败</h3>

<p><strong>问题</strong>：控制台报错 <code>Cannot find module './locales/zh.json'</code>。</p>

<p><strong>解决方案</strong>：</p>

<ol>
<li><strong>检查文件路径</strong>：确认 <code>langDir</code> 配置正确</li>
<li><strong>验证JSON格式</strong>：语言文件必须是<strong>严格有效的JSON</strong></li>
</ol>

<pre><code class="language-json">// 正确
{ &quot;welcome&quot;: &quot;欢迎&quot; }

// 错误（有注释）
{
  // 欢迎语
  &quot;welcome&quot;: &quot;欢迎&quot;
}

// 错误（有尾随逗号）
{ &quot;welcome&quot;: &quot;欢迎&quot;, }
</code></pre>

<h2 id="四-高级配置">四、高级配置</h2>

<h3 id="4-1-子域名国际化-像vue官网一样">4.1 子域名国际化（像Vue官网一样）</h3>

<pre><code class="language-typescript">i18n: {
  locales: [
    {
      code: 'zh_cn',
      domain: 'cn.your-app.com', // 生产环境子域名
      language: 'zh-CN',
      file: 'zh_cn.json'
    },
    {
      code: 'en',
      domain: 'your-app.com', // 主域名作为默认语言
      language: 'en-US',
      file: 'en.json'
    }
  ],
  differentDomains: true, // 启用子域名模式
  defaultLocale: 'en', // 默认语言对应主域名
  detectBrowserLanguage: false // 子域名模式下通常禁用
}
</code></pre>

<h3 id="4-2-翻译占位符-参数插值">4.2 翻译占位符（参数插值）</h3>

<p>在实际项目中，经常需要动态替换翻译文本中的变量，例如“共 {count} 条记录”。<code>@nuxtjs/i18n</code> 支持在翻译字符串中使用 <code>{key}</code> 占位符，并在组件中传入对应的值。</p>

<h4 id="语言文件示例">语言文件示例</h4>

<p>在 <code>i18n/locales/zh_cn.json</code> 和 <code>i18n/locales/en.json</code> 中定义带占位符的键：</p>

<pre><code class="language-json">// zh_cn.json
{
  &quot;findCount&quot;: &quot;已查询到 {count} 条文档&quot;,
  &quot;greeting&quot;: &quot;你好，{name}！欢迎回来。&quot;,
  &quot;balance&quot;: &quot;当前余额：{amount, number} 元&quot;
}
</code></pre>

<pre><code class="language-json">// en.json
{
  &quot;findCount&quot;: &quot;Found {count} documents&quot;,
  &quot;greeting&quot;: &quot;Hello {name}! Welcome back.&quot;,
  &quot;balance&quot;: &quot;Balance: {amount, number} USD&quot;
}
</code></pre>

<h4 id="组件中使用">组件中使用</h4>

<p>在 Vue 组件中通过 <code>$t</code> 或 <code>t</code> 函数传递参数对象：</p>

<pre><code class="language-vue">&lt;template&gt;
  &lt;div&gt;
    &lt;p&gt;{{ t('findCount', { count: totalDocs }) }}&lt;/p&gt;
    &lt;p&gt;{{ t('greeting', { name: userName }) }}&lt;/p&gt;
    &lt;p&gt;{{ t('balance', { amount: userBalance }) }}&lt;/p&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script setup&gt;
const { t } = useI18n()
const totalDocs = ref(25)
const userName = ref('张三')
const userBalance = ref(12345.67)
&lt;/script&gt;
</code></pre>

<h3 id="注意事项">注意事项</h3>

<ul>
<li>占位符键名必须<strong>严格匹配</strong>（区分大小写），如 <code>{count}</code> 不能写成 <code>{Count}</code>。</li>
<li>如果某个占位符未传入值，它会原样输出（如 <code>{count}</code>）。</li>
<li>对于需要复数处理的场景（如“1 条评论 / 2 条评论”），请使用 <code>vue-i18n</code> 的复数机制，而非手动拼接。</li>
</ul>

<h2 id="总结">总结</h2>

<p><code>@nuxtjs/i18n</code> v10 为 Nuxt 应用提供了强大且灵活的国际化方案，但配置细节繁多，尤其在中文场景下容易踩坑。回顾全文，有几个关键点值得再次强调：</p>

<ol>
<li><p><strong>版本差异巨大</strong><br>
v10 与 v8 不兼容，务必以本文和官方文档为准，摒弃旧版认知。</p></li>

<li><p><strong>语言标识符规范化</strong></p>

<ul>
<li><code>code</code>：全小写下划线（如 <code>zh_cn</code>）<br>
</li>
<li><code>language</code>：标准连字符格式（如 <code>zh-CN</code>）<br>
</li>
<li><code>defaultLocale</code>：必须与 <code>code</code> <strong>严格一致</strong></li>
</ul></li>

<li><p><strong>语言文件维护</strong><br>
存放目录与 JSON 格式需准确无误，避免注释或尾随逗号。</p></li>

<li><p><strong>占位符参数插值</strong><br>
使用 <code>{key}</code> 配合 <code>t('key', { key: value })</code> 实现动态文本，并支持数字/日期格式化。</p></li>

<li><p><strong>路由策略与子域名</strong><br>
小型项目推荐 <code>prefix_except_default</code>，大型多站点可启用 <code>differentDomains</code>。</p></li>

<li><p><strong>性能与 SEO</strong></p>

<ul>
<li>通过 <code>useHead</code> 动态设置 <code>lang</code> 和 <code>dir</code> 提升可访问性</li>
</ul></li>
</ol>

<blockquote>
<p>国际化的核心目标是让不同语言的用户获得一致的体验，希望本文能帮助你少走弯路，快速构建高质量的 Nuxt 多语言应用。</p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[本文介绍了 @nuxt/i18n 模块的配置方法，并提供了一些常见问题的解决方案。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[i18n]]></category>
      <category><![CDATA[Engineering]]></category>
      <category><![CDATA[Performance]]></category>
      <dc:subject>P1</dc:subject>
      <dc:relation><![CDATA[series:i18n]]></dc:relation>
    </item>

  </channel>
</rss>