<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0">
<channel>
<atom:link href="https://liuyaowen.cn/feed" rel="self" type="application/rss+xml"/>
<title>刘耀文</title>
<link>https://liuyaowen.cn</link>
<description>刘耀文个人网站，聚焦技术分享、项目展示与成长记录，涵盖前端开发、人工智能、个人作品集等内容，致力于打造专业、有温度的开发者主页。</description>
<language>zh-CN</language>
<copyright>© 刘耀文 </copyright>
<pubDate>Sat, 25 Jul 2026 16:44:24 GMT</pubDate>
<generator>Mix Space CMS (https://github.com/mx-space)</generator>
<docs>https://mx-space.js.org</docs>
<image>
    <url>https://avatars.githubusercontent.com/u/55525531?v=4</url>
    <title>刘耀文</title>
    <link>https://liuyaowen.cn</link>
</image>
<item>
    <title>Java 虚拟线程如何与网络 I/O 协作：Carrier、Continuation 与 Poller</title>
    <link>https://liuyaowen.cn/posts/default/java-virtual-threads-network-io-carrier-continuation-poller</link>
    <pubDate>Sat, 25 Jul 2026 12:03:41 GMT</pubDate>
    <description>虚拟线程执行下面这段代码时，表面上和普通线程没有区别：

int count = socket.ge</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/java-virtual-threads-network-io-carrier-continuation-poller'>https://liuyaowen.cn/posts/default/java-virtual-threads-network-io-carrier-continuation-poller</a></blockquote>
          <p>虚拟线程执行下面这段代码时，表面上和普通线程没有区别：</p>
<pre><code class="language-java">int count = socket.getInputStream().read(buffer);
process(buffer, count);</code></pre><p>当 Socket 暂时没有数据，当前虚拟线程会暂停。承载它的 Carrier Thread 随后可以运行其他虚拟线程。网络数据到达后，原虚拟线程重新得到调度，read() 返回，代码继续执行 process()。</p>
<p>这里容易产生几个疑问：</p>
<ul>
<li>Carrier Thread 只能线性执行代码，当前方法还没有执行完，它怎么去运行另一个任务？</li>
<li>虚拟线程暂停后保存在哪里？</li>
<li>网络事件到达时，Poller 如何找到对应的虚拟线程？</li>
<li>调度队列中保存的是虚拟线程、回调函数，还是完整调用栈？</li>
<li>网络数据怎样回到原来的 buffer？</li>
<li>Continuation.yield()、Thread.yield() 和 LockSupport.park() 分别负责什么？</li>
</ul>
<p>本文以 JDK 21 的实现为基线，沿着一次 Socket 读取的调用过程分析这些问题。JDK 21 的虚拟线程采用 M:N 调度，大量虚拟线程由 JDK 调度到少量平台线程上运行；默认调度器是一个独立的 ForkJoinPool。（OpenJDK）</p>
<h2>Carrier Thread 没有任务切换指令</h2>
<p>先去掉虚拟线程，只看一个普通的工作线程：</p>
<pre><code class="language-java">final class Worker extends Thread {
    private final BlockingQueue&lt;Runnable&gt; queue;
    Worker(BlockingQueue&lt;Runnable&gt; queue) {
        this.queue = queue;
    }
    @Override
    public void run() {
        while (!isInterrupted()) {
            try {
                Runnable task = queue.take();
                task.run();
            } catch (InterruptedException e) {
                interrupt();
            }
        }
    }
}</code></pre><p>这条线程始终线性执行。它从队列取出一个 Runnable，调用 run()，等待该方法返回，然后进入下一轮循环。</p>
<pre><code class="language-java">Runnable taskA = queue.take();
taskA.run();
Runnable taskB = queue.take();
taskB.run();</code></pre><p>Carrier Thread 的工作方式也是如此。它不会在 taskA.run() 尚未返回时，突然跳到 taskB.run()。</p>
<p>虚拟线程能够切换，是因为 taskA.run() 可以在业务调用尚未完成时暂时返回。返回之前，JVM 会保存虚拟线程的调用栈。以后再次执行这个任务时，JVM 恢复原调用栈，代码继续运行。</p>
<p>因此，一条 Carrier Thread 上实际发生的是：</p>
<pre><code class="language-java">virtualThreadAResumeTask.run(); // 保存调用栈后暂时返回
virtualThreadBResumeTask.run(); // Carrier 回到调度循环后执行</code></pre><p>Carrier Thread 仍然遵守普通方法调用规则。特殊能力位于 Continuation，它允许一段尚未完成的调用栈暂停和恢复。</p>
<h2>调度队列中保存了什么</h2>
<p>JDK 21 的 VirtualThread 内部有几个关键字段。省略诊断、线程容器和中断处理后，可以整理成下面的结构：</p>
<pre><code class="language-java">final class VirtualThread extends Thread {
    private final Executor scheduler;
    private final Continuation cont;
    private final Runnable runContinuation;
    private volatile int state;
    private volatile boolean parkPermit;
    private volatile Thread carrierThread;
}</code></pre><p>其中：</p>
<ul>
<li>scheduler 指向虚拟线程调度器。</li>
<li>cont 保存可暂停和恢复的执行状态。</li>
<li>runContinuation 是提交给调度器的任务。</li>
<li>state 记录虚拟线程当前处于运行、挂起或可调度状态。</li>
<li>parkPermit 保存一次 unpark 许可。</li>
<li>carrierThread 指向当前承载该虚拟线程的平台线程。</li>
</ul>
<p>构造虚拟线程时，会创建 Continuation，并生成一个绑定到当前虚拟线程的方法引用：</p>
<pre><code class="language-java">this.cont = new VThreadContinuation(this, task);
this.runContinuation = this::runContinuation;</code></pre><p>方法引用可以展开成一个普通的 Runnable：</p>
<pre><code class="language-java">final class ResumeTask implements Runnable {
    private final VirtualThread virtualThread;
    ResumeTask(VirtualThread virtualThread) {
        this.virtualThread = virtualThread;
    }
    @Override
    public void run() {
        virtualThread.runContinuation();
    }
}</code></pre><p>调度队列中保存的核心内容是这样一个恢复任务。它持有 VirtualThread 引用，而 VirtualThread 再持有 Continuation。</p>
<pre><code class="language-text">ForkJoinPool.WorkQueue
    └── ForkJoinTask
          └── runContinuation
                └── VirtualThread
                      └── Continuation</code></pre><p>VirtualThread 不直接操作 ForkJoinPool 中的某个工作队列。它只调用：</p>
<pre><code class="language-java">scheduler.execute(runContinuation);</code></pre><p>默认调度器收到普通 Runnable 后，会将其包装为 ForkJoinTask，再放入内部队列。OpenJDK 的注释说明，在调度器 Worker 上提交时，任务进入本地队列；其他线程提交时，任务进入外部提交队列。（GitHub）</p>
<h2>虚拟线程第一次运行</h2>
<p>启动虚拟线程时，内部会把状态由 NEW 修改为 STARTED，随后提交 runContinuation：</p>
<pre><code class="language-java">void start() {
    if (!compareAndSetState(NEW, STARTED)) {
        throw new IllegalThreadStateException();
    }
    submitRunContinuation();
}
private void submitRunContinuation() {
    scheduler.execute(runContinuation);
}</code></pre><p>某条 Carrier Thread 从 ForkJoinPool 队列中取出任务，最终调用：</p>
<pre><code class="language-java">virtualThread.runContinuation();</code></pre><p>runContinuation() 的主要结构如下：</p>
<pre><code class="language-java">private void runContinuation() {
    int initialState = state();
    if (initialState != STARTED
            && initialState != UNPARKED
            && initialState != YIELDED) {
        return;
    }
    if (!compareAndSetState(initialState, RUNNING)) {
        return;
    }
    mount();
    try {
        cont.run();
    } finally {
        unmount();
        if (cont.isDone()) {
            afterDone();
        } else {
            afterYield();
        }
    }
}</code></pre><p>mount() 将当前平台线程记录为 Carrier，并修改 JVM 中的当前线程身份：</p>
<pre><code class="language-java">private void mount() {
    Thread carrier = Thread.currentCarrierThread();
    setCarrierThread(carrier);
    carrier.setCurrentThread(this);
}</code></pre><p>因此，虚拟线程中的业务代码调用：</p>
<pre><code class="language-java">Thread.currentThread()</code></pre><p>得到的是 VirtualThread 对象，而不是底层 Carrier Thread。</p>
<p>随后执行：</p>
<pre><code class="language-java">cont.run();</code></pre><p>第一次调用时，Continuation 从用户任务入口开始执行。OpenJDK 的 runContinuation() 确实是在 mount() 后调用 cont.run()，并在 finally 中完成 unmount() 和后续状态处理。（GitHub）</p>
<h2>Continuation.yield() 如何释放 Carrier</h2>
<p>假设业务代码的调用关系如下：</p>
<pre><code class="language-java">void handleRequest(Socket socket) throws IOException {
    User user = loadUser();
    byte[] buffer = new byte[1024];
    int count = socket.getInputStream().read(buffer);
    process(user, buffer, count);
}</code></pre><p>虚拟线程执行到网络等待位置时，Carrier Thread 上的逻辑调用栈可能是：</p>
<pre><code class="language-text">ForkJoinPool.runWorker()
VirtualThread.runContinuation()
Continuation.run()
handleRequest()
InputStream.read()
NioSocketImpl.implRead()
Poller.poll()
LockSupport.park()
VirtualThread.park()
Continuation.yield()</code></pre><p>执行 Continuation.yield() 后，JVM 会冻结属于当前 Continuation 的栈帧。局部变量、对象引用、调用关系和程序执行位置都会被保留。</p>
<p>例如，handleRequest() 对应的栈帧中可能存在：</p>
<pre><code class="language-text">socket  -&gt; Socket@100
user    -&gt; User@200
buffer  -&gt; byte[]@300
count   -&gt; 尚未赋值</code></pre><p>这些数据属于虚拟线程的执行状态，不再依赖当前 Carrier Thread 的平台栈。</p>
<p>冻结成功后，控制流会离开 Continuation，回到调用 cont.run() 的位置。此时属于虚拟线程的深层调用栈已经从 Carrier Thread 上移走，Carrier Thread 上只剩调度器相关栈帧：</p>
<pre><code class="language-text">ForkJoinPool.runWorker()
VirtualThread.runContinuation()</code></pre><p>cont.run() 随后返回，runContinuation() 进入 finally，执行 unmount()：</p>
<pre><code class="language-java">private void unmount() {
    Thread carrier = this.carrierThread;
    carrier.setCurrentThread(carrier);
    setCarrierThread(null);
}</code></pre><p>runContinuation() 返回后，Carrier Thread 回到 ForkJoinPool 的工作循环，从队列中获取其他任务。</p>
<p>后续某条 Carrier Thread 再次调用 cont.run() 时，JVM 恢复此前冻结的栈帧，原来的 Continuation.yield() 开始返回，调用链依次继续：</p>
<pre><code class="language-text">Continuation.yield() 返回
VirtualThread.park() 返回
LockSupport.park() 返回
Poller.poll() 返回
NioSocketImpl.implRead() 继续</code></pre><p>这里没有重新调用 handleRequest()，也没有从方法开头重新执行。恢复点位于此前暂停的位置。</p>
<h2>park() 为什么不会立即重新入队</h2>
<p>Continuation.yield() 只负责暂停执行，它并不决定虚拟线程什么时候继续。调度策略取决于是谁调用了它。</p>
<p>虚拟线程调用 Thread.yield() 时，当前线程依然具备运行条件，只是暂时让出 Carrier。执行栈冻结后，afterYield() 会立即重新提交 runContinuation：</p>
<pre><code class="language-text">RUNNING
  -&gt; YIELDING
  -&gt; YIELDED
  -&gt; 重新提交
  -&gt; RUNNING</code></pre><p>LockSupport.park() 表达的是等待条件尚未满足。例如等待网络数据、等待锁释放或者等待队列元素。执行栈冻结后，虚拟线程不能立即重新进入运行队列，否则会不断恢复、检查条件、再次挂起，造成 CPU 空转。</p>
<p>它的状态变化为：</p>
<pre><code class="language-text">RUNNING
  -&gt; PARKING
  -&gt; PARKED</code></pre><p>只有其他线程调用 unpark() 后，它才重新具备运行条件：</p>
<pre><code class="language-text">PARKED
  -&gt; UNPARKED
  -&gt; 重新提交
  -&gt; RUNNING</code></pre><p>OpenJDK 的 afterYield() 会检查当前状态。PARKING 会转为 PARKED，默认不重新提交；YIELDING 会转为 YIELDED 并立即提交。（GitHub）</p>
<p>对应代码可以简化为：</p>
<pre><code class="language-java">private void afterYield() {
    int s = state();
    if (s == PARKING) {
        setState(PARKED);
        if (parkPermit
                && compareAndSetState(PARKED, UNPARKED)) {
            submitRunContinuation();
        }
        return;
    }
    if (s == YIELDING) {
        setState(YIELDED);
        submitRunContinuation();
    }
}</code></pre><h2>Socket 读取如何进入 Poller</h2>
<p>下面进入网络 I/O 路径。</p>
<pre><code class="language-java">int count = socket.getInputStream().read(buffer);</code></pre><p>以 JDK 21 在 Linux 上的实现为例，JDK 会先尝试读取 Socket。内核接收缓冲区已有数据时，读取直接完成，不需要挂起虚拟线程。</p>
<p>如果当前没有数据，非阻塞读取会得到 EAGAIN 或等价状态。JDK 随后将文件描述符注册给 Poller，并挂起当前虚拟线程。JEP 444 描述了这一处理：JDK 中的阻塞网络操作无法立即完成时，虚拟线程会卸载；I/O 可以完成后，再把该虚拟线程提交回调度器。（OpenJDK）</p>
<p>简化后的读取逻辑如下：</p>
<pre><code class="language-java">int read(int fd, byte[] buffer) throws IOException {
    while (true) {
        int result = nonBlockingRead(fd, buffer);
        if (result &gt;= 0) {
            return result;
        }
        Poller.poll(fd);
    }
}</code></pre><p>Poller.poll(fd) 内部可以抽象为：</p>
<pre><code class="language-java">void poll(int fd) {
    Thread thread = Thread.currentThread();
    waiters.put(fd, thread);
    registerWithEpoll(fd);
    LockSupport.park();
}</code></pre><p>由于当前执行的是虚拟线程，Thread.currentThread() 返回对应的 VirtualThread。</p>
<p>此时存在两份关联。</p>
<p>Linux 内核中的 epoll 记录：</p>
<pre><code class="language-text">监听 fd 37 的可读事件</code></pre><p>JDK 的 Poller 记录：</p>
<pre><code class="language-text">fd 37 -&gt; VirtualThread@500</code></pre><p>操作系统只认识文件描述符，不认识 Java 虚拟线程。Poller 中的映射负责将内核事件重新关联到 Java 线程对象。JDK 21 的 Poller 就是网络事件通知和虚拟线程唤醒之间的中间层。（GitHub）</p>
<p>注册完成后，当前虚拟线程执行：</p>
<pre><code class="language-java">LockSupport.park();</code></pre><p>park() 进入 VirtualThread.park()，设置 PARKING 状态并调用 Continuation.yield()。执行栈冻结后，虚拟线程变成 PARKED，Carrier Thread 被释放。</p>
<p>等待期间的对象关系如下：</p>
<pre><code class="language-text">Linux epoll
    └── fd 37
Poller
    └── fd 37 -&gt; VirtualThread@500
VirtualThread@500
    ├── state = PARKED
    ├── carrierThread = null
    └── Continuation
          └── 保存 read() 及其上层调用栈
ForkJoinPool
    └── 当前没有该虚拟线程的恢复任务</code></pre><p>虚拟线程此时没有占用 Carrier Thread，也不在运行队列中。Poller 保存它的等待关系，Continuation 保存它的执行状态。</p>
<h2>网络就绪后如何重新调度</h2>
<p>网络数据到达后，Linux 协议栈将字节放入 Socket 的内核接收缓冲区，并将 fd 标记为可读。</p>
<p>Linux 上的 Poller 线程通常阻塞在 epoll_wait()。事件返回后，它获得就绪的文件描述符：</p>
<pre><code class="language-java">int[] readyFds = epollWait();
for (int fd : readyFds) {
    Thread thread = waiters.remove(fd);
    if (thread != null) {
        LockSupport.unpark(thread);
    }
}</code></pre><p>Poller 不执行用户的 handleRequest()，也不会在 Poller Thread 中调用原来的 InputStream.read()。它只找到等待该 fd 的虚拟线程，然后调用 unpark()。</p>
<p>VirtualThread.unpark() 的关键逻辑如下：</p>
<pre><code class="language-java">void unpark() {
    Thread currentThread = Thread.currentThread();
    if (!getAndSetParkPermit(true)
            && currentThread != this) {
        int s = state();
        if (s == PARKED
                && compareAndSetState(PARKED, UNPARKED)) {
            submitRunContinuation();
        }
    }
}</code></pre><p>submitRunContinuation() 最终执行：</p>
<pre><code class="language-java">scheduler.execute(runContinuation);</code></pre><p>这一步把虚拟线程重新交给调度器。OpenJDK 的 unpark() 会先设置 parkPermit，再通过状态 CAS 将已挂起的虚拟线程转为 UNPARKED，随后提交其恢复任务。（GitHub）</p>
<p>由于调用 unpark() 的通常是 Poller Thread，它不属于默认调度器的 Worker，因此这次任务一般走 ForkJoinPool 的外部提交路径。</p>
<p>某条 Carrier Thread 之后取出该任务，再次调用：</p>
<pre><code class="language-java">virtualThread.runContinuation();</code></pre><p>状态由 UNPARKED 修改为 RUNNING，虚拟线程挂载到新的 Carrier Thread，然后执行：</p>
<pre><code class="language-java">cont.run();</code></pre><p>此前冻结的调用栈被恢复，LockSupport.park() 返回，Socket 读取逻辑继续。</p>
<h2>网络数据没有进入调度队列</h2>
<p>调度队列中没有网络数据，也没有下面这种对象：</p>
<pre><code class="language-java">new ResumeTask(virtualThread, networkData);</code></pre><p>Poller 的职责是通知“fd 现在可能可读”，调度器负责安排虚拟线程继续执行。网络字节仍然保存在 Socket 的内核接收缓冲区中。</p>
<p>虚拟线程恢复后，读取循环再次调用操作系统：</p>
<pre><code class="language-java">int read(int fd, byte[] buffer) throws IOException {
    while (true) {
        int result = nonBlockingRead(fd, buffer);
        if (result &gt;= 0) {
            return result;
        }
        Poller.poll(fd);
        // park 返回后，再次进入循环
    }
}</code></pre><p>第一次 nonBlockingRead() 返回 EAGAIN，虚拟线程进入等待。</p>
<p>网络就绪后，Poller 调用 unpark()，虚拟线程恢复，随后再次执行：</p>
<pre><code class="language-java">nonBlockingRead(fd, buffer);</code></pre><p>这一次内核接收缓冲区已有数据，系统调用将字节复制到原来的 buffer 中，并返回字节数。</p>
<p>之所以仍然能够访问原来的 buffer，是因为 Continuation 保存的调用栈中仍持有：</p>
<pre><code class="language-text">buffer -&gt; byte[]@300</code></pre><p>数据传递路径为：</p>
<pre><code class="language-text">网卡
  -&gt; Linux 网络协议栈
  -&gt; Socket 内核接收缓冲区
  -&gt; 虚拟线程被唤醒
  -&gt; 恢复 read() 调用栈
  -&gt; 再次执行 read()
  -&gt; 数据复制到原 buffer</code></pre><p>调度队列只传递执行资格。Poller 只传递就绪通知。网络数据由内核 Socket 缓冲区保存。</p>
<h2>parkPermit 解决丢失唤醒</h2>
<p>虚拟线程准备挂起时，网络事件可能提前到达。</p>
<p>考虑下面的时序：</p>
<pre><code class="language-text">虚拟线程发现当前没有数据
Poller 注册 fd
网络数据立即到达
Poller 调用 unpark()
虚拟线程随后才执行 park()</code></pre><p>如果 unpark() 只能唤醒已经处于 PARKED 状态的线程，这次通知就会丢失。虚拟线程之后进入 park()，可能一直无法恢复。</p>
<p>parkPermit 相当于一个容量为 1 的许可：</p>
<pre><code class="language-java">private volatile boolean parkPermit;</code></pre><p>unpark() 首先设置：</p>
<pre><code class="language-java">parkPermit = true;</code></pre><p>park() 开始时先尝试消费许可：</p>
<pre><code class="language-java">if (getAndSetParkPermit(false)) {
    return;
}</code></pre><p>如果网络事件已经到达，park() 直接返回，不再冻结调用栈。</p>
<p>连续调用多次 unpark() 也只会保存一个许可：</p>
<pre><code class="language-java">unpark(thread);
unpark(thread);
unpark(thread);</code></pre><p>最终仍然只是：</p>
<pre><code class="language-text">parkPermit = true</code></pre><p>下一次 park() 消费该许可后恢复为 false。</p>
<p>这也是 JUC 同步器通常使用循环检查条件的原因：</p>
<pre><code class="language-java">while (!conditionSatisfied()) {
    LockSupport.park();
}</code></pre><p>park() 返回只说明线程重新获得执行机会，业务条件仍需重新判断。</p>
<h2>PARKING 解决重复调度</h2>
<p>RUNNING 和 PARKED 两个状态还不够，因为调用栈冻结需要一段执行过程。</p>
<p>虚拟线程会先执行：</p>
<pre><code class="language-java">state = PARKING;
Continuation.yield();</code></pre><p>在设置 PARKING 后、Continuation 完成冻结前，Poller 可能已经调用 unpark()。</p>
<p>此时原 Carrier Thread 仍可能执行当前虚拟线程，不能立刻把恢复任务提交给调度器。否则另一条 Carrier Thread 可能同时取出该虚拟线程。</p>
<p>因此，在 PARKING 状态下，unpark() 只设置 parkPermit，暂不提交任务。</p>
<p>等 Continuation 冻结完成，原 Carrier 执行 afterYield()：</p>
<pre><code class="language-java">state = PARKED;
if (parkPermit
        && compareAndSetState(PARKED, UNPARKED)) {
    submitRunContinuation();
}</code></pre><p>这样既保留了提前到达的唤醒通知，也避免两条 Carrier Thread 同时运行同一个虚拟线程。</p>
<p>另外，runContinuation() 在恢复前还会对状态执行 CAS。即使调度队列中意外出现两个指向同一虚拟线程的恢复任务，也只有一条 Carrier Thread 能成功将状态改为 RUNNING。</p>
<h2>三条线程如何协作</h2>
<p>完整实现虽然涉及 ForkJoinPool、Continuation、Poller 和操作系统，但运行过程仍然可以拆成三条普通线程循环。</p>
<p>Carrier Thread 的循环：</p>
<pre><code class="language-java">while (true) {
    Runnable task = scheduler.takeTask();
    task.run();
}</code></pre><p>Poller Thread 的循环：</p>
<pre><code class="language-java">while (true) {
    int[] readyFds = epollWait();
    for (int fd : readyFds) {
        VirtualThread thread = waiters.remove(fd);
        LockSupport.unpark(thread);
    }
}</code></pre><p>虚拟线程中的 Socket 读取循环：</p>
<pre><code class="language-java">while (true) {
    int count = tryRead(fd, buffer);
    if (count &gt;= 0) {
        return count;
    }
    registerPoller(fd, Thread.currentThread());
    LockSupport.park();
}</code></pre><p>三条执行流都保持线性。它们通过以下对象建立联系：</p>
<pre><code class="language-text">ForkJoinPool WorkQueue
    保存可运行的 runContinuation
VirtualThread
    持有 scheduler、Continuation 和线程状态
Poller
    保存 fd 到 VirtualThread 的等待关系
Socket 内核接收缓冲区
    保存网络数据</code></pre><p>Carrier Thread 调用恢复任务，Poller 根据 fd 找到等待线程，虚拟线程通过 scheduler 重新提交自己，Continuation 保存暂停位置和局部对象引用。</p>
<h2>完整调用路径</h2>
<p>将整个过程连接起来，可以得到下面的关键路径：</p>
<pre><code class="language-text">Thread.startVirtualThread
  -&gt; VirtualThread.start
  -&gt; submitRunContinuation
  -&gt; ForkJoinPool 工作队列
  -&gt; Carrier 取出任务
  -&gt; VirtualThread.runContinuation
  -&gt; mount
  -&gt; Continuation.run
  -&gt; 用户代码
  -&gt; Socket.read
  -&gt; 非阻塞 read 返回 EAGAIN
  -&gt; Poller 注册 fd
  -&gt; LockSupport.park
  -&gt; VirtualThread.park
  -&gt; Continuation.yield
  -&gt; 调用栈被冻结
  -&gt; cont.run 暂时返回
  -&gt; unmount
  -&gt; VirtualThread 进入 PARKED
  -&gt; Carrier 返回调度循环
网络数据到达
  -&gt; Socket 内核接收缓冲区
  -&gt; epoll_wait 返回 fd
  -&gt; Poller 找到 VirtualThread
  -&gt; LockSupport.unpark
  -&gt; VirtualThread.unpark
  -&gt; submitRunContinuation
  -&gt; ForkJoinPool 工作队列
  -&gt; Carrier 取出恢复任务
  -&gt; VirtualThread.runContinuation
  -&gt; mount
  -&gt; Continuation.run
  -&gt; 恢复被冻结的调用栈
  -&gt; LockSupport.park 返回
  -&gt; 再次执行 read
  -&gt; 数据复制到原 buffer
  -&gt; 用户代码继续</code></pre><p>理解这条路径后，虚拟线程的调度就不再神秘。Carrier Thread 始终执行普通任务循环；Continuation 允许当前任务在保留调用栈的情况下暂时返回；Poller 将网络 fd 与等待中的虚拟线程关联起来；unpark() 在事件到达后重新提交恢复任务。</p>
<h2>JDK 21 与 JDK 24 的 Pinning 差异</h2>
<p>JDK 21 中，如果虚拟线程在持有 synchronized Monitor 时进入某些阻塞操作，Continuation 可能无法卸载，虚拟线程和 Carrier Thread 会一起阻塞，这种情况称为 Pinning。</p>
<p>因此，JDK 21 阶段经常建议避免在长时间 synchronized 临界区中执行网络 I/O，必要时使用 ReentrantLock。</p>
<p>JDK 24 交付了 JEP 491，修改了 JVM Monitor 与虚拟线程的协作方式。虚拟线程在 synchronized 方法或代码块中阻塞时，绝大部分情况下也能卸载并释放 Carrier Thread。JDK 24 之后，选择 synchronized 还是 java.util.concurrent.locks，应更多依据可中断获取、公平性、超时和条件变量等语义，而不是单纯为了规避 Monitor Pinning。（OpenJDK）</p>
<p>持锁期间执行长时间网络 I/O 仍然需要谨慎。即便 Carrier 能够释放，锁本身仍然处于持有状态，其他需要进入同一临界区的线程仍会等待。</p>
<h2>参考源码</h2>
<ul>
<li>JEP 444：Virtual Threads。（OpenJDK）</li>
<li>JDK 21 java.lang.VirtualThread。（GitHub）</li>
<li>JDK 21 jdk.internal.vm.Continuation。（GitHub）</li>
<li>JDK 21 sun.nio.ch.Poller。（GitHub）</li>
<li>JEP 491：Synchronize Virtual Threads without Pinning。（OpenJDK）</li>
</ul>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/java-virtual-threads-network-io-carrier-continuation-poller#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">162894276609445888</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>我的 TypeScript 全栈技术栈取舍：SSR、SPA 与 API 的边界</title>
    <link>https://liuyaowen.cn/posts/default/typescript-fullstack-ssr-spa-api-boundary</link>
    <pubDate>Thu, 23 Jul 2026 03:51:05 GMT</pubDate>
    <description>最近在整理一套可以长期复用的 TypeScript 项目模板。

我的项目大多是 AI 工具、管理后</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/typescript-fullstack-ssr-spa-api-boundary'>https://liuyaowen.cn/posts/default/typescript-fullstack-ssr-spa-api-boundary</a></blockquote>
          <p>最近在整理一套可以长期复用的 TypeScript 项目模板。</p>
<p>我的项目大多是 AI 工具、管理后台、工作流、编辑器和桌面端配套应用。技术栈以 React、TypeScript 和 Vite 为主，后端会使用 Node.js、PostgreSQL 和 Drizzle。</p>
<p>一开始考虑的是直接选择一个 React 全栈框架，把路由、SSR、数据请求、API 和部署统一起来。重点看了 Next.js、React Router Framework Mode 和 TanStack Start，也顺带关注了 Remix 3。</p>
<p>研究了一段时间后，我发现框架能力并不是越完整越合适。对于偏重交互的应用，SSR 和同构运行模型会引入不少额外概念，而这些概念未必能直接转化为产品收益。</p>
<p>最后确定的方向是：</p>
<pre><code class="language-text">公开站点使用 SSR 或 SSG
登录后的产品使用 SPA
业务能力通过统一 HTTP API 提供</code></pre><p>这篇文章记录一下具体的判断过程。</p>
<hr>
<h2>TanStack Start 的吸引力</h2>
<p>TanStack Start 很符合我对现代 TypeScript 框架的期待。</p>
<p>它建立在 TanStack Router 和 Vite 之上，提供类型安全路由、Search Params、Loader、Server Function、Middleware 和 SSR。再配合 TanStack Query、Form 和 Table，可以形成一套完整的前端应用开发体系。</p>
<p>一个简单的服务端查询可以写成：</p>
<pre><code class="language-ts">export const listProjectsFn = createServerFn()
  .handler(async () =&gt; {
    return db.select().from(projects)
  })</code></pre><p>路由中直接调用：</p>
<pre><code class="language-tsx">export const Route = createFileRoute('/projects')({
  loader: () =&gt; listProjectsFn(),
  component: ProjectsPage,
})</code></pre><p>组件通过路由读取结果：</p>
<pre><code class="language-tsx">function ProjectsPage() {
  const projects = Route.useLoaderData()

  return &lt;ProjectList projects={projects} /&gt;
}</code></pre><p>这种写法不需要手动维护 HTTP Client 和返回类型。Server Function 在客户端表现得像一个普通的异步函数，输入和输出类型可以直接推导。</p>
<p>对于简单页面，这套模式很顺畅。</p>
<p>问题主要出现在页面交互开始变复杂之后。</p>
<p>例如，列表需要手动刷新，可以调用：</p>
<pre><code class="language-ts">router.invalidate()</code></pre><p>如果列表使用 TanStack Query，则会写成：</p>
<pre><code class="language-ts">query.refetch()</code></pre><p>新增一条记录后，可以重新执行路由 Loader：</p>
<pre><code class="language-ts">await router.invalidate()</code></pre><p>也可以让 Query Cache 失效：</p>
<pre><code class="language-ts">await queryClient.invalidateQueries({
  queryKey: ['projects'],
})</code></pre><p>这时项目里可能同时存在两套数据生命周期：</p>
<pre><code class="language-text">Route Loader 管理路由数据
TanStack Query 管理客户端缓存</code></pre><p>如果继续加入 Query Options、Mutation、TanStack Form、TanStack Table 和 Zod，一个普通 Feature 很容易拆出很多文件：</p>
<pre><code class="language-text">project.server.ts
project.functions.ts
project.queries.ts
project.mutations.ts
project.schema.ts
project.form.ts
project.columns.tsx</code></pre><p>这些模块单独看都有合理用途，但组合起来后，简单业务的框架代码占比会比较高。</p>
<p>TanStack 生态本身没有要求项目必须这样写。问题在于它提供的能力很多，开发者很容易在项目初期就把完整方案铺开。</p>
<p>对于复杂 Dashboard、实时数据、无限列表和乐观更新，这些抽象能够发挥作用。对于普通 CRUD，它们可能显得偏重。</p>
<hr>
<h2>Next.js 的复杂度在运行边界</h2>
<p>Next.js 的代码通常更短。</p>
<p>Server Component 可以直接查询数据库：</p>
<pre><code class="language-tsx">export default async function ProjectPage() {
  const projectList = await db
    .select()
    .from(projects)

  return &lt;ProjectList projects={projectList} /&gt;
}</code></pre><p>修改操作可以写成 Server Action：</p>
<pre><code class="language-ts">'use server'

export async function createProject(
  formData: FormData,
) {
  await db.insert(projects).values({
    name: String(formData.get('name')),
  })

  revalidatePath('/projects')
}</code></pre><p>页面直接提交：</p>
<pre><code class="language-tsx">&lt;form action={createProject}&gt;
  &lt;input name="name" /&gt;
  &lt;button type="submit"&gt;创建&lt;/button&gt;
&lt;/form&gt;</code></pre><p>这种模式对内容站、电商和以服务端渲染为主的页面很合适。页面、数据读取和修改逻辑可以放在相近的位置，项目初期的代码量也不大。</p>
<p>随着客户端交互增加，代码会逐渐涉及：</p>
<pre><code class="language-text">Server Component
Client Component
Server Action
Route Handler
Suspense
缓存失效
序列化边界</code></pre><p>Next.js 的额外成本通常不表现为大量包装函数，而是运行模型和框架规则。</p>
<p>对于编辑器、工作流和管理后台，页面里往往有较多本地状态、轮询、实时数据、弹窗和多区域交互。此时 Server Component 与 Client Component 的边界需要持续维护。</p>
<p>Next.js 依然是成熟的生产方案，只是它更偏向以服务端组件和页面渲染为中心组织应用，不完全符合我目前的产品形态。</p>
<hr>
<h2>React Router 的数据模型比较统一</h2>
<p>React Router Framework Mode 使用 Loader 和 Action 组织路由数据。</p>
<pre><code class="language-tsx">export async function loader() {
  return {
    projects: await listProjects(),
  }
}

export async function action({
  request,
}: Route.ActionArgs) {
  const formData = await request.formData()

  return createProject({
    name: String(formData.get('name')),
  })
}

export default function ProjectsPage({
  loaderData,
}: Route.ComponentProps) {
  return (
    &lt;&gt;
      &lt;ProjectList projects={loaderData.projects} /&gt;

      &lt;Form method="post"&gt;
        &lt;input name="name" /&gt;
        &lt;button type="submit"&gt;创建&lt;/button&gt;
      &lt;/Form&gt;
    &lt;/&gt;
  )
}</code></pre><p>它的模型比较接近传统 Web 开发：</p>
<pre><code class="language-text">读取数据使用 loader
修改数据使用 action
页面提交使用 Form 或 fetcher</code></pre><p>Action 完成后，React Router 会重新验证相关 Loader，因此普通表单场景不需要额外维护 Query Cache。</p>
<p>这种方式适合表单和路由驱动的应用。Request、Response、Cookie、Session 和上传都使用标准 Web API，调试路径也比较清楚。</p>
<p>页面内操作较多时，Action 可能需要根据 <code>intent</code> 分发不同业务：</p>
<pre><code class="language-ts">switch (formData.get('intent')) {
  case 'create':
    return createProject(formData)

  case 'archive':
    return archiveProject(formData)

  case 'delete':
    return deleteProject(formData)
}</code></pre><p>也可以拆成独立 Resource Route。</p>
<p>React Router 的代码量通常介于 Next.js 和完整 TanStack 方案之间。它适合传统 Web 数据流，但对于复杂客户端缓存和多个组件共享远程状态，项目最终仍可能引入 TanStack Query。</p>
<hr>
<h2>SPA 的数据流更适合产品后台</h2>
<p>把几个全栈框架放在一起比较之后，我重新审视了普通 SPA。</p>
<p>一个 SPA 项目的远程数据流很直接：</p>
<pre><code class="language-text">React Component
    ↓
TanStack Query
    ↓
HTTP API
    ↓
业务逻辑
    ↓
数据库</code></pre><p>查询：</p>
<pre><code class="language-tsx">const projectsQuery = useQuery({
  queryKey: ['projects'],
  queryFn: projectApi.list,
})</code></pre><p>手动刷新：</p>
<pre><code class="language-tsx">&lt;button onClick={() =&gt; projectsQuery.refetch()}&gt;
  刷新
&lt;/button&gt;</code></pre><p>新增后刷新列表：</p>
<pre><code class="language-tsx">const createProjectMutation = useMutation({
  mutationFn: projectApi.create,

  onSuccess: () =&gt; {
    queryClient.invalidateQueries({
      queryKey: ['projects'],
    })
  },
})</code></pre><p>这套模型只有一份远程数据状态。</p>
<pre><code class="language-text">查询使用 useQuery
修改使用 useMutation
缓存刷新使用 invalidateQueries
页面参数使用 Router Search Params
本地状态使用 useState 或 Jotai</code></pre><p>对于登录后的管理系统，这样的模型已经足够。</p>
<p>管理后台、AI 工具和工作流应用通常不会依赖搜索引擎索引内部页面。用户登录后会停留较长时间，主要操作发生在浏览器端。</p>
<p>SSR 在这些页面中的收益有限，但会增加 Hydration、服务端与客户端模块边界、Loader 生命周期和缓存同步等问题。</p>
<p>因此，我决定不让整个产品都进入 SSR 模型。</p>
<hr>
<h2>SSR 与 SPA 按路由区域拆分</h2>
<p>最终的产品路由可以分成三个区域：</p>
<pre><code class="language-text">/                     官网首页
/pricing              定价页面
/blog/*                博客
/docs/*                文档
/tools/*               公开工具页面

/app/*                 登录后的产品
/app/projects/*        项目管理
/app/workflows/*       工作流
/app/analytics/*       数据分析

/api/*                 业务 API</code></pre><p>公开区域使用 SSR 或 SSG，负责：</p>
<ul>
<li>SEO；</li>
<li>首屏内容；</li>
<li>Open Graph；</li>
<li>博客和文档；</li>
<li>产品介绍；</li>
<li>公开工具页面。</li>
</ul>
<p><code>/app</code> 区域使用 SPA，负责：</p>
<ul>
<li>Dashboard；</li>
<li>工作流；</li>
<li>编辑器；</li>
<li>AI 生成；</li>
<li>实时数据；</li>
<li>复杂表格；</li>
<li>多面板交互。</li>
</ul>
<p>API 负责：</p>
<ul>
<li>认证；</li>
<li>权限；</li>
<li>业务逻辑；</li>
<li>数据库访问；</li>
<li>文件上传；</li>
<li>Webhook；</li>
<li>Desktop 和其他客户端。</li>
</ul>
<p>这样每个区域只维护一种主要数据模型。</p>
<hr>
<h2>最终的 Monorepo 结构</h2>
<p>目前比较适合我的结构是：</p>
<pre><code class="language-text">repo/
├── apps/
│   ├── site/
│   ├── web/
│   ├── api/
│   ├── desktop/
│   └── worker/
│
├── packages/
│   ├── ui/
│   ├── contracts/
│   ├── db/
│   ├── shared/
│   └── config/
│
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json</code></pre><p>各应用的职责如下：</p>
<pre><code class="language-text">apps/site
    官网、博客、文档和公开页面

apps/web
    登录后的 SPA 产品

apps/api
    统一业务 API

apps/desktop
    Tauri 客户端，需要时增加

apps/worker
    异步任务和 AI Job，需要时增加</code></pre><p>共享包保持克制。</p>
<p><code>packages/ui</code> 存放基础组件、样式 Token 和动画预设。</p>
<p><code>packages/contracts</code> 存放真正需要跨客户端共享的 Zod Schema、错误结构和 API 类型。</p>
<p><code>packages/db</code> 存放 Drizzle Schema、Migration 和数据库 Client。</p>
<p>业务代码默认留在具体 App 中，不会在项目开始阶段就提取到 <code>packages/core</code>。</p>
<hr>
<h2>API 使用 Hono</h2>
<p>API 层选择 Hono。</p>
<p>它的路由写法比较轻：</p>
<pre><code class="language-ts">const app = new Hono()

const projectRoutes = app
  .get('/projects', async (c) =&gt; {
    return c.json(await listProjects())
  })
  .post(
    '/projects',
    zValidator('json', createProjectSchema),
    async (c) =&gt; {
      const input = c.req.valid('json')
      const project = await createProject(input)

      return c.json(project, 201)
    },
  )</code></pre><p>客户端可以使用 Hono RPC：</p>
<pre><code class="language-ts">const client = hc&lt;AppType&gt;('/api')</code></pre><p>在 TanStack Query 中调用：</p>
<pre><code class="language-ts">export function useProjects() {
  return useQuery({
    queryKey: ['projects'],

    queryFn: async () =&gt; {
      const response =
        await client.projects.$get()

      if (!response.ok) {
        throw new Error(
          'Failed to load projects',
        )
      }

      return response.json()
    },
  })
}</code></pre><p>完整调用链如下：</p>
<pre><code class="language-text">Component
    ↓
TanStack Query
    ↓
Hono RPC Client
    ↓
Hono Route
    ↓
Feature Function
    ↓
Drizzle</code></pre><p>这套 API 可以同时提供给 Web、Desktop、浏览器扩展、Agent 和 Webhook。</p>
<p>相比框架内部专用的 Server Function，HTTP API 的复用范围更大。</p>
<hr>
<h2>后端按 Feature 组织</h2>
<p>前后端拆分不意味着后端需要复制 Java 的分层方式。</p>
<p>不会采用这种全局目录：</p>
<pre><code class="language-text">controllers/
services/
repositories/
mappers/
dto/</code></pre><p>API 仍然按 Feature 组织：</p>
<pre><code class="language-text">apps/api/src/features/project/
├── project.routes.ts
├── project.schema.ts
├── project.query.ts
├── project.command.ts
└── project.policy.ts</code></pre><p>简单 Feature 只保留必要文件：</p>
<pre><code class="language-text">project.routes.ts
project.schema.ts</code></pre><p>数据库实例可以直接导入：</p>
<pre><code class="language-ts">import { db } from '@repo/db'</code></pre><p>只有存在多个实现、复杂测试或运行时替换需求时，才使用参数注入或构造器注入。</p>
<p>不会为了保持架构形式，为每张表创建 Repository、Service 和 Mapper。</p>
<hr>
<h2>工具链保持激进，运行层保持稳定</h2>
<p>项目工具链会使用相对新的方案：</p>
<pre><code class="language-text">Vite
Rolldown
Oxc
Oxlint
Oxfmt
Vite+
Vitest
Playwright</code></pre><p>这些工具不会直接处理线上业务数据。即使某个版本出现兼容问题，也可以回退到对应的底层命令。</p>
<p>应用层使用：</p>
<pre><code class="language-text">React
TanStack Router
TanStack Query
TanStack Form
TanStack Table
Jotai
Tailwind CSS
shadcn/ui
Base UI
Motion
Lucide</code></pre><p>其中 TanStack Form、Table 和 Jotai 都按需使用，不作为每个页面的固定依赖。</p>
<p>数据与安全层相对保守：</p>
<pre><code class="language-text">Node.js LTS
PostgreSQL
Drizzle ORM
Better Auth 稳定版本
Zod
正式数据库 Migration
Cookie Session</code></pre><p>工具升级失败会影响开发效率，认证和数据库升级失败则可能影响用户和数据。两部分不应该使用相同的升级策略。</p>
<hr>
<h2>部署方式</h2>
<p>源码上拆成多个 App，不代表生产环境必须维护大量服务。</p>
<p>可以使用同一个域名：</p>
<pre><code class="language-text">example.com/
    → Site

example.com/app/*
    → SPA

example.com/api/*
    → API</code></pre><p>网关按路径分流：</p>
<pre><code class="language-text">/          → Site
/app/*     → Web
/api/*     → API</code></pre><p>认证保持同源 Cookie。SPA 调用 <code>/api</code> 时不需要处理跨域 Token，Desktop 等外部客户端再使用单独的认证方式。</p>
<p>个人项目早期也可以让 Hono 同时提供 API 和 SPA 静态文件，保持一个 Docker 镜像和一个 Node 进程。</p>
<hr>
<h2>结论</h2>
<p>这次技术选型没有确定一个统一管理所有能力的全栈框架，而是把不同类型的页面放回它们更适合的运行模型中。</p>
<pre><code class="language-text">公开内容使用 SSR 或 SSG
登录后的产品使用 SPA
通用业务能力使用 HTTP API</code></pre><p>最终技术栈大致是：</p>
<pre><code class="language-text">Site
    Astro 或其他 SSR / SSG 方案

Web
    Vite
    React
    TanStack Router
    TanStack Query

API
    Hono
    Drizzle
    PostgreSQL
    Better Auth
    Zod

UI
    Tailwind CSS
    shadcn/ui
    Base UI
    Motion
    Lucide

Tooling
    Vite
    Rolldown
    Oxc
    Oxlint
    Oxfmt
    Vitest
    Playwright</code></pre><p>这套结构保留了现代 TypeScript 工具链，也让业务后台继续使用熟悉的 SPA 数据流。</p>
<p>框架能力可以按项目需求增加。对于大多数偏工具型的产品，保持清楚的客户端、API 和数据边界，比在每个页面中同时组合 SSR、Loader、Server Function 和 Query 更容易维护。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/typescript-fullstack-ssr-spa-api-boundary#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">162045532200505344</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>为什么 Vite、Webpack、Rspack 能实现 @ 别名</title>
    <link>https://liuyaowen.cn/posts/default/vite-webpack-rspack</link>
    <pubDate>Tue, 09 Jun 2026 11:39:13 GMT</pubDate>
    <description>Resolver 是什么?

对于：

import Button from &quot;@/componen</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/vite-webpack-rspack'>https://liuyaowen.cn/posts/default/vite-webpack-rspack</a></blockquote>
          <h2>Resolver 是什么?</h2>
<p>对于：</p>
<pre><code class="language-ts">import Button from "@/components/Button";</code></pre><p>JavaScript 引擎实际上只看到了 这些：</p>
<pre><code class="language-text">specifier = "@/components/Button"</code></pre><p>接下来需要解决的问题是：</p>
<pre><code class="language-text">specifier
    ↓
真实模块</code></pre><p>这个过程称为 Resolution。</p>
<p>Node.js ESM 规范中的入口算法：</p>
<pre><code class="language-text">ESM_RESOLVE(specifier, parentURL)</code></pre><p>本质就是完成这个映射过程。</p>
<h2>Node 默认支持哪些 specifier</h2>
<p>Node 原生 Resolver 只支持四类：</p>
<pre><code class="language-js">import "./foo.js"
import "../foo.js"
import "/app/foo.js"</code></pre><p>路径模块。</p>
<pre><code class="language-js">import react from "react"</code></pre><p>包模块。</p>
<pre><code class="language-js">import db from "#db"</code></pre><p>Package Imports。</p>
<pre><code class="language-js">import x from "file:///app/a.js"</code></pre><p>URL 模块。</p>
<p>除此之外：</p>
<pre><code class="language-js">import x from "@/utils"</code></pre><p>Node 无法解析。</p>
<p>直接进入：</p>
<pre><code class="language-text">Module Not Found</code></pre><p>异常路径。</p>
<h2>为什么 Alias 可以工作</h2>
<p>假设存在：</p>
<pre><code class="language-js">import Button from "@/components/Button"</code></pre><p>Webpack 配置：</p>
<pre><code class="language-js">resolve: {
  alias: {
    "@": "/project/src"
  }
}</code></pre><p>Webpack 在执行真正解析前：</p>
<pre><code class="language-text">@/components/Button</code></pre><p>先经过 Alias Resolver：</p>
<pre><code class="language-text">/project/src/components/Button</code></pre><p>随后再进入正常模块解析。</p>
<p>因此 Alias 并不是一种新的模块类型。</p>
<p>而是：</p>
<pre><code class="language-text">旧 specifier
    ↓
新 specifier</code></pre><p>的一次转换。</p>
<h2>Vite Alias</h2>
<p>Vite：</p>
<pre><code class="language-ts">resolve: {
  alias: {
    "@": path.resolve(__dirname, "src")
  }
}</code></pre><p>执行流程与 Webpack 完全一致。</p>
<p>对于：</p>
<pre><code class="language-ts">import x from "@/utils/request"</code></pre><p>Vite 首先执行：</p>
<pre><code class="language-text">@ -&gt; /project/src</code></pre><p>得到：</p>
<pre><code class="language-text">/project/src/utils/request</code></pre><p>然后继续执行后续解析。</p>
<p>因此：</p>
<pre><code class="language-text">Webpack Alias
Vite Alias
Rspack Alias</code></pre><p>本质是同一个东西。</p>
<p>区别只是 Resolver 实现不同。</p>
<h2>TypeScript Paths 为什么经常失效</h2>
<p>配置：</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "paths": {
      "@/*": ["src/*"]
    }
  }
}</code></pre><p>很多人会发现：</p>
<pre><code class="language-text">VSCode 正常
TypeScript 正常
Node 运行失败</code></pre><p>原因是：</p>
<pre><code class="language-text">paths</code></pre><p>不是运行时 Resolver。</p>
<p>TypeScript 只会在：</p>
<pre><code class="language-text">类型检查
IDE 跳转
编译阶段</code></pre><p>使用 paths。</p>
<p>Node 运行时根本不会读取：</p>
<pre><code class="language-json">tsconfig.json</code></pre><p>因此：</p>
<pre><code class="language-json">{
  "paths": {}
}</code></pre><p>无法让 Node 识别：</p>
<pre><code class="language-js">import "@/utils"</code></pre><p>必须额外配置：</p>
<pre><code class="language-text">Webpack Alias
Vite Alias
Rspack Alias
Node Loader</code></pre><p>其中之一。</p>
<h2>Node Loader 如何实现 Alias</h2>
<p>Node 提供：</p>
<pre><code class="language-js">export async function resolve(
  specifier,
  context,
  nextResolve
)</code></pre><p>允许接管解析过程。</p>
<p>例如：</p>
<pre><code class="language-js">export async function resolve(
  specifier,
  context,
  nextResolve
) {
  if (specifier.startsWith("@/")) {
    return {
      url: pathToFileURL(
        path.resolve(
          process.cwd(),
          "src",
          specifier.slice(2)
        )
      ).href,
      shortCircuit: true
    }
  }

  return nextResolve(specifier, context)
}</code></pre><p>此时：</p>
<pre><code class="language-js">import x from "@/utils"</code></pre><p>会变成：</p>
<pre><code class="language-text">file:///project/src/utils.js</code></pre><p>然后进入正常加载流程。</p>
<h2>Browser Import Map</h2>
<p>浏览器没有：</p>
<pre><code class="language-text">node_modules</code></pre><p>因此：</p>
<pre><code class="language-js">import React from "react"</code></pre><p>无法工作。</p>
<p>Import Map 的作用：</p>
<pre><code class="language-html">&lt;script type="importmap"&gt;
{
  "imports": {
    "react": "/vendor/react.js"
  }
}
&lt;/script&gt;</code></pre><p>执行：</p>
<pre><code class="language-js">import React from "react"</code></pre><p>时。</p>
<p>浏览器先映射：</p>
<pre><code class="language-text">react
    ↓
/vendor/react.js</code></pre><p>再继续加载。</p>
<h2>Resolver 的本质</h2>
<p>观察：</p>
<pre><code class="language-text">Webpack Alias
Vite Alias
Rspack Alias
TS Paths
Node Loader
Import Map</code></pre><p>会发现它们都在做同一件事：</p>
<pre><code class="language-text">specifier
    ↓
mapping
    ↓
new specifier
    ↓
real URL</code></pre><p>区别仅在于：</p>
<pre><code class="language-text">映射规则存放的位置不同</code></pre><h2>模块图的起点</h2>
<p>对于：</p>
<pre><code class="language-ts">import App from "./App"</code></pre><p>编译器首先执行：</p>
<pre><code class="language-text">specifier
    ↓
resolver
    ↓
real module</code></pre><p>得到：</p>
<pre><code class="language-text">App.tsx</code></pre><p>然后继续递归：</p>
<pre><code class="language-text">App.tsx
    ↓
Button.tsx
    ↓
request.ts
    ↓
...</code></pre><p>最终构建：</p>
<pre><code class="language-text">Module Graph</code></pre><p>Tree Shaking、Code Splitting、HMR、Chunk 拆分全部建立在这个图之上。</p>
<p>因此 Resolver 是整个现代前端工具链真正的入口。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/vite-webpack-rspack#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233523</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>Node.js ESM 模块解析算法理解</title>
    <link>https://liuyaowen.cn/posts/default/node-js-esm</link>
    <pubDate>Tue, 09 Jun 2026 11:36:56 GMT</pubDate>
    <description>Node.js ESM 解析算法（Resolution Algorithm）的职责只有两个：

将 </description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/node-js-esm'>https://liuyaowen.cn/posts/default/node-js-esm</a></blockquote>
          <p>Node.js ESM 解析算法（Resolution Algorithm）的职责只有两个：</p>
<ol>
<li>将 <code>import</code> 中的 specifier 解析为最终 URL</li>
<li>判断该 URL 对应的模块格式</li>
</ol>
<p>例如：</p>
<pre><code class="language-js">import react from "react";
import util from "./utils.js";
import config from "#config";</code></pre><p>Node.js 最终需要得到：</p>
<pre><code class="language-text">react      -&gt; file:///project/node_modules/react/index.js
./utils.js -&gt; file:///project/src/utils.js
#config    -&gt; file:///project/src/config/index.js</code></pre><p>以及：</p>
<pre><code class="language-text">module
commonjs
json
wasm</code></pre><p>等模块格式。</p>
<p>整个 ESM 规范本质上是在描述：</p>
<pre><code class="language-text">specifier
    ↓
resolved URL
    ↓
module format
    ↓
load
    ↓
execute</code></pre><h1>一、ESM_RESOLVE 总体流程</h1>
<p>Node.js ESM 的入口算法：</p>
<pre><code class="language-text">ESM_RESOLVE(specifier, parentURL)</code></pre><p>可以简化为：</p>
<pre><code class="language-text">判断 specifier 类型

URL
路径
#imports
裸包名

        ↓

解析得到 URL

        ↓

检查文件是否合法

        ↓

判断模块格式

        ↓

返回给 Loader</code></pre><p>整个算法的核心目标：</p>
<pre><code class="language-text">specifier
      ↓
唯一 URL</code></pre><p>而不是像 CommonJS 那样不断猜测。</p>
<h1>二、specifier 分类</h1>
<p>Node.js 将 specifier 分为四类。</p>
<h2>1. URL Specifier</h2>
<p>例如：</p>
<pre><code class="language-js">import x from "file:///app/src/a.js";</code></pre><p>或者：</p>
<pre><code class="language-js">import x from "data:text/javascript,export default 1";</code></pre><p>如果本身是合法 URL：</p>
<pre><code class="language-text">new URL(specifier)</code></pre><p>成功。</p>
<p>那么直接返回。</p>
<h2>2. Relative Specifier</h2>
<p>例如：</p>
<pre><code class="language-js">import x from "./foo.js";
import y from "../bar.js";</code></pre><p>Node.js 根据当前模块位置解析。</p>
<p>假设：</p>
<pre><code class="language-text">file:///app/src/main.js</code></pre><p>执行：</p>
<pre><code class="language-js">import "./utils.js";</code></pre><p>得到：</p>
<pre><code class="language-text">file:///app/src/utils.js</code></pre><p>本质上就是：</p>
<pre><code class="language-js">new URL("./utils.js", parentURL)</code></pre><h2>3. Package Imports</h2>
<p>例如：</p>
<pre><code class="language-js">import db from "#db";</code></pre><p>或者：</p>
<pre><code class="language-js">import logger from "#utils/logger";</code></pre><p>以：</p>
<pre><code class="language-text">#</code></pre><p>开头。</p>
<p>会进入：</p>
<pre><code class="language-text">PACKAGE_IMPORTS_RESOLVE()</code></pre><p>读取当前包：</p>
<pre><code class="language-json">{
  "imports": {
    "#db": "./src/db/index.js",
    "#utils/*": "./src/utils/*.js"
  }
}</code></pre><p>例如：</p>
<pre><code class="language-js">import db from "#db";</code></pre><p>最终得到：</p>
<pre><code class="language-text">./src/db/index.js</code></pre><h2>4. Bare Specifier</h2>
<p>例如：</p>
<pre><code class="language-js">import react from "react";</code></pre><pre><code class="language-js">import lodash from "lodash";</code></pre><pre><code class="language-js">import axios from "axios";</code></pre><p>既不是：</p>
<pre><code class="language-text">URL
路径
#import</code></pre><p>就属于裸包名。</p>
<p>进入：</p>
<pre><code class="language-text">PACKAGE_RESOLVE()</code></pre><p>开始查找 node_modules。</p>
<h1>三、PACKAGE_RESOLVE</h1>
<p>这是 Node.js 查找 npm 包的核心逻辑。</p>
<p>例如：</p>
<pre><code class="language-js">import react from "react";</code></pre><p>当前文件：</p>
<pre><code class="language-text">/app/src/pages/home/index.js</code></pre><p>Node.js 会不断向上查找：</p>
<pre><code class="language-text">/app/src/pages/home/node_modules/react
/app/src/pages/node_modules/react
/app/src/node_modules/react
/app/node_modules/react
/node_modules/react</code></pre><p>直到找到。</p>
<p>等价于：</p>
<pre><code class="language-text">while(currentDirectory){
    查找 node_modules/packageName
    找不到继续向上
}</code></pre><h1>四、读取 package.json</h1>
<p>找到包以后：</p>
<pre><code class="language-text">node_modules/react/package.json</code></pre><p>Node.js 开始读取配置。</p>
<p>优先级如下：</p>
<pre><code class="language-text">exports
    ↓
main
    ↓
直接路径</code></pre><h1>五、exports 机制</h1>
<p>现代 Node.js 包解析几乎完全依赖 exports。</p>
<p>例如：</p>
<pre><code class="language-json">{
  "exports": {
    ".": "./dist/index.js",
    "./jsx-runtime": "./dist/jsx-runtime.js"
  }
}</code></pre><p>允许：</p>
<pre><code class="language-js">import React from "react";</code></pre><p>对应：</p>
<pre><code class="language-text">.</code></pre><p>得到：</p>
<pre><code class="language-text">./dist/index.js</code></pre><p>允许：</p>
<pre><code class="language-js">import jsx from "react/jsx-runtime";</code></pre><p>对应：</p>
<pre><code class="language-text">./jsx-runtime</code></pre><p>得到：</p>
<pre><code class="language-text">./dist/jsx-runtime.js</code></pre><p>但是：</p>
<pre><code class="language-js">import internal from "react/internal";</code></pre><p>如果 exports 中不存在：</p>
<pre><code class="language-json">"./internal"</code></pre><p>则报错：</p>
<pre><code class="language-text">Package Path Not Exported</code></pre><h1>六、exports 的本质</h1>
<p>exports 可以理解为：</p>
<pre><code class="language-text">包对外暴露的公开 API</code></pre><p>例如：</p>
<pre><code class="language-json">{
  "exports": {
    ".": "./dist/index.js",
    "./api": "./dist/api.js"
  }
}</code></pre><p>允许：</p>
<pre><code class="language-js">import x from "my-lib";
import y from "my-lib/api";</code></pre><p>禁止：</p>
<pre><code class="language-js">import z from "my-lib/dist/internal.js";</code></pre><p>即使文件真实存在。</p>
<h1>七、Conditional Exports</h1>
<p>exports 不一定是字符串。</p>
<p>可以是对象。</p>
<p>例如：</p>
<pre><code class="language-json">{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "default": "./dist/index.js"
    }
  }
}</code></pre><p>Node.js 会根据条件选择。</p>
<p>ESM：</p>
<pre><code class="language-js">import x from "lib";</code></pre><p>匹配：</p>
<pre><code class="language-json">"import"</code></pre><p>得到：</p>
<pre><code class="language-text">./dist/index.mjs</code></pre><p>CommonJS：</p>
<pre><code class="language-js">require("lib");</code></pre><p>匹配：</p>
<pre><code class="language-json">"require"</code></pre><p>得到：</p>
<pre><code class="language-text">./dist/index.cjs</code></pre><h1>八、PACKAGE_SELF_RESOLVE</h1>
<p>假设：</p>
<pre><code class="language-json">{
  "name": "my-lib",
  "exports": {
    ".": "./src/index.js",
    "./core": "./src/core.js"
  }
}</code></pre><p>当前代码就在：</p>
<pre><code class="language-text">my-lib</code></pre><p>内部。</p>
<p>那么：</p>
<pre><code class="language-js">import core from "my-lib/core";</code></pre><p>不会去外部 node_modules 查找。</p>
<p>Node.js 会发现：</p>
<pre><code class="language-text">当前包名就是 my-lib</code></pre><p>直接使用当前 package.json 的 exports。</p>
<p>这就是：</p>
<pre><code class="language-text">PACKAGE_SELF_RESOLVE</code></pre><h1>九、imports 机制</h1>
<p>imports 和 exports 很像。</p>
<p>区别：</p>
<pre><code class="language-text">exports 给别人用
imports 给自己用</code></pre><p>例如：</p>
<pre><code class="language-json">{
  "imports": {
    "#db": "./src/db/index.js",
    "#utils/*": "./src/utils/*.js"
  }
}</code></pre><p>之后：</p>
<pre><code class="language-js">import db from "#db";</code></pre><p>实际解析：</p>
<pre><code class="language-text">./src/db/index.js</code></pre><p>再例如：</p>
<pre><code class="language-js">import stringUtil from "#utils/string.js";</code></pre><p>得到：</p>
<pre><code class="language-text">./src/utils/string.js</code></pre><h1>十、Pattern Match</h1>
<p>imports 和 exports 支持通配符。</p>
<p>例如：</p>
<pre><code class="language-json">{
  "exports": {
    "./features/*": "./src/features/*.js"
  }
}</code></pre><p>执行：</p>
<pre><code class="language-js">import user from "pkg/features/user";</code></pre><p>匹配：</p>
<pre><code class="language-text">*</code></pre><p>得到：</p>
<pre><code class="language-text">user</code></pre><p>最终：</p>
<pre><code class="language-text">./src/features/user.js</code></pre><h1>十一、PACKAGE_TARGET_RESOLVE</h1>
<p>这是 exports/imports 真正执行映射的地方。</p>
<p>支持四种类型。</p>
<h2>String</h2>
<pre><code class="language-json">{
  "exports": {
    ".": "./dist/index.js"
  }
}</code></pre><p>直接解析。</p>
<h2>Object</h2>
<pre><code class="language-json">{
  "exports": {
    ".": {
      "node": "./node.js",
      "browser": "./browser.js",
      "default": "./index.js"
    }
  }
}</code></pre><p>根据 conditions 选择。</p>
<h2>Array</h2>
<pre><code class="language-json">{
  "exports": {
    ".": [
      "./native.js",
      "./fallback.js"
    ]
  }
}</code></pre><p>前一个失败。</p>
<p>继续尝试下一个。</p>
<h2>null</h2>
<pre><code class="language-json">{
  "exports": {
    "./internal/*": null
  }
}</code></pre><p>明确禁止导出。</p>
<h1>十二、ESM_FILE_FORMAT</h1>
<p>URL 定位完成后。</p>
<p>Node.js 需要判断：</p>
<pre><code class="language-text">这个文件应该按什么格式加载？</code></pre><h2>.mjs</h2>
<pre><code class="language-text">module</code></pre><h2>.cjs</h2>
<pre><code class="language-text">commonjs</code></pre><h2>.json</h2>
<pre><code class="language-text">json</code></pre><h2>.wasm</h2>
<pre><code class="language-text">wasm</code></pre><h2>.node</h2>
<pre><code class="language-text">addon</code></pre><p>原生扩展模块。</p>
<h1>十三、type 字段的作用</h1>
<p>对于：</p>
<pre><code class="language-text">.js</code></pre><p>文件。</p>
<p>Node.js 会查找最近的 package.json。</p>
<p>例如：</p>
<pre><code class="language-json">{
  "type": "module"
}</code></pre><p>那么：</p>
<pre><code class="language-js">app.js</code></pre><p>被解释为：</p>
<pre><code class="language-text">ESM</code></pre><p>如果：</p>
<pre><code class="language-json">{
  "type": "commonjs"
}</code></pre><p>则：</p>
<pre><code class="language-js">app.js</code></pre><p>被解释为：</p>
<pre><code class="language-text">CommonJS</code></pre><p>因此：</p>
<pre><code class="language-text">.mjs</code></pre><p>永远 ESM。</p>
<pre><code class="language-text">.cjs</code></pre><p>永远 CommonJS。</p>
<pre><code class="language-text">.js</code></pre><p>取决于 type。</p>
<h1>十四、为什么 ESM 不支持目录导入</h1>
<p>CommonJS：</p>
<pre><code class="language-js">require("./foo");</code></pre><p>Node.js 会尝试：</p>
<pre><code class="language-text">foo.js
foo.json
foo.node
foo/index.js
foo/index.json</code></pre><p>ESM：</p>
<pre><code class="language-js">import "./foo";</code></pre><p>不会猜。</p>
<p>如果：</p>
<pre><code class="language-text">foo</code></pre><p>是目录。</p>
<p>直接报错：</p>
<pre><code class="language-text">Unsupported Directory Import</code></pre><p>正确写法：</p>
<pre><code class="language-js">import "./foo/index.js";</code></pre><p>或者：</p>
<pre><code class="language-js">import "./foo.js";</code></pre><h1>十五、LOOKUP_PACKAGE_SCOPE</h1>
<p>Node.js 如何找到最近的 package.json？</p>
<p>算法：</p>
<pre><code class="language-text">当前目录
      ↓
父目录
      ↓
继续向上
      ↓
直到根目录</code></pre><p>例如：</p>
<pre><code class="language-text">/app/src/pages/home/index.js</code></pre><p>查找：</p>
<pre><code class="language-text">/app/src/pages/home/package.json
/app/src/pages/package.json
/app/src/package.json
/app/package.json</code></pre><p>找到第一个就停止。</p>
<h1>十六、自定义 ESM Resolver</h1>
<p>Node.js 默认解析：</p>
<pre><code class="language-js">import x from "./a.js";
import y from "react";</code></pre><p>如果想支持：</p>
<pre><code class="language-js">import x from "@/utils";</code></pre><p>怎么办？</p>
<p>答案：</p>
<pre><code class="language-text">Loader Hook</code></pre><p>例如：</p>
<pre><code class="language-js">import { pathToFileURL } from "node:url";
import path from "node:path";

export async function resolve(
  specifier,
  context,
  nextResolve
) {
  if (specifier.startsWith("@/")) {
    return {
      url: pathToFileURL(
        path.resolve(
          process.cwd(),
          "src",
          specifier.slice(2)
        )
      ).href,
      shortCircuit: true
    };
  }

  return nextResolve(specifier, context);
}</code></pre><p>运行：</p>
<pre><code class="language-bash">node --loader ./loader.mjs app.js</code></pre><p>之后：</p>
<pre><code class="language-js">import util from "@/utils.js";</code></pre><p>就会自动映射：</p>
<pre><code class="language-text">src/utils.js</code></pre><h1>十七、完整解析链路</h1>
<p>Node.js ESM 解析可以总结为：</p>
<pre><code class="language-text">import specifier
        ↓
ESM_RESOLVE
        ↓
判断类型

URL
路径
#imports
裸包名

        ↓

PACKAGE_RESOLVE

        ↓

exports
imports
main

        ↓

得到最终 URL

        ↓

ESM_FILE_FORMAT

        ↓

module
commonjs
json
wasm

        ↓

Loader

        ↓

Execute</code></pre><h1>总结</h1>
<p>Node.js ESM 的核心思想不是“寻找文件”。</p>
<p>而是：</p>
<pre><code class="language-text">specifier
      ↓
确定 URL
      ↓
确定模块格式
      ↓
加载执行</code></pre><p>整个 exports、imports、type、conditional exports、loader 机制，本质上都是围绕这一目标构建的。</p>
<p>ESM 解析模型相比 CommonJS 更严格、更静态、更接近浏览器，也更适合现代工具链（Vite、Webpack、Rspack、Rollup、Turbopack）进行分析和优化。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/node-js-esm#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233522</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>从 C++ 到 Rust、Go：把主流编译流程真正串起来</title>
    <link>https://liuyaowen.cn/posts/default/c-rust-go</link>
    <pubDate>Mon, 08 Jun 2026 00:49:13 GMT</pubDate>
    <description>最近在看 C++、Rust、Go 的编译过程。

刚开始最容易被一堆名词卡住：

GCC
Clang</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/c-rust-go'>https://liuyaowen.cn/posts/default/c-rust-go</a></blockquote>
          <p>最近在看 C++、Rust、Go 的编译过程。</p>
<p>刚开始最容易被一堆名词卡住：</p>
<ul>
<li>GCC</li>
<li>Clang</li>
<li>LLVM</li>
<li>AST</li>
<li>IR</li>
<li>SSA</li>
<li>MIR</li>
<li>GIMPLE</li>
<li>RTL</li>
<li>Linker</li>
<li>Loader</li>
</ul>
<p>单独看每个词，好像都能理解一点。但把它们放到一张图里，就很容易乱。</p>
<p>后来我发现，问题不在于名词太多，而在于没有从一段真实程序出发。</p>
<p>编译器不是简单地“把源码翻译成汇编”。更准确地说，它是在不断降低程序的抽象层级：</p>
<pre><code class="language-text">人类写的源码
    ↓
结构化表示
    ↓
语义明确的表示
    ↓
适合优化的表示
    ↓
接近机器的表示
    ↓
机器可以执行的二进制</code></pre><p>每一层中间表示，都是为了让某一类问题变得更容易处理。</p>
<p>下面用一段很小的 C++ 程序，把主流编译链路串起来。</p>
<pre><code class="language-cpp">extern "C" int printf(const char*, ...);

#define INC(x) ((x) + 1)

int add(int a, int b) {
    return a + b;
}

int main() {
    int x = INC(add(1, 2));
    printf("%d\n", x);
    return 0;
}</code></pre><p>这段代码故意保留了几个点：</p>
<pre><code class="language-text">宏：INC
函数调用：add
外部符号：printf
C++ 符号：add 会发生 name mangling
链接阶段：printf 需要从 C 标准库解析
优化空间：add(1, 2) 和 INC 可以被优化成常量</code></pre><p>一个小程序，基本够覆盖主流编译流程。</p>
<h2>1. 源码不是编译器真正想要的东西</h2>
<p>程序员看到的是：</p>
<pre><code class="language-cpp">int x = INC(add(1, 2));</code></pre><p>人很容易理解它大概等价于：</p>
<pre><code class="language-cpp">int x = ((add(1, 2)) + 1);</code></pre><p>但编译器不能一开始就这么理解。</p>
<p>源码只是字符流：</p>
<pre><code class="language-text">i n t 空格 x 空格 = 空格 I N C ...</code></pre><p>编译器必须先把字符处理成更稳定的结构。</p>
<p>这也是为什么现代编译流程不会从源码直接跳到汇编。源码适合人读，不适合机器分析。</p>
<h2>2. 预处理：C/C++ 最早的一层文本系统</h2>
<p>C/C++ 的第一步通常是预处理。</p>
<pre><code class="language-bash">clang++ -E main.cpp -o main.ii</code></pre><p>预处理器主要处理：</p>
<pre><code class="language-text">#include
#define
#ifdef
#pragma</code></pre><p>在这段程序里，最明显的是：</p>
<pre><code class="language-cpp">#define INC(x) ((x) + 1)</code></pre><p>预处理后，核心代码会变成类似这样：</p>
<pre><code class="language-cpp">int main() {
    int x = ((add(1, 2)) + 1);
    printf("%d\n", x);
    return 0;
}</code></pre><p>这一步有个很重要的认知：</p>
<blockquote>
<p>预处理不是语义分析。</p>
</blockquote>
<p>它不理解类型。</p>
<p>它不知道 <code>add</code> 是不是函数。</p>
<p>它也不知道 <code>INC(add(1, 2))</code> 是否合理。</p>
<p>它只是按照规则展开文本。</p>
<p>这也是 C/C++ 宏容易制造问题的原因。宏不是函数，它不会遵守函数那套类型检查和作用域规则。</p>
<p>例如：</p>
<pre><code class="language-cpp">#define SQUARE(x) x * x</code></pre><p>如果写：</p>
<pre><code class="language-cpp">SQUARE(1 + 2)</code></pre><p>会被展开成：</p>
<pre><code class="language-cpp">1 + 2 * 1 + 2</code></pre><p>而不是：</p>
<pre><code class="language-cpp">(1 + 2) * (1 + 2)</code></pre><p>很多 C/C++ 的奇怪问题，其实还没进入真正编译阶段，只是在预处理阶段就已经埋雷了。</p>
<h2>3. 词法分析：把字符切成 Token</h2>
<p>预处理后，编译器开始做词法分析。</p>
<p>源码片段：</p>
<pre><code class="language-cpp">int x = ((add(1, 2)) + 1);</code></pre><p>会被切成类似：</p>
<pre><code class="language-text">keyword      int
identifier   x
operator     =
punctuator   (
punctuator   (
identifier   add
punctuator   (
literal      1
punctuator   ,
literal      2
punctuator   )
punctuator   )
operator     +
literal      1
punctuator   )
punctuator   ;</code></pre><p>这一步只负责“切词”。</p>
<p>它知道 <code>int</code> 是关键字。</p>
<p>知道 <code>x</code> 是标识符。</p>
<p>知道 <code>1</code> 是数字字面量。</p>
<p>但它还不知道：</p>
<pre><code class="language-text">x 是局部变量
add 是函数
printf 是外部函数
1 能不能传给 add</code></pre><p>这些都不是词法分析负责的。</p>
<p>可以用 Clang 看 Token：</p>
<pre><code class="language-bash">clang++ -Xclang -dump-tokens -fsyntax-only main.cpp</code></pre><p>如果编译错误里出现非法字符、字符串没有闭合、数字字面量格式错误，通常就停在这一层附近。</p>
<h2>4. 语法分析：从 Token 变成 AST</h2>
<p>Token 还是线性的。</p>
<p>编译器需要知道程序结构。</p>
<p>例如：</p>
<pre><code class="language-cpp">int x = ((add(1, 2)) + 1);</code></pre><p>语法分析后会变成一棵树，简化后大概是：</p>
<pre><code class="language-text">VarDecl x : int
    └── BinaryOperator +
        ├── CallExpr add
        │   ├── IntegerLiteral 1
        │   └── IntegerLiteral 2
        └── IntegerLiteral 1</code></pre><p>这就是 AST，抽象语法树。</p>
<p>AST 的作用不是为了显得高级，而是为了把源码结构稳定下来。</p>
<p>源码是文本。</p>
<p>AST 是结构。</p>
<p>这一步之后，编译器终于知道：</p>
<pre><code class="language-text">这是一个变量声明
变量名是 x
初始化表达式是一个加法
加法左边是函数调用
加法右边是整数 1</code></pre><p>但 AST 仍然不能证明程序正确。</p>
<p>例如：</p>
<pre><code class="language-cpp">int x = foo(1, 2);</code></pre><p>即使 <code>foo</code> 根本不存在，也可以先构建 AST。</p>
<p>语法上没问题。</p>
<p>语义上才有问题。</p>
<p>可以用 Clang 看 AST：</p>
<pre><code class="language-bash">clang++ -Xclang -ast-dump -fsyntax-only main.cpp</code></pre><p>真实 C++ AST 会非常长。模板、重载、隐式转换、构造函数、析构函数都会出现在里面。</p>
<p>这也是 C++ 前端复杂度很高的原因。不是因为 Token 难切，也不是因为语法树难画，而是因为语义太复杂。</p>
<h2>5. 语义分析：编译器真正开始理解程序</h2>
<p>语义分析会处理这些问题：</p>
<pre><code class="language-text">add 是否存在
add 参数数量是否正确
add 参数类型是否匹配
printf 是否声明过
int x 的初始化是否合法
函数返回值是否符合声明</code></pre><p>对这段代码来说：</p>
<pre><code class="language-cpp">int x = INC(add(1, 2));</code></pre><p>预处理后是：</p>
<pre><code class="language-cpp">int x = ((add(1, 2)) + 1);</code></pre><p>语义分析会确认：</p>
<pre><code class="language-text">add 的类型是 int(int, int)
add(1, 2) 返回 int
返回值可以和 1 做加法
结果可以赋给 int x</code></pre><p>如果写成：</p>
<pre><code class="language-cpp">int x = add("hello", 2);</code></pre><p>语法仍然成立。</p>
<p>AST 也能构建。</p>
<p>但语义分析会报错，因为参数类型不匹配。</p>
<p>很多开发者平时说“编译错误”，其实可以再细分：</p>
<pre><code class="language-text">语法错误：代码结构不合法
语义错误：结构合法，但意思不合法
链接错误：单个文件能编译，但最终符号找不到</code></pre><p>这三个错误发生在完全不同的阶段。</p>
<p>Rust 的 Borrow Checker 也属于这个大范围。</p>
<p>例如：</p>
<pre><code class="language-rust">let r;

{
    let x = 1;
    r = &x;
}

println!("{}", r);</code></pre><p>这不是语法错误。</p>
<p>它的结构完全合法。</p>
<p>真正的问题是语义层面的：<code>x</code> 已经离开作用域，<code>r</code> 不能再引用它。</p>
<p>所以 Rust 的所有权、借用、生命周期，不应该理解成语法特性，而应该理解成更强的语义分析。</p>
<p>这也是 Rust 编译器比 Go 编译器复杂很多的原因之一。</p>
<h2>6. AST 不适合优化，所以需要 IR</h2>
<p>很多人第一次学编译流程，会以为：</p>
<pre><code class="language-text">源码
  ↓
AST
  ↓
汇编</code></pre><p>这个模型太粗糙。</p>
<p>现代编译器不会长期停留在 AST 上做优化。</p>
<p>原因很简单：</p>
<blockquote>
<p>AST 太接近源码。</p>
</blockquote>
<p>例如：</p>
<pre><code class="language-cpp">int x = INC(add(1, 2));</code></pre><p>AST 会保留很多源码结构。</p>
<p>但优化器真正关心的是：</p>
<pre><code class="language-text">这个值从哪里来
这个值被谁使用
这个计算有没有副作用
这个分支是否可达
这个变量能不能放进寄存器
这个函数能不能内联</code></pre><p>AST 对这些问题并不友好。</p>
<p>所以编译器会把 AST 降低到 IR。</p>
<p>IR 是 Intermediate Representation，中间表示。</p>
<p>比如这段：</p>
<pre><code class="language-cpp">int x = INC(add(1, 2));</code></pre><p>可以理解为降低成类似三地址码：</p>
<pre><code class="language-text">t1 = call add, 1, 2
t2 = t1 + 1
x = t2
call printf, "%d\n", x
return 0</code></pre><p>这就比 AST 更适合优化。</p>
<p>表达式树被拆成了简单指令。</p>
<p>数据依赖也更清楚。</p>
<p>LLVM IR 会更像这样，简化后：</p>
<pre><code class="language-llvm">define i32 @_Z3addii(i32 %a, i32 %b) {
entry:
  %sum = add nsw i32 %a, %b
  ret i32 %sum
}

define i32 @main() {
entry:
  %call = call i32 @_Z3addii(i32 1, i32 2)
  %add = add nsw i32 %call, 1
  call i32 (ptr, ...) @printf(ptr @.str, i32 %add)
  ret i32 0
}</code></pre><p>这里有几个点值得注意。</p>
<p><code>add</code> 在 C++ 里可能会被编译成：</p>
<pre><code class="language-text">_Z3addii</code></pre><p>这是 C++ name mangling。</p>
<p>因为 C++ 支持函数重载，链接器不能只看到一个名字 <code>add</code>。</p>
<p>例如：</p>
<pre><code class="language-cpp">int add(int, int);
double add(double, double);</code></pre><p>这两个函数源码里都叫 <code>add</code>，但链接时必须区分。</p>
<p>而 <code>printf</code> 因为声明成：</p>
<pre><code class="language-cpp">extern "C" int printf(const char*, ...);</code></pre><p>所以不会被 C++ 改名，仍然叫：</p>
<pre><code class="language-text">printf</code></pre><p>这就是 <code>extern &quot;C&quot;</code> 的实际意义之一：控制符号名，方便和 C ABI 对接。</p>
<h2>7. 优化：编译器开始改写程序</h2>
<p>如果不开优化，编译器会比较忠实地保留程序结构。</p>
<p>如果打开优化：</p>
<pre><code class="language-bash">clang++ -O2 -S -emit-llvm main.cpp -o main.ll</code></pre><p>这段代码很可能被优化成类似：</p>
<pre><code class="language-llvm">define i32 @main() {
entry:
  call i32 (ptr, ...) @printf(ptr @.str, i32 4)
  ret i32 0
}</code></pre><p><code>add(1, 2)</code> 没了。</p>
<p><code>INC</code> 展开后的 <code>+ 1</code> 也没了。</p>
<p><code>x</code> 也没了。</p>
<p>最后只剩：</p>
<pre><code class="language-text">printf("%d\n", 4)</code></pre><p>这不是编译器乱改代码。</p>
<p>这是它证明了这些改写不会改变可观察行为。</p>
<p>这里发生了几类典型优化：</p>
<pre><code class="language-text">函数内联：add(1, 2) 被展开
常量折叠：1 + 2 + 1 变成 4
死代码删除：中间变量 x 不再需要</code></pre><p>如果看过 Java JIT、Go SSA、Rust LLVM 优化，会发现这些优化名字经常重复出现。</p>
<p>原因很简单：程序优化的基本问题是相通的。</p>
<h2>8. SSA：现代优化器为什么喜欢“变量只赋值一次”</h2>
<p>SSA 是理解现代编译器优化的关键。</p>
<p>SSA，全称 Static Single Assignment。</p>
<p>意思是每个变量在静态程序里只赋值一次。</p>
<p>看这个例子：</p>
<pre><code class="language-cpp">int f(bool cond) {
    int x = 1;

    if (cond) {
        x = 2;
    }

    return x;
}</code></pre><p>普通代码里，<code>x</code> 被赋值两次。</p>
<p>控制流一复杂，优化器就很难判断某个位置的 <code>x</code> 到底来自哪里。</p>
<p>SSA 会把它变成类似：</p>
<pre><code class="language-text">x1 = 1

if cond goto then else merge

then:
  x2 = 2
  goto merge

merge:
  x3 = phi(x1, x2)
  return x3</code></pre><p>这里的：</p>
<pre><code class="language-text">phi(x1, x2)</code></pre><p>表示：</p>
<pre><code class="language-text">如果从未进入 if 的路径来，x3 = x1
如果从 then 分支来，x3 = x2</code></pre><p>SSA 的价值在于，它把“变量会变化”这件事变成了显式的数据流关系。</p>
<p>这对优化非常重要。</p>
<p>例如：</p>
<pre><code class="language-text">常量传播
死代码删除
公共子表达式消除
循环不变代码外提
值编号
逃逸分析</code></pre><p>都会受益于 SSA。</p>
<p>LLVM IR 是 SSA 形式。</p>
<p>Go 编译器内部使用 SSA。</p>
<p>HotSpot C2 也使用接近 SSA 的 Sea of Nodes IR。</p>
<p>很多数据库优化器虽然不叫 SSA，但也会维护类似的数据依赖关系。</p>
<p>这不是某个编译器的小技巧，而是现代优化系统的基础方法。</p>
<h2>9. 后端：从平台无关 IR 到机器相关 IR</h2>
<p>LLVM IR 仍然是平台无关的。</p>
<p>比如：</p>
<pre><code class="language-llvm">%sum = add i32 %a, %b</code></pre><p>这句话没有指定：</p>
<pre><code class="language-text">x86 用哪条指令
ARM 用哪条指令
结果放哪个寄存器
参数从哪里来
调用约定是什么</code></pre><p>这些都是后端的问题。</p>
<p>后端要处理：</p>
<pre><code class="language-text">指令选择
寄存器分配
指令调度
调用约定
栈帧布局
目标平台 ABI</code></pre><p>同一个加法，在 x86-64 下可能是：</p>
<pre><code class="language-asm">mov eax, edi
add eax, esi
ret</code></pre><p>在 ARM64 下可能是：</p>
<pre><code class="language-asm">add w0, w0, w1
ret</code></pre><p>IR 相同，目标机器不同，最终指令就不同。</p>
<p>这也是 LLVM 的价值所在。</p>
<p>语言实现者只要生成 LLVM IR，就可以复用 LLVM 的后端能力。</p>
<p>否则每写一门语言，都要自己支持：</p>
<pre><code class="language-text">x86-64
ARM64
RISC-V
Windows ABI
Linux ABI
macOS ABI
寄存器分配
指令选择
调试信息
异常处理</code></pre><p>这几乎不现实</p>
<h2>10. 目标文件：机器码还不是最终程序</h2>
<p>后端生成汇编后，汇编器会生成目标文件：</p>
<pre><code class="language-bash">clang++ -c main.cpp -o main.o</code></pre><p><code>main.o</code> 里面有机器码，但它还不是完整程序。</p>
<p>可以用：</p>
<pre><code class="language-bash">nm main.o</code></pre><p>看到符号。</p>
<p>可能会看到类似：</p>
<pre><code class="language-text">0000000000000000 T _Z3addii
0000000000000010 T main
                 U printf</code></pre><p>含义大概是：</p>
<pre><code class="language-text">_Z3addii 已定义
main 已定义
printf 未定义</code></pre><p><code>U printf</code> 不是错误。</p>
<p>它只是说：当前目标文件里没有 <code>printf</code> 的实现，链接阶段需要去别的地方找。</p>
<p>这就是很多人第一次遇到 <code>undefined reference</code> 时容易误解的地方。</p>
<p>它不是语法错误。</p>
<p>也不是类型错误。</p>
<p>它是链接阶段的符号解析失败。</p>
<h2>11. 链接：把分散的二进制拼成一个程序</h2>
<p>链接器做的事情可以粗略理解为：</p>
<pre><code class="language-text">main.o
  +
libc
  +
启动代码
  +
运行时库
  ↓
可执行文件</code></pre><p>它要解决：</p>
<pre><code class="language-text">符号解析
地址分配
重定位
静态库选择
动态库记录
入口点设置</code></pre><p>如果写 C++，还会涉及：</p>
<pre><code class="language-text">name mangling
ODR
模板实例化
静态初始化
异常表
RTTI
虚表</code></pre><p>比如：</p>
<pre><code class="language-text">undefined reference to `foo'</code></pre><p>说明链接器找不到 <code>foo</code> 的实现。</p>
<pre><code class="language-text">multiple definition of `foo'</code></pre><p>说明多个目标文件都提供了同一个强符号。</p>
<pre><code class="language-text">undefined reference to `_Z3addii'</code></pre><p>说明 C++ 符号名对不上，可能是声明和定义不一致，也可能是 C/C++ ABI 混用出了问题。</p>
<p>链接器是独立于编译器前端的另一个大系统。</p>
<p>很多大型 C++ 工程的构建问题，其实不是编译器问题，而是链接器问题。</p>
<h2>12. 加载：程序运行前，操作系统还要接手</h2>
<p>执行：</p>
<pre><code class="language-bash">./a.out</code></pre><p>也不是 CPU 直接从 <code>main</code> 开始跑。</p>
<p>Linux 下大致会经历：</p>
<pre><code class="language-text">内核读取 ELF
映射程序段到虚拟内存
加载动态链接器
加载共享库
处理重定位
初始化运行时
调用 main</code></pre><p>程序的内存布局大致包括：</p>
<pre><code class="language-text">.text     代码段
.rodata   只读数据
.data     已初始化全局变量
.bss      未初始化全局变量
heap      堆
stack     栈</code></pre><p>所以严格说，完整链路不是：</p>
<pre><code class="language-text">源码 → 可执行文件</code></pre><p>而是：</p>
<pre><code class="language-text">源码 → 目标文件 → 可执行文件 → 进程</code></pre><p>编译器解决“怎么生成程序”。</p>
<p>链接器解决“怎么合成程序”。</p>
<p>加载器解决“怎么把程序变成进程”。</p>
<h2>13. Clang、LLVM、GCC 应该怎么放在一张图里</h2>
<p>现在再看这些名字，就清楚很多。</p>
<p>Clang + LLVM 是这样：</p>
<pre><code class="language-text">C++ Source
    ↓
Clang Frontend
    ↓
Clang AST
    ↓
LLVM IR
    ↓
LLVM Optimizer
    ↓
LLVM Backend
    ↓
Object File
    ↓
Linker
    ↓
Executable</code></pre><p>GCC 是另一条链路：</p>
<pre><code class="language-text">C++ Source
    ↓
GCC C++ Frontend
    ↓
GENERIC
    ↓
GIMPLE
    ↓
GIMPLE SSA
    ↓
RTL
    ↓
GCC Backend
    ↓
Object File
    ↓
Linker
    ↓
Executable</code></pre><p>这两条链路解决的是同一类问题，但内部表示不同。</p>
<p>Clang 不是 LLVM 的别名。</p>
<p>LLVM 也不是 Clang 的别名。</p>
<p>更准确地说：</p>
<pre><code class="language-text">Clang 是 C/C++/Objective-C 前端。
LLVM 是中端和后端基础设施。
GCC 是另一套完整编译器系统。</code></pre><p>对应关系可以这样记：</p>
<pre><code class="language-text">Clang 负责读懂 C++。
LLVM 负责优化和生成机器码。
GCC 自己既有前端，也有中端和后端。</code></pre><h2>14. Rust 为什么有 HIR、MIR，又为什么用 LLVM</h2>
<p>Rust 的流程大致是：</p>
<pre><code class="language-text">Rust Source
    ↓
AST
    ↓
HIR
    ↓
THIR
    ↓
MIR
    ↓
Borrow Check
    ↓
LLVM IR
    ↓
LLVM Backend
    ↓
Machine Code</code></pre><p>Rust 这几层不是为了显得复杂。</p>
<p>每层都有明确责任。</p>
<p>AST 接近源码。</p>
<p>HIR 会把一些语法糖和表层结构降下来，让程序形态更稳定。</p>
<p>THIR 更适合类型检查后的表达式分析。</p>
<p>MIR 是 Rust 很关键的一层，适合表达控制流、move、borrow、drop、生命周期等语义。</p>
<p>LLVM IR 则负责进入通用优化和机器码生成阶段。</p>
<p>这里要注意一个区分：</p>
<pre><code class="language-text">MIR 是 Rust 语义层的 IR。
LLVM IR 是机器代码生成层的 IR。</code></pre><p>Borrow Checker 不能直接依赖 LLVM IR。</p>
<p>因为 LLVM IR 已经太低级，Rust 的 ownership、borrow、drop 语义在那一层已经不适合作为主要分析对象。</p>
<p>也不能直接依赖 AST。</p>
<p>因为 AST 太接近语法表面，语法糖太多，不适合做严格的数据流和控制流分析。</p>
<p>所以 Rust 需要 MIR。</p>
<p>这就是中间表示真正的价值：不是多一层抽象，而是为特定分析提供合适的程序形态。</p>
<h2>15. Go 为什么看起来简单很多</h2>
<p>Go 的编译流程大致是：</p>
<pre><code class="language-text">Go Source
    ↓
Parser
    ↓
AST
    ↓
Type Check
    ↓
SSA
    ↓
Go Backend
    ↓
Machine Code</code></pre><p>Go 默认不走 LLVM。</p>
<p>它有自己的 SSA 和后端。</p>
<p>这和 Go 的设计目标有关。</p>
<p>Go 语言本身刻意保持简单：</p>
<pre><code class="language-text">没有模板元编程
没有复杂宏系统
没有 Rust 那种 ownership 检查
没有 C++ 那种重载和隐式规则</code></pre><p>所以 Go 编译器可以更直接。</p>
<p>这也是 Go 编译速度快的一个重要原因。</p>
<p>当然，“简单”不是说 Go 编译器没有技术含量。</p>
<p>Go 的逃逸分析、内联、SSA 优化、栈增长、GC 相关元数据生成，都有不少工程细节。</p>
<p>但和 C++、Rust 相比，Go 前端语义复杂度确实低很多。</p>
<h2>16. 为什么现代编译器总在发明新的 IR</h2>
<p>到这里，基本可以回答最初的问题。</p>
<p>为什么 GCC 有 GIMPLE 和 RTL？</p>
<p>为什么 LLVM 有 LLVM IR、SelectionDAG、Machine IR？</p>
<p>为什么 Rust 有 HIR、THIR、MIR？</p>
<p>为什么 Go 有 SSA？</p>
<p>因为没有一种表示适合所有阶段。</p>
<p>AST 适合表示源码结构。</p>
<p>HIR 适合消除表层语法。</p>
<p>MIR 适合表达语言语义和控制流。</p>
<p>LLVM IR 适合做平台无关优化。</p>
<p>Machine IR 适合做寄存器分配和指令调度。</p>
<p>RTL 适合 GCC 后端描述接近机器的操作。</p>
<p>同一段程序，在不同阶段要被看成不同的东西。</p>
<p>对人来说，它是业务逻辑。</p>
<p>对前端来说，它是语法树。</p>
<p>对类型系统来说，它是约束集合。</p>
<p>对优化器来说，它是数据流图。</p>
<p>对后端来说，它是指令选择和寄存器分配问题。</p>
<p>对链接器来说，它是符号和重定位记录。</p>
<p>对加载器来说，它是 ELF 段和动态库依赖。</p>
<p>编译器的复杂性，正是来自这些视角之间的切换。</p>
<h2>17. 真正需要记住的主流流程</h2>
<p>如果只保留主流路径，不陷入全部细节，可以记成这条线：</p>
<pre><code class="language-text">源码
  ↓
预处理
  ↓
Token
  ↓
AST
  ↓
语义分析
  ↓
IR
  ↓
SSA
  ↓
优化
  ↓
目标相关 IR
  ↓
汇编 / 目标文件
  ↓
链接
  ↓
可执行文件
  ↓
加载执行</code></pre><p>对应到几个主流编译器：</p>
<pre><code class="language-text">Clang/LLVM:
C++ → Clang AST → LLVM IR → LLVM Backend → Object

GCC:
C++ → GENERIC → GIMPLE SSA → RTL → Object

Rust:
Rust → AST/HIR/THIR → MIR → LLVM IR → Object

Go:
Go → AST → Type Check → SSA → Go Backend → Object</code></pre><p>这张图比单独背 AST、IR、SSA 更有用。</p>
<p>因为它告诉你每个系统在同一条工业流水线里的位置。</p>
<h2>18. 学这套东西对工程有什么用</h2>
<p>这不是纯理论。</p>
<p>遇到宏问题，你知道去看预处理结果：</p>
<pre><code class="language-bash">clang++ -E main.cpp</code></pre><p>遇到语法和语义问题，你知道它发生在前端：</p>
<pre><code class="language-bash">clang++ -Xclang -ast-dump -fsyntax-only main.cpp</code></pre><p>想看优化前后的差异，你知道去看 LLVM IR：</p>
<pre><code class="language-bash">clang++ -O0 -S -emit-llvm main.cpp -o main_O0.ll
clang++ -O2 -S -emit-llvm main.cpp -o main_O2.ll</code></pre><p>遇到符号找不到，你知道看目标文件：</p>
<pre><code class="language-bash">nm main.o</code></pre><p>想看 ELF 结构：</p>
<pre><code class="language-bash">readelf -a a.out</code></pre><p>想看汇编：</p>
<pre><code class="language-bash">objdump -d a.out</code></pre><p>这些工具不是为了炫技。</p>
<p>它们对应的是编译流程中的不同阶段。</p>
<p>能定位阶段，问题就已经解决了一半。</p>
<h2>19. 编译器真正有价值的地方</h2>
<p>编译器最有价值的地方，不是几个术语，而是一种看复杂系统的方法。</p>
<p>一个复杂输入，不会被直接执行。</p>
<p>它会先变成某种中间表示。</p>
<p>然后被分析、约束、优化、降低，最后才进入执行层。</p>
<p>这个模式不只存在于编译器里。</p>
<p>数据库会把 SQL 变成逻辑计划和物理计划。</p>
<p>JVM 会把字节码变成内部 IR，再交给 JIT 优化。</p>
<p>React 会把 UI 更新组织成 Fiber 结构。</p>
<p>Kubernetes Scheduler 会把调度请求变成资源约束和评分模型。</p>
<p>这些系统看起来不一样，但底层思路很接近：</p>
<pre><code class="language-text">输入不是直接执行的。
先建立表示。
再基于表示做分析和优化。
最后执行。</code></pre><p>编译器只是这个思想最经典、最完整的版本。</p>
<p>如果能把 C++、Rust、Go、GCC、LLVM 这条线看懂，再回头看 JVM、数据库、前端框架、调度系统，很多设计就不再是孤立的名词了。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/c-rust-go#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233521</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>Spring 6 循环依赖与早期代理：三级缓存的真实边界</title>
    <link>https://liuyaowen.cn/posts/default/spring</link>
    <pubDate>Fri, 05 Jun 2026 11:47:08 GMT</pubDate>
    <description>Spring 6 循环依赖与早期代理：三级缓存的真实边界

基线：Spring Framework </description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/spring'>https://liuyaowen.cn/posts/default/spring</a></blockquote>
          <h1>Spring 6 循环依赖与早期代理：三级缓存的真实边界</h1>
<blockquote>
<p>基线：Spring Framework 6、Spring Boot 3。本文解释一个具体代理不一致案例，不把结论推广为所有 Bean 的普遍行为。</p>
</blockquote>
<h2>1. 问题模型</h2>
<p>典型循环依赖：</p>
<pre><code class="language-text">A -&gt; B -&gt; Repository -&gt; A</code></pre><p>如果依赖注入发生在 Bean 完整初始化之前，Spring 可能通过三级缓存提前暴露引用。后续 Bean 初始化还可能经过 BeanPostProcessor 创建代理，于是出现：</p>
<pre><code class="language-text">Early Reference != Final Bean</code></pre><h2>2. 三级缓存能做什么</h2>
<p>三级缓存中的 ObjectFactory 可以提供早期引用。实现 SmartInstantiationAwareBeanPostProcessor 的后处理器有机会通过 getEarlyBeanReference 参与早期代理。</p>
<p>AbstractAutoProxyCreator 会记录 early proxy reference，避免同一个自动代理在初始化后重复创建。但不是每一个 BeanPostProcessor 都参与 early reference。</p>
<p>普通后处理器若只在 postProcessAfterInitialization 阶段包装 Bean，就可能造成早期引用与最终对象身份不一致。具体是否发生，取决于 Spring 版本、后处理器顺序和代理类型。</p>
<h2>3. Spring Boot 3 的处理建议</h2>
<p>Spring Boot 2.6 起循环引用默认更严格。Boot 3 项目应优先拆除循环依赖，不要用 spring.main.allow-circular-references=true 作为默认修复。</p>
<p>推荐顺序：</p>
<ol>
<li>拆分服务和 Repository 的职责；</li>
<li>使用事件或接口反转依赖方向；</li>
<li>对确实需要延迟创建的依赖使用 @Lazy，并评估生命周期；</li>
<li>检查事务、缓存、异步、异常翻译和自定义后处理器是否创建代理。</li>
</ol>
<h2>4. 排障方法</h2>
<p>记录实际 Spring Framework 版本；查看 Bean 的代理类型和 Advisor；分别跟踪 getEarlyBeanReference 与 postProcessAfterInitialization；确认是否存在多个后处理器重复包装；检查 allowCircularReferences 和 BeanDefinition 顺序。</p>
<p>三级缓存不是“所有循环依赖都能解决”的保证。它只是容器在特定生命周期阶段提供早期引用的机制，最终仍需要一个清晰且可维护的依赖图。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/spring#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233520</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title># 从 Linux 网络到 Kubernetes 网络：彻底理解 Pod 是如何通信的</title>
    <link>https://liuyaowen.cn/posts/default/linux-kubernetes-pod</link>
    <pubDate>Tue, 02 Jun 2026 14:01:18 GMT</pubDate>
    <description>很多人学习 Kubernetes 网络时，一上来就接触：

Pod
Service
Ingress
</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/linux-kubernetes-pod'>https://liuyaowen.cn/posts/default/linux-kubernetes-pod</a></blockquote>
          <p>很多人学习 Kubernetes 网络时，一上来就接触：</p>
<ul>
<li>Pod</li>
<li>Service</li>
<li>Ingress</li>
<li>CNI</li>
<li>Flannel</li>
<li>Calico</li>
</ul>
<p>结果越学越乱。</p>
<p>真正的问题在于：</p>
<blockquote>
<p>Kubernetes 网络并不是一个独立体系，而是建立在 Linux 网络之上的一层抽象。</p>
</blockquote>
<p>如果不了解 Linux 网络，那么 Pod、Service、VXLAN 这些概念都会变成黑盒。</p>
<h2>一、网络世界只有三个核心概念</h2>
<p>整个网络可以抽象成三件事：</p>
<pre><code class="language-text">设备
地址
转发</code></pre><p>对应：</p>
<pre><code class="language-text">设备：
网卡
交换机
路由器

地址：
MAC
IP

转发：
交换
路由
NAT</code></pre><h2>二、MAC 与 IP</h2>
<h3>MAC 地址</h3>
<p>MAC 是二层地址。</p>
<pre><code class="language-text">00:11:22:33:44:55</code></pre><p>作用是在同一个局域网内定位设备。</p>
<h3>IP 地址</h3>
<p>IP 是三层地址。</p>
<pre><code class="language-text">192.168.1.10
10.244.1.2
8.8.8.8</code></pre><p>作用是跨网络定位设备。</p>
<h2>三、交换机、ARP、路由器</h2>
<p>交换机工作在二层，只看 MAC 地址，不看 IP、TCP、HTTP。</p>
<p>ARP 负责：</p>
<pre><code class="language-text">IP -&gt; MAC</code></pre><p>路由器工作在三层：</p>
<pre><code class="language-text">查看目标 IP
↓
查路由表
↓
决定下一跳</code></pre><p>路由表示例：</p>
<pre><code class="language-bash">ip route</code></pre><pre><code class="language-text">192.168.1.0/24 dev eth0
default via 192.168.1.1</code></pre><p>含义：</p>
<pre><code class="language-text">同网段直接发送
其他流量交给默认网关</code></pre><h2>四、NAT</h2>
<p>私网地址不能直接在公网路由：</p>
<pre><code class="language-text">192.168.x.x
172.16.x.x
10.x.x.x</code></pre><p>所以路由器会做 SNAT：</p>
<pre><code class="language-text">SRC 192.168.1.10
↓
SRC 公网 IP</code></pre><p>并通过 <code>conntrack</code> 记录连接映射，用于回包恢复。</p>
<h2>五、Linux Network Namespace</h2>
<p>Namespace 是 Linux 的隔离机制。</p>
<p>Network Namespace 隔离的是一整套网络栈：</p>
<pre><code class="language-text">网卡
IP
路由表
ARP 表
iptables
端口空间</code></pre><p>所以容器不是虚拟机，而是：</p>
<pre><code class="language-text">普通 Linux 进程
+
Namespace 隔离
+
Cgroups 限制
+
RootFS 文件系统</code></pre><h2>六、veth Pair</h2>
<p>Namespace 之间需要连接，Linux 提供了 <code>veth pair</code>。</p>
<pre><code class="language-text">vethA &lt;====&gt; vethB</code></pre><p>它像一根虚拟网线：</p>
<pre><code class="language-text">从 vethA 发出的包，会从 vethB 出来
从 vethB 发出的包，会从 vethA 出来</code></pre><h2>七、Linux Bridge</h2>
<p>Bridge 是软件交换机。</p>
<pre><code class="language-text">ContainerA
   |
 Bridge
   |
ContainerB</code></pre><p>它和物理交换机一样：</p>
<pre><code class="language-text">学习 MAC
查 MAC 表
转发数据帧</code></pre><h2>八、Docker 网络</h2>
<p>Docker 默认网络结构：</p>
<pre><code class="language-text">Container
  eth0
    │
veth
    │
docker0
    │
Host</code></pre><p>其中 <code>docker0</code> 是 Linux Bridge，同时被 Docker 配置了 IP：</p>
<pre><code class="language-text">docker0 = 172.17.0.1</code></pre><p>所以 <code>docker0</code> 既是交换机，也是容器默认网关。</p>
<p>Docker 自己维护 IPAM：</p>
<pre><code class="language-text">172.17.0.0/16</code></pre><p>容器 IP 通常由 Docker 分配：</p>
<pre><code class="language-text">172.17.0.2
172.17.0.3
172.17.0.4</code></pre><h2>九、Pod 是什么</h2>
<p>Pod 不是容器。</p>
<p>一个 Pod 里可以有多个容器：</p>
<pre><code class="language-text">Pod
├── app
└── sidecar</code></pre><p>真正持有网络命名空间的是 <code>pause container</code>。</p>
<pre><code class="language-text">pause container
└── Network Namespace

app
└── 加入 pause 的 Network Namespace

sidecar
└── 加入 pause 的 Network Namespace</code></pre><p>所以同一个 Pod 内多个容器共享：</p>
<pre><code class="language-text">IP
localhost
端口空间</code></pre><h2>十、Kubernetes 单节点网络</h2>
<p>很多 CNI 会在 Node 上创建类似 <code>docker0</code> 的网桥，比如：</p>
<pre><code class="language-text">cni0</code></pre><p>结构：</p>
<pre><code class="language-text">PodA
  |
veth
  |
cni0
  |
veth
  |
PodB</code></pre><p>同节点 Pod 通信时：</p>
<pre><code class="language-text">PodA ARP 查询 PodB MAC
↓
cni0 广播
↓
PodB 回复
↓
cni0 根据 MAC 表转发</code></pre><p>这个过程本质是二层交换。</p>
<h2>十一、CNI 是什么</h2>
<p>Kubernetes 本身不直接实现网络。</p>
<p>它通过 CNI 插件处理 Pod 网络。</p>
<p>常见 CNI：</p>
<pre><code class="language-text">Flannel
Calico
Cilium</code></pre><p>CNI 负责：</p>
<pre><code class="language-text">创建 veth
配置 IP
配置路由
接入 Bridge 或其他数据平面
维护跨节点通信</code></pre><p>Pod IP 通常由 CNI 的 IPAM 模块分配，不是 Kubernetes 或 Linux 自动分配。</p>
<h2>十二、跨节点 Pod 通信</h2>
<p>假设：</p>
<pre><code class="language-text">Node1
PodA = 10.244.1.2

Node2
PodB = 10.244.2.2</code></pre><p>PodA 访问 PodB：</p>
<pre><code class="language-text">10.244.1.2 -&gt; 10.244.2.2</code></pre><p>问题是：</p>
<pre><code class="language-text">Node1 怎么知道 10.244.2.0/24 在 Node2？</code></pre><p>这就是 CNI 要解决的问题。</p>
<h2>十三、VXLAN</h2>
<p>VXLAN 是：</p>
<pre><code class="language-text">L2 Overlay over L3</code></pre><p>意思是：</p>
<pre><code class="language-text">把二层网络封装到三层 IP 网络里面</code></pre><p>原始 Pod 包：</p>
<pre><code class="language-text">SRC 10.244.1.2
DST 10.244.2.2</code></pre><p>经过 VXLAN 封装：</p>
<pre><code class="language-text">Outer IP:
SRC Node1IP
DST Node2IP

UDP

VXLAN Header

Inner IP:
SRC 10.244.1.2
DST 10.244.2.2</code></pre><p>包结构：</p>
<pre><code class="language-text">Outer Ethernet
Outer IP
UDP
VXLAN
Inner Ethernet
Inner IP
TCP
HTTP</code></pre><p>中间物理网络只看到：</p>
<pre><code class="language-text">Node1IP -&gt; Node2IP</code></pre><p>Node2 收到后解 VXLAN，再把原始 Pod 包送给 PodB。</p>
<h2>十四、flannel.1 是什么</h2>
<p><code>flannel.1</code> 不是进程。</p>
<p>它是 Linux VXLAN 网络设备。</p>
<p>类似：</p>
<pre><code class="language-text">eth0
docker0
cni0
vethxxx</code></pre><p>路由表里：</p>
<pre><code class="language-text">10.244.2.0/24 dev flannel.1</code></pre><p>意思是：</p>
<pre><code class="language-text">去 10.244.2.0/24 的流量
交给 flannel.1 这个 VXLAN 设备处理</code></pre><p>然后 Linux 内核负责 VXLAN 封装。</p>
<p><code>flanneld</code> 才是进程，它负责：</p>
<pre><code class="language-text">创建 flannel.1
写路由表
维护 Node 和 PodCIDR 的映射</code></pre><h2>十五、Service 的本质</h2>
<p>Service 提供稳定访问入口。</p>
<p>例如：</p>
<pre><code class="language-text">Service IP = 10.96.0.10</code></pre><p>但这个 IP 通常不是某张真实网卡上的 IP。</p>
<p>Service 本质是：</p>
<pre><code class="language-text">虚拟 IP
+
iptables / IPVS 转发规则</code></pre><h2>十六、kube-proxy</h2>
<p><code>kube-proxy</code> 监听：</p>
<pre><code class="language-text">Service
Endpoint / EndpointSlice</code></pre><p>然后生成转发规则。</p>
<p>例如：</p>
<pre><code class="language-text">10.96.0.10:8080
↓ DNAT
10.244.1.2:8080</code></pre><p>所以访问 Service 时，真实过程是：</p>
<pre><code class="language-text">访问 Service IP
↓
iptables / IPVS 命中规则
↓
DNAT 到某个后端 Pod IP
↓
通过 CNI 网络转发到目标 Pod</code></pre><h2>十七、CoreDNS</h2>
<p>Pod 内访问服务名：</p>
<pre><code class="language-bash">curl user-service</code></pre><p>解析过程：</p>
<pre><code class="language-text">user-service
↓
CoreDNS
↓
Service ClusterIP
↓
kube-proxy
↓
Pod IP</code></pre><h2>十八、Ingress</h2>
<p>Ingress 是规则，不是真正的网关进程。</p>
<p>真正处理流量的是 Ingress Controller，比如：</p>
<pre><code class="language-text">Nginx Ingress Controller
Traefik
APISIX
Kong</code></pre><p>外部请求链路：</p>
<pre><code class="language-text">Browser
↓
LoadBalancer / NodePort
↓
Ingress Controller
↓
Service
↓
Pod</code></pre><p>Ingress Controller 通常是七层反向代理，会解析：</p>
<pre><code class="language-text">Host
Path
TLS
HTTP Header</code></pre><h2>十九、完整链路总结</h2>
<p>Linux 网络基础：</p>
<pre><code class="language-text">MAC
↓
ARP
↓
交换机
↓
路由器
↓
路由表
↓
NAT</code></pre><p>容器网络基础：</p>
<pre><code class="language-text">Namespace
↓
veth
↓
Bridge
↓
IPAM
↓
Docker 网络</code></pre><p>Kubernetes 网络：</p>
<pre><code class="language-text">Pod
↓
CNI
↓
PodCIDR
↓
cni0 / route / eBPF
↓
VXLAN / BGP
↓
Service
↓
CoreDNS
↓
Ingress</code></pre><h2>二十、最终结论</h2>
<p>Kubernetes 网络不是凭空出现的新体系。</p>
<p>它本质是：</p>
<pre><code class="language-text">Linux 网络能力
+
CNI 自动化
+
Kubernetes 声明式编排</code></pre><p>更具体地说：</p>
<pre><code class="language-text">Kubernetes 网络
=
Namespace
+
veth
+
Bridge
+
Route
+
NAT
+
iptables / IPVS
+
VXLAN / BGP / eBPF
+
CNI</code></pre><p>理解 Kubernetes 网络，正确路径不是先背 Pod、Service、Ingress，而是先理解：</p>
<pre><code class="language-text">Linux 如何转发一个包
容器如何拥有自己的网络栈
Pod 如何通过 CNI 接入网络
跨节点 Pod 如何通过 VXLAN 或路由互通
Service 如何通过 DNAT 找到后端 Pod
Ingress 如何作为七层入口转发流量</code></pre>
          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/linux-kubernetes-pod#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233519</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>一键配置高效终端：zsh + Oh My Zsh + powerlevel10k（零配置上手）</title>
    <link>https://liuyaowen.cn/posts/default/one-click-setup-zsh-oh-my-zsh-powerlevel10k</link>
    <pubDate>Fri, 03 Apr 2026 05:42:44 GMT</pubDate>
    <description>一、完整脚本，复制到bash终端运行

#!/usr/bin/env bash
set -e

ec</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/one-click-setup-zsh-oh-my-zsh-powerlevel10k'>https://liuyaowen.cn/posts/default/one-click-setup-zsh-oh-my-zsh-powerlevel10k</a></blockquote>
          <h1>一、完整脚本，复制到bash终端运行</h1>
<pre><code class="language-bash id=">#!/usr/bin/env bash
set -e

echo "==&gt; Setup zsh + Oh My Zsh + powerlevel10k"

# 1. zsh
if ! command -v zsh &gt;/dev/null 2&gt;&1; then
  echo "Install zsh..."
  brew install zsh
fi

# 2. oh-my-zsh
if [ ! -d "$HOME/.oh-my-zsh" ]; then
  echo "Install oh-my-zsh..."
  sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended
fi

ZSH_CUSTOM=${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}

# 3. plugins
install_plugin () {
  if [ ! -d "$ZSH_CUSTOM/plugins/$1" ]; then
    git clone --depth=1 "$2" "$ZSH_CUSTOM/plugins/$1"
  fi
}

install_plugin zsh-autosuggestions https://github.com/zsh-users/zsh-autosuggestions
install_plugin zsh-syntax-highlighting https://github.com/zsh-users/zsh-syntax-highlighting

# 4. theme
if [ ! -d "$ZSH_CUSTOM/themes/powerlevel10k" ]; then
  git clone --depth=1 https://github.com/romkatv/powerlevel10k \
    "$ZSH_CUSTOM/themes/powerlevel10k"
fi

# 5. tools
if command -v brew &gt;/dev/null 2&gt;&1; then
  brew install fzf zoxide &gt;/dev/null 2&gt;&1 || true
  $(brew --prefix)/opt/fzf/install --all &gt;/dev/null 2&gt;&1 || true
fi

# 6. config
cat &gt; ~/.zshrc &lt;&lt;'EOF'
export ZSH="$HOME/.oh-my-zsh"

ZSH_THEME="powerlevel10k/powerlevel10k"

plugins=(
  git
  zsh-autosuggestions
  zsh-syntax-highlighting
)

source $ZSH/oh-my-zsh.sh

command -v zoxide &gt;/dev/null 2&gt;&1 && eval "$(zoxide init zsh)"
[ -f ~/.fzf.zsh ] && source ~/.fzf.zsh
EOF

# 7. default shell
if [ "$SHELL" != "$(which zsh)" ]; then
  chsh -s "$(which zsh)"
fi

echo "Done. Restart terminal or run: source ~/.zshrc"
echo "Run: p10k configure"</code></pre><hr>
<h2>包含</h2>
<ul>
<li>zsh</li>
<li>Oh My Zsh</li>
<li>powerlevel10k</li>
<li>autosuggestions / syntax-highlighting</li>
<li>fzf / zoxide</li>
</ul>
<hr>
<h2>使用</h2>
<pre><code class="language-bash id="># 自动补全
git → 按 →

# 历史搜索
Ctrl + R

# 跳目录
z project

# git
gst / gco / gp</code></pre><hr>
<h2>初始化主题</h2>
<pre><code class="language-bash id=">p10k configure</code></pre>
          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/one-click-setup-zsh-oh-my-zsh-powerlevel10k#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233518</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>不想再用 puppeteer 了，我写了个 Markdown 渲染引擎</title>
    <link>https://liuyaowen.cn/posts/default/markdown-rendering-engine-alternative-puppeteer</link>
    <pubDate>Thu, 02 Apr 2026 08:48:26 GMT</pubDate>
    <description>做过「Markdown → 图片」的，应该都踩过这个坑：

Markdown → HTML → 浏览</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/markdown-rendering-engine-alternative-puppeteer'>https://liuyaowen.cn/posts/default/markdown-rendering-engine-alternative-puppeteer</a></blockquote>
          <p>做过「Markdown → 图片」的，应该都踩过这个坑：</p>
<pre><code class="language-text">Markdown → HTML → 浏览器 → 截图</code></pre><p>然后你就会遇到：</p>
<ul>
<li>chromium 很重（部署麻烦）</li>
<li>分页不稳定（每次结果都可能不一样）</li>
<li>批量渲染性能很差</li>
<li>serverless 环境基本不好搞
我最近有点受不了这套链路，就自己写了一个：
<strong>marknative</strong></li>
</ul>
<hr>
<h2>核心思路（和常规方案完全不一样）</h2>
<p>不走 HTML，不走 DOM，不走浏览器。</p>
<p>而是：</p>
<pre><code class="language-text">Markdown → AST → 自定义文档模型 → layout → 分页 → canvas 绘制</code></pre><p>简单理解：</p>
<blockquote>
<p>把“浏览器排版”这件事，自己实现了一套极简版本（只针对 Markdown）。</p>
</blockquote>
<hr>
<h2>能干什么？</h2>
<h3>1. 直接输出 PNG / SVG</h3>
<pre><code class="language-ts">import { renderMarkdown } from 'marknative'

const pages = await renderMarkdown('# Hello')

await Bun.write('page-1.png', pages[0].data)</code></pre><p>不需要：</p>
<ul>
<li>puppeteer</li>
<li>headless chrome</li>
<li>截图</li>
</ul>
<hr>
<h3>2. 天然支持分页（而且是稳定的）</h3>
<p>默认就是：</p>
<ul>
<li>1080 × 1440</li>
<li>类似卡片比例
而且分页是 deterministic 的：</li>
<li>同一份 Markdown</li>
<li>任意环境</li>
<li>渲染结果一致</li>
</ul>
<hr>
<h3>3. 适合批量生成内容</h3>
<p>比如：</p>
<ul>
<li>小红书卡片</li>
<li>技术文章配图</li>
<li>AI 自动生成内容图
相比 puppeteer：</li>
<li>启动更快</li>
<li>内存更低</li>
<li>更容易横向扩展</li>
</ul>
<hr>
<h2>这个方向值得做</h2>
<p>本质上，这类需求其实是：</p>
<blockquote>
<p>“结构化文本 → 可控排版 → 图像输出”
但浏览器：</p>
</blockquote>
<ul>
<li>太通用（为网页设计）</li>
<li>不可控（CSS + layout 太复杂）</li>
<li>成本太高
而 Markdown 场景：</li>
<li>结构固定</li>
<li>可约束</li>
<li>非常适合做“专用排版引擎”</li>
</ul>
<hr>
<h2>技术点（简单说几个有意思的）</h2>
<ul>
<li>用 <code>micromark</code> + <code>mdast</code> 做解析</li>
<li>自己实现 block / inline layout</li>
<li>用 <code>skia-canvas</code> 直接绘制</li>
<li>layout 和 render 完全解耦
所以理论上可以</li>
<li>换 renderer（SVG / PDF / WebGL）</li>
<li>做自定义主题系统</li>
<li>做可视化编辑器（未来）</li>
</ul>
<hr>
<h2>项目地址</h2>
<p>👉 <a href="https://github.com/liyown/marknative">https://github.com/liyown/marknative</a></p>
<hr>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/markdown-rendering-engine-alternative-puppeteer#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233517</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>Claude 风格的流式 UI，到底是怎么做出来的</title>
    <link>https://liuyaowen.cn/posts/default/20260317</link>
    <pubDate>Tue, 17 Mar 2026 15:55:50 GMT</pubDate>
    <description>claude 最近更新了交互是 UI，可以在聊天框流式输出可交互的页面 claude：



同时看</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/20260317'>https://liuyaowen.cn/posts/default/20260317</a></blockquote>
          <p>claude 最近更新了交互是 UI，可以在聊天框流式输出可交互的页面<a href="https://x.com/claudeai/status/2032124273587077133"> claude</a>：</p>
<p></p>
<p>同时看到一个叫 <code>Generative-UI-MCP</code> 的项目，作者的想法很直接：用 MCP 协议把 Claude 能生成可交互 UI 这套东西复刻出来。</p>
<p>这个项目本身不复杂，但它把一件平时很难说清楚的事拆开了：Claude 这种交互式 UI，真正新在哪里；它为什么不像“AI 帮你写了一段前端代码”那么简单；以及它背后最先要解决的，到底是渲染问题，还是协议问题。</p>
<p>我后来又对着一段真实的 SSE 流式输出日志，把整个过程重新拆了一遍。两边对起来之后，会更清楚：Claude 风格的流式 UI，本质上不是“模型输出 HTML”，而是“模型持续输出一个宿主能够可靠消费的 UI 协议”。</p>
<p>这篇文章想讲的，就是这件事。</p>
<h2>它是一个可持续交互UI 页面</h2>
<p>Claude 这波交互式 UI / Artifacts，真正新的地方不是“AI 生成了一个页面”，那个早就有人做过了。新的地方在于：生成出来的东西还能继续用，还能继续跟模型交互，还能挂工具、更新状态。</p>
<p>这跟“AI 写了段前端代码，你复制出去跑”是完全不同的东西。</p>
<p>以前那种方式，模型是起点，生成完就结束了。现在这种方式，模型是这个 UI 会话里的持续参与者。用户在界面上的操作可以再回到模型，模型返回的新内容可以局部更新界面，来回转。</p>
<p>这个闭环大概长这样：</p>
<pre class="mermaid">
sequenceDiagram

actor U as 用户

participant UI as Widget UI

participant Host as 宿主 / Host

participant Tool as 工具 / API

participant LLM as 模型 / LLM



U-&gt;&gt;UI: 点击、输入、操作

UI-&gt;&gt;Host: emit(action, payload)



alt 前端可直接处理

Host-&gt;&gt;UI: 更新本地状态

else 需要调工具

Host-&gt;&gt;Tool: 调用业务接口

Tool--&gt;&gt;Host: 返回结果

Host-&gt;&gt;UI: 局部更新

else 需要再问模型

Host-&gt;&gt;LLM: 当前状态 + action

LLM--&gt;&gt;Host: 新内容 / 新 widget

Host-&gt;&gt;UI: 增量插入或局部替换

end
</pre><p>这个循环跑得起来，才是真正的“交互式”。带几个按钮的 HTML 不叫交互式，事件能回流才叫。</p>
<h2>一段真实的流式输出，能把这件事看得很清楚</h2>
<p>如果看一段真实的 SSE 流式输出，会发现模型并不是一次性吐出一个完整页面，而是在流里持续输出不同类型的内容块，前端边收边拼，拼完再交给对应工具渲染。</p>
<p>拆开之后，大概是五步。</p>
<h3>第一步：先加载 UI 生成规范</h3>
<p>模型一开始并没有直接生成 widget，而是先调用了一个类似 <code>visualize:read_me</code> 的工具，输入参数非常短：</p>
<pre><code class="language-json">
{

"modules": ["diagram", "interactive"]

}
</code></pre><p>这一步很关键。它说明模型在真正开始“画界面”之前，先去拿一份运行时 UI 规范。也就是说，生成不是裸奔的，模型先要知道这次该遵守什么规则。</p>
<h3>第二步：工具返回一整套设计系统和流式输出约束</h3>
<p>这份返回内容里，有几段特别关键。</p>
<p>先是模块说明：</p>
<pre><code class="language-text">
Call read_me again with the modules parameter to load detailed guidance:

- `diagram` — SVG flowcharts, structural diagrams, illustrative diagrams

- `mockup` — UI mockups, forms, cards, dashboards

- `interactive` — interactive explainers with controls

- `chart` — charts, data analysis, geographic maps (Chart.js, D3 choropleth)

- `art` — illustration and generative art
</code></pre><p>然后是角色定义：</p>
<pre><code class="language-text">
You create rich visual content — SVG diagrams/illustrations and HTML interactive widgets — that renders inline in conversation. The best output feels like a natural extension of the chat.
</code></pre><p>接着是它最关键的几条约束：</p>
<pre><code class="language-text">
### Philosophy

- Seamless: Users shouldn't notice where claude.ai ends and your widget begins.

- Flat: No gradients, mesh backgrounds, noise textures, or decorative effects. Clean flat surfaces.

- Compact: Show the essential inline. Explain the rest in text.

- Text goes in your response, visuals go in the tool.
</code></pre><p>还有专门面向流式渲染的顺序规则：</p>
<pre><code class="language-text">
### Streaming

Output streams token-by-token. Structure code so useful content appears early.

- HTML: &lt;style&gt; (short) → content HTML → &lt;script&gt; last.

- SVG: &lt;defs&gt; (markers) → visual elements immediately.

- Prefer inline style="..." over &lt;style&gt; blocks.

- Gradients, shadows, and blur flash during streaming DOM diffs. Use solid flat fills instead.
</code></pre><p>如果只看表面，这像一份设计规范；但从运行角度看，它其实更像一份“让模型输出稳定 UI 消息”的生成协议。</p>
<p>它在做几件事：</p>
<ul>
<li><p>规定什么该出现在工具里，什么该出现在自然语言里</p>
</li>
<li><p>规定代码要按什么顺序流式吐出来</p>
</li>
<li><p>规定哪些视觉效果会破坏流式体验，所以禁止使用</p>
</li>
<li><p>规定组件必须适配宿主环境，比如 CSS 变量、深色模式、受控脚本能力</p>
</li>
</ul>
<p>也就是说，这不是“给模型一些审美建议”，而是在给模型划一条窄轨道。</p>
<h2>真正的关键，不是 HTML，而是模型输出的结构</h2>
<p>随后模型开始调用另一个工具，比如 <code>visualize:show_widget</code>。这一段流最容易让人误解，因为它看起来像一堆零碎碎片：</p>
<pre><code class="language-text">
event: content_block_delta

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"-2-2L"}}
</code></pre><p>单看这种片段，几乎没有可读性。但它们其实不是乱码，而是工具调用参数的一部分。宿主会把同一个 block 下不断到来的 <code>partial_json</code> 拼起来，最后还原成一个完整 JSON。</p>
<p>像这次，重组之后大概是这样：</p>
<pre><code class="language-json">
{

"title": "ui_icons_outline",

"loading_messages": [

"Sketching icon paths...",

"Adding hover magic...",

"Lining up the grid..."

],

"i_have_seen_read_me": true,

"widget_code": "..."

}
</code></pre><p>这里面每个字段都很有意思。</p>
<p><code>title</code> 是这次 widget 的标识。<code>loading_messages</code> 不是装饰，而是在把“等待”转成可感知的生成过程。<code>i_have_seen_read_me</code> 则像一个状态确认，表明模型是在已经读过规范的前提下生成的。</p>
<p>而真正的界面，都放进了 <code>widget_code</code>。</p>
<p>这一步揭示了流式 UI 的核心事实：模型不是直接输出最终页面，而是在输出一个宿主可以消费的 UI 消息。</p>
<h2>为什么 <code>Generative-UI-MCP</code> 这个项目看起来很小，却很有价值</h2>
<p>我原本以为要复刻 Claude 交互式 UI，需要很多东西：自定义渲染器、状态管理、完整前端运行时、组件库、DSL。</p>
<p>结果 <code>Generative-UI-MCP</code> 的核心极简得有点反直觉：</p>
<ul>
<li><p>一个 <code>load_ui_guidelines</code> 工具，按需给模型加载 UI 生成规范</p>
</li>
<li><p>一个 system prompt 资源，把最基础的输出约束提前注入</p>
</li>
</ul>
<p>没有大而全的组件系统，也没有复杂 DSL。</p>
<p>这个取舍反而很能说明问题：复刻 Claude 交互式 UI，最先要解决的不是“怎么渲染”，而是“怎么让模型输出一个宿主能稳定消费的结构”。渲染反而是后面的事。</p>
<h2>所以这件事首先是协议问题，不是渲染问题</h2>
<p>Claude 的交互式 UI 有一个很强的体验特征：widget 的出现是稳定、可预期的。不会这次变成代码块，下次又变成自然语言夹 HTML。</p>
<p>要做到这件事，不能靠模型“自觉”，只能靠协议。</p>
<p><code>Generative-UI-MCP</code> 暴露出来的，正是这层东西：</p>
<ul>
<li><p>widget 要用专用 fence 包裹</p>
</li>
<li><p>fence 里必须是结构化 JSON</p>
</li>
<li><p><code>widget_code</code> 字段里装 HTML 或 SVG</p>
</li>
<li><p>解释文本必须写在 widget block 外面</p>
</li>
<li><p>多个 widget 要拆成多个 block</p>
</li>
<li><p>输出顺序得适合流式渲染</p>
</li>
</ul>
<p>这些约束堆起来，本质上已经非常接近一个轻量的 UI 消息协议。</p>
<p>宿主做的事情，本质上就是在这些消息之间路由。</p>
<h2>为什么规范一定要按需加载，而不是一次性全塞进 prompt</h2>
<p>复刻项目把 UI 规范拆成了几个模块：<code>interactive</code>、<code>chart</code>、<code>mockup</code>、<code>diagram</code>、<code>art</code>，需要什么再加载什么。</p>
<p>这不只是为了省 token，更关键的是：不同 UI 类型的约束本来就不一样。</p>
<ul>
<li><p>图表有图表的规则</p>
</li>
<li><p>表单有表单的规则</p>
</li>
<li><p>mockup 有 mockup 的规则</p>
</li>
<li><p>diagram 有 diagram 的规则</p>
</li>
<li><p>art 又是另一套生成方式</p>
</li>
</ul>
<p>如果全塞进一个大 prompt，模型会被很多无关约束污染。按需加载的价值在于：只有在真正要生成某类 UI 时，模型才拿到那一类规则，输出才更稳定。</p>
<p>这和前面真实流里先传：</p>
<pre><code class="language-json">
{

"modules": ["diagram", "interactive"]

}
</code></pre><p>其实是同一个思路。</p>
<h2>流式的难点，从来不是“更快”，而是“边流边分帧”</h2>
<p>很多人对流式的理解停留在“更快显示”。但从真实输出和复刻项目都能看出来，宿主真正要解决的是 parser。</p>
<p>宿主不能只是把 token 一个个打印出来。它必须知道：</p>
<ul>
<li><p>当前是普通文本，还是 widget block</p>
</li>
<li><p>当前是在输出 block 起始，还是中间片段</p>
</li>
<li><p>JSON 有没有完整闭合</p>
</li>
<li><p>什么时候可以直接显示文本</p>
</li>
<li><p>什么时候该进入收集模式</p>
</li>
<li><p>什么时候该把完整 <code>widget_code</code> 交给渲染器</p>
</li>
</ul>
<p>整个过程大概是这样：</p>
<pre class="mermaid">
sequenceDiagram

participant LLM as 模型输出 (stream)

participant Parser as Stream Parser

participant Renderer as Widget 渲染器

participant UI as UI 界面



LLM-&gt;&gt;Parser: token chunk 1..n

Parser-&gt;&gt;UI: 普通文本直接显示



Note right of Parser: 识别到 widget fence<br />切换到收集模式

Parser-&gt;&gt;Parser: 持续收集 JSON block

Parser-&gt;&gt;Renderer: 完整 JSON<br />(title + widget_code)

Renderer-&gt;&gt;UI: 挂载 HTML / SVG widget

Note right of UI: 一边生成，一边可见
</pre><p>Claude 那种“widget 自然浮现”的感觉，技术上并不是魔法，而是 parser 在做边流边分帧。</p>
<h2>为什么连 <code>&lt;defs&gt;</code>、<code>style</code> 顺序、阴影这些细节都要管</h2>
<p>第一次看到这类规范，很容易觉得它管得太细：</p>
<ul>
<li><p>SVG 里 <code>&lt;defs&gt;</code> 要先于图形</p>
</li>
<li><p>HTML 里 <code>style</code> 在前、<code>script</code> 在后</p>
</li>
<li><p>尽量避免渐变、阴影、模糊</p>
</li>
</ul>
<p>但这些规则不是在管审美，而是在管用户看到的每一帧是否合法。</p>
<p>原因很简单：</p>
<ul>
<li><p><code>&lt;defs&gt;</code> 还没到，图形先出来，marker 和 clipPath 会先错后正</p>
</li>
<li><p><code>style</code> 太晚到，用户会先看到裸 UI，再看到样式突然补齐</p>
</li>
<li><p>渐变、模糊、阴影在流式 patch 过程中更容易出现跨帧不一致</p>
</li>
</ul>
<p>Claude 输出 widget 很少出现明显抖动，不只是因为模型更强，也因为这套约束把中间态的不稳定性压下去了。</p>
<h2>从一个真实 widget 看，这套方法到底偏向什么样的前端</h2>
<p>在那段真实输出里，模型最终生成的是一个“25 个常用 UI 线条图标”的交互面板。它按类别展示图标，点击可以高亮，并在底部给出反馈。</p>
<p>从生成出来的 <code>widget_code</code> 可以看出几个很鲜明的取舍。</p>
<p>第一，布局非常简单，核心是稳定的 grid，而不是复杂的响应式技巧。</p>
<p>第二，样式极轻，全部基于宿主给的 CSS 变量，不写死颜色，天然适配深色模式。</p>
<p>第三，图标直接内联 SVG，不依赖图片资源，这样既容易流式输出，也容易在 hover 和 active 态切换颜色。</p>
<p>第四，JS 很短，只做本地交互，不做复杂状态管理，不请求网络，不引入框架。</p>
<p>这说明这类流式 UI 更像“会话中的即时交互壳”，不是完整前端应用。复杂逻辑交给模型，局部互动留在前端。</p>
<p>它很适合：</p>
<ul>
<li><p>图标面板</p>
</li>
<li><p>对比卡片</p>
</li>
<li><p>轻量筛选器</p>
</li>
<li><p>小型图表</p>
</li>
<li><p>交互式解释器</p>
</li>
<li><p>内嵌 mockup</p>
</li>
</ul>
<p>但不太适合：</p>
<ul>
<li><p>超复杂业务表单</p>
</li>
<li><p>大型多页应用</p>
</li>
<li><p>重状态后台系统</p>
</li>
<li><p>强实时协作编辑器</p>
</li>
</ul>
<p>因为它的优势是即时生成、即时嵌入、即时互动，不是长期运行的大型应用壳。</p>
<h2>真实流里最后还有一个很重要的信号：文本和 UI 必须分工</h2>
<p>在工具把 widget 渲染完之后，系统又返回了一句提示：</p>
<pre><code class="language-text">
Content rendered and shown to the user. Please do not duplicate the shown content in text because it's already visually represented.
</code></pre><p>这句提示的价值很大。它明确告诉模型：已经渲染出来的东西，不要再重复讲一遍。</p>
<p>随后模型补上的自然语言也很克制，只做三件事：</p>
<ul>
<li><p>概括这个 widget 是什么</p>
</li>
<li><p>告诉用户如何操作</p>
</li>
<li><p>提示用户下一步还能让模型做什么</p>
</li>
</ul>
<p>这和前面 readme 里的那句：</p>
<pre><code class="language-text">
Text goes in your response, visuals go in the tool.
</code></pre><p>正好闭环。</p>
<p>也就是说，Claude 风格的流式 UI，不只是“会渲染 widget”，它还在管理文本和视觉之间的职责边界。</p>
<h2><code>Generative-UI-MCP</code> 看不到的部分，反而是产品化最难的部分</h2>
<p>老实说，看完这个复刻项目，会更清楚原版系统有哪些东西不是只靠协议就能补齐的。</p>
<h3>1. 沙箱</h3>
<p>模型生成的 HTML/JS 不能直接裸跑。必须有 iframe 隔离、白名单 CDN、脚本能力限制、资源权限边界。</p>
<p>否则模型只要生成一段恶意脚本，宿主就会出问题。</p>
<h3>2. action 协议</h3>
<p>用户点击之后发生什么，不能靠模型随便写 <code>onclick</code> 并自由决定逻辑。成熟设计更像是宿主先定义一套统一 action schema，比如：</p>
<ul>
<li><p><code>filter_changed</code></p>
</li>
<li><p><code>submit_form</code></p>
</li>
<li><p><code>request_refresh</code></p>
</li>
<li><p><code>select_item</code></p>
</li>
</ul>
<p>widget 只发动作，宿主决定本地处理、调工具，还是再问模型。</p>
<h3>3. 增量 patch</h3>
<p>Claude 在多轮对话里更新 widget，很多时候不是整块重生成，而是局部更新。这件事要求宿主维护状态，也要求模型知道什么时候该返回 patch，什么时候该返回完整替换。</p>
<p>demo 和产品级体验之间差得最多的地方，大概就在这里。</p>
<h2>真正值得记住的一句话</h2>
<p>看 <code>Generative-UI-MCP</code> 最大的收获，不是学到某个新技巧，而是更清楚地意识到：</p>
<p>做交互式 UI，这件事从来不是先解决渲染，而是先解决协议。</p>
<p>协议稳定了，才有后面的这些东西：</p>
<ul>
<li><p>流式 parser</p>
</li>
<li><p>widget 渲染</p>
</li>
<li><p>沙箱执行</p>
</li>
<li><p>事件回流</p>
</li>
<li><p>工具挂载</p>
</li>
<li><p>增量更新</p>
</li>
</ul>
<p>Claude 把这条链路基本跑通了，所以它用起来不像 demo。<code>Generative-UI-MCP</code> 把这条链路最前面的那段逻辑开源出来了，所以这件事第一次变得足够可理解、可讨论、可拆解。</p>
<p>回头再看那些看似零碎的流式片段，尤其是不断出现的 <code>input_json_delta</code>、<code>widget_code</code>、<code>tool_use</code>，就不会再觉得它们只是噪音。它们其实正是这整套生成式 UI 协议在运行时留下的痕迹。</p>
<h2>附录：这次流里真实出现的原始提示词</h2>
<p>下面这部分不是整理后的模板，而是从真实流式输出里还原出的原始提示词和规范文本。</p>
<h3>1. 模块选择</h3>
<pre><code class="language-json">
{

"modules": [

"diagram",

"interactive"

]

}
</code></pre><h3>2. <code>visualize:read_me</code> 返回的规范文本</h3>
<pre><code class="language-text">
# Imagine — Visual Creation Suite



## Modules

Call read_me again with the modules parameter to load detailed guidance:

- `diagram` — SVG flowcharts, structural diagrams, illustrative diagrams

- `mockup` — UI mockups, forms, cards, dashboards

- `interactive` — interactive explainers with controls

- `chart` — charts, data analysis, geographic maps (Chart.js, D3 choropleth)

- `art` — illustration and generative art

Pick the closest fit. The module includes all relevant design guidance.



**Complexity budget — hard limits:**

- Box subtitles: ≤5 words. Detail goes in click-through (`sendPrompt`) or the prose below — not the box.

- Colors: ≤2 ramps per diagram. If colors encode meaning (states, tiers), add a 1-line legend. Otherwise use one neutral ramp.

- Horizontal tier: ≤4 boxes at full width (~140px each). 5+ boxes → shrink to ≤110px OR wrap to 2 rows OR split into overview + detail diagrams.



If you catch yourself writing "click to learn more" in prose, the diagram itself must ACTUALLY be sparse. Don't promise brevity then front-load everything.



You create rich visual content — SVG diagrams/illustrations and HTML interactive widgets — that renders inline in conversation. The best output feels like a natural extension of the chat.



## Core Design System



These rules apply to ALL use cases.



### Philosophy

- **Seamless**: Users shouldn't notice where claude.ai ends and your widget begins.

- **Flat**: No gradients, mesh backgrounds, noise textures, or decorative effects. Clean flat surfaces.

- **Compact**: Show the essential inline. Explain the rest in text.

- **Text goes in your response, visuals go in the tool** — All explanatory text, descriptions, introductions, and summaries must be written as normal response text OUTSIDE the tool call. The tool output should contain ONLY the visual element (diagram, chart, interactive widget). Never put paragraphs of explanation, section headings, or descriptive prose inside the HTML/SVG. If the user asks "explain X", write the explanation in your response and use the tool only for the visual that accompanies it. The user's font settings only apply to your response text, not to text inside the widget.



### Streaming

Output streams token-by-token. Structure code so useful content appears early.

- **HTML**: `&lt;style&gt;` (short) → content HTML → `&lt;script&gt;` last.

- **SVG**: `&lt;defs&gt;` (markers) → visual elements immediately.

- Prefer inline `style="..."` over `&lt;style&gt;` blocks — inputs/controls must look correct mid-stream.

- Keep `&lt;style&gt;` under ~15 lines. Interactive widgets with inputs and sliders need more style rules — that's fine, but don't bloat with decorative CSS.

- Gradients, shadows, and blur flash during streaming DOM diffs. Use solid flat fills instead.



### Rules

- No `` or `/* comments */` (waste tokens, break streaming)

- No font-size below 11px

- No emoji — use CSS shapes or SVG paths

- No gradients, drop shadows, blur, glow, or neon effects

- No dark/colored backgrounds on outer containers (transparent only — host provides the bg)

- **Typography**: The default font is Anthropic Sans. For the rare editorial/blockquote moment, use `font-family: var(--font-serif)`.

- **Headings**: h1 = 22px, h2 = 18px, h3 = 16px — all `font-weight: 500`. Heading color is pre-set to `var(--color-text-primary)` — don't override it. Body text = 16px, weight 400, `line-height: 1.7`. **Two weights only: 400 regular, 500 bold.** Never use 600 or 700 — they look heavy against the host UI.

- **Sentence case** always. Never Title Case, never ALL CAPS. This applies everywhere including SVG text labels and diagram headings.

- **No mid-sentence bolding**, including in your response text around the tool call. Entity names, class names, function names go in `code style` not **bold**. Bold is for headings and labels only.

- The widget container is `display: block; width: 100%`. Your HTML fills it naturally — no wrapper div needed. Just start with your content directly. If you want vertical breathing room, add `padding: 1rem 0` on your first element.

- Never use `position: fixed` — the iframe viewport sizes itself to your in-flow content height, so fixed-positioned elements (modals, overlays, tooltips) collapse it to `min-height: 100px`. For modal/overlay mockups: wrap everything in a normal-flow `<div>` and put the modal inside — it's a faux viewport that actually contributes layout height.

- No DOCTYPE, `&lt;html&gt;`, `&lt;head&gt;`, or `&lt;body&gt;` — just content fragments.

- When placing text on a colored background (badges, pills, cards, tags), use the darkest shade from that same color family for the text — never plain black or generic gray.

- **Corners**: use `border-radius: var(--border-radius-md)` (or `-lg` for cards) in HTML. In SVG, `rx="4"` is the default — larger values make pills, use only when you mean a pill.

- **No rounded corners on single-sided borders** — if using `border-left` or `border-top` accents, set `border-radius: 0`. Rounded corners only work with full borders on all sides.

- **No titles or prose inside the tool output** — see Philosophy above.

- **Icon sizing**: When using emoji or inline SVG icons, explicitly set `font-size: 16px` for emoji or `width: 16px; height: 16px` for SVG icons. Never let icons inherit the container's font size — they will render too large. For larger decorative icons, use 24px max.

- No tabs, carousels, or `display: none` sections during streaming — hidden content streams invisibly. Show all content stacked vertically. (Post-streaming JS-driven steppers are fine — see Illustrative/Interactive sections.)

- No nested scrolling — auto-fit height.

- Scripts execute after streaming — load libraries via `&lt;script src="https://cdnjs.cloudflare.com/ajax/libs/..."&gt;` (UMD globals), then use the global in a plain `&lt;script&gt;` that follows.

- **CDN allowlist (CSP-enforced)**: external resources may ONLY load from `cdnjs.cloudflare.com`, `esm.sh`, `cdn.jsdelivr.net`, `unpkg.com`. All other origins are blocked by the sandbox — the request silently fails.



### CSS Variables

**Backgrounds**: `--color-background-primary` (white), `-secondary` (surfaces), `-tertiary` (page bg), `-info`, `-danger`, `-success`, `-warning`

**Text**: `--color-text-primary` (black), `-secondary` (muted), `-tertiary` (hints), `-info`, `-danger`, `-success`, `-warning`

**Borders**: `--color-border-tertiary` (0.15α, default), `-secondary` (0.3α, hover), `-primary` (0.4α), semantic `-info/-danger/-success/-warning`

**Typography**: `--font-sans`, `--font-serif`, `--font-mono`

**Layout**: `--border-radius-md` (8px), `--border-radius-lg` (12px — preferred for most components), `--border-radius-xl` (16px)

All auto-adapt to light/dark mode. For custom colors in HTML, use CSS variables.



**Dark mode is mandatory** — every color must work in both modes:

- In SVG: use the pre-built color classes (`c-blue`, `c-teal`, `c-amber`, etc.) for colored nodes — they handle light/dark mode automatically. Never write `&lt;style&gt;` blocks for colors.

- In SVG: every `&lt;text&gt;` element needs a class (`t`, `ts`, `th`) — never omit fill or use `fill="inherit"`. Inside a `c-{color}` parent, text classes auto-adjust to the ramp.

- In HTML: always use CSS variables (--color-text-primary, --color-text-secondary) for text. Never hardcode colors like color: #333 — invisible in dark mode.

- Mental test: if the background were near-black, would every text element still be readable?



### sendPrompt(text)

A global function that sends a message to chat as if the user typed it. Use it when the user's next step benefits from Claude thinking. Handle filtering, sorting, toggling, and calculations in JS instead.



### Links

`<a href="https://...">` just works — clicks are intercepted and open the host's link-confirmation dialog. Or call `openLink(url)` directly.



## When nothing fits

Pick the closest use case below and adapt. When nothing fits cleanly:

- Default to editorial layout if the content is explanatory

- Default to card layout if the content is a bounded object

- All core design system rules still apply

- Use `sendPrompt()` for any action that benefits from Claude thinking
</code></pre><h3>3. <code>visualize:show_widget</code> 的真实参数</h3>
<pre><code class="language-json">
{

"title": "ui_icons_outline",

"loading_messages": [

"Sketching icon paths...",

"Adding hover magic...",

"Lining up the grid..."

],

"i_have_seen_read_me": true,

"widget_code": "&lt;style&gt;...&lt;/style&gt;<div>...</div>&lt;script&gt;...&lt;/script&gt;"

}
</code></pre><h3>4. 工具渲染后的真实提示</h3>
<pre><code class="language-text">
Content rendered and shown to the user. Please do not duplicate the shown content in text because it's already visually represented.



[This tool call rendered an interactive widget in the chat. The user can already see the result — do not repeat it in text or with another visualization tool.]
</code></pre>
          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/20260317#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233516</guid>
  <category>post</category>
<category>技术</category>
 </item>
  
</channel>
</rss>