<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>hooninedev.com</title>
        <link>https://hooninedev.com/zh-CN</link>
        <description>프론트엔드 개발자 이지훈(후니)의 기술 블로그. React, TypeScript, Next.js 로 만들면서 겪은 설계 판단과 문제 해결 과정, 성능과 관측 실측, 학습 노트를 남깁니다.</description>
        <lastBuildDate>Thu, 15 Oct 2026 20:32:12 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>zh-CN</language>
        <copyright>All rights reserved 2026, 이지훈</copyright>
        <item>
            <title><![CDATA[React 应用的分层]]></title>
            <link>https://hooninedev.com/zh-CN/261001</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/261001</guid>
            <pubDate>Thu, 01 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊聊如何把 React 应用的代码拆成界面、业务规则和数据访问三部分。它写给这样的开发者：一个 component 里同时塞着 fetch、计算和 render，每次修改都得从头读到尾。读完之后，你会知道 hook、pure component、domain object、Strategy、network client 分别该看到什么信号、按什么顺序抽出来，以及把这套结构原样搬过去时会在...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊如何把 React 应用的代码拆成界面、业务规则和数据访问三部分。它写给这样的开发者：一个 component 里同时塞着 fetch、计算和 render，每次修改都得从头读到尾。读完之后，你会知道 hook、pure component、domain object、Strategy、network client 分别该看到什么信号、按什么顺序抽出来，以及把这套结构原样搬过去时会在哪里出问题。</p>
<p>骨架是 Thoughtworks 的 Juntao Qiu 于 2023 年 2 月在 martinfowler.com 上连载的 <a href="https://martinfowler.com/articles/modularizing-react-apps.html" target="_blank" rel="noopener noreferrer">Modularizing React Applications with Established UI Patterns</a>。笔者没有照搬原文，而是用自己的话重新讲了一遍，正文中的图全部取自原文。在此之上，笔者还拉下作者的<a href="https://github.com/abruzzi/payment-round-up-refactoring" target="_blank" rel="noopener noreferrer">示例仓库</a>跑了类型检查和测试，并补充了讨论同一问题的其他资料。</p>
<h2 id="react-是-view-library"><a class="anchor" href="#react-是-view-library">React 是 view library</a></h2>
<p>初学 React 时，最有吸引力的想法是：UI 是把数据变成 DOM 的纯函数。这在一定程度上没错。但一旦向服务器发请求或者跳转页面，component 就不再纯粹了。再加上全局 state 和局部 state 交织在一起，代码很快就会变得复杂。</p>
<p>作者的回答是换个视角。并不存在"React 应用"这种特殊的软件。React 是绘制 UI 的 library，计算和业务规则放在哪里，从一开始就不是它关心的事。架在它上面的，是一个普通的 JavaScript 应用。既然如此，就可以直接沿用从桌面 GUI 时代起一直在用的设计，尤其是把代码分成 presentation、domain、data 三层的做法。</p>
<p>真实的前端应用里，除了 view 还有 router、local storage、多层缓存、网络请求、第三方服务集成与登录、安全、日志、性能调优。如果把这些全都塞进 component 和 hook，同一个文件里就会出现这样的情形：请求订单状态的那一行后面，紧跟着一行去掉字符串开头空格的代码，再后面又是一行跳转到别的页面的代码。读代码的人只能不断切换抽象层次，来回翻看。</p>
<p>拆分的理由可以归结为两点。界面比业务规则变得更频繁。而且把两者分开后，一次只需要思考一件事。第二个理由正是 Martin Fowler 在 <a href="https://martinfowler.com/bliki/PresentationDomainDataLayering.html" target="_blank" rel="noopener noreferrer">PresentationDomainDataLayering</a> 中列为这种划分最大好处的一点：三个主题可以相对独立地思考，需要关注的范围因此缩小。</p>
<h2 id="应用成长的五个阶段"><a class="anchor" href="#应用成长的五个阶段">应用成长的五个阶段</a></h2>
<p>在进入重构之前，作者用五张图展示了应用在成长过程中结构如何变化。重要的是进入下一阶段的契机。推动下一阶段的不是某个固定的时间点，而是当前阶段产生的不便。五张图里颜色的含义都一样：浅绿是持有 state 的 container component，深绿是只负责绘制的 presentational component，紫色是 hook，蓝色是 domain object，橙色是网络之类的 infrastructure。</p>
<p>最开始，一个 component 包揽网络请求、state、计算和 render。对小应用或一次性项目来说这就够了，而且代码长得像 HTML，反而更好读。当遍历列表生成条目的代码和配置外部 component 的代码混在一起，读懂到底发生了什么开始变得费时，就该进入下一阶段了。</p>
<p><picture><source type="image/webp" srcset="/content/261001/1.720w.webp 720w, /content/261001/1.1440w.webp 1440w, /content/261001/1.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/1.png" alt="一个 component 同时包含网络请求、state 管理、domain logic 和 render，并直接调用 API 的结构" style="max-width:min(720px, 100%);height:auto;" width="1600" height="661" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>界面变大之后，就按照最终 HTML 的形状拆分 component。只负责绘制的 component 被分离出来，但最上层的 component 里仍然留着网络请求、把响应转换成界面所需形状的代码，以及收集要发给服务器的数据的代码。这三样都不是 UI，却待在 component 里。</p>
<p><picture><source type="image/webp" srcset="/content/261001/2.720w.webp 720w, /content/261001/2.1440w.webp 1440w, /content/261001/2.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/2.png" alt="只负责绘制的 component 已经分离，但上层 component 里仍留有网络请求和 domain logic 的结构" style="max-width:min(720px, 100%);height:auto;" width="1600" height="984" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>接着把 state 及其变化抽到 custom hook 里。这时 hook 中就剩下了既不是 side effect 也不是 state 的纯计算。</p>
<p><picture><source type="image/webp" srcset="/content/261001/3.720w.webp 720w, /content/261001/3.1440w.webp 1440w, /content/261001/3.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/3.png" alt="网络请求和 domain logic 移进了 hook，但两者在 hook 内部混在一起的结构" style="max-width:min(720px, 100%);height:auto;" width="1600" height="872" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>把这段计算抽到与 React 无关的对象里，就得到了 domain object。数据格式转换、null 检查、默认值都搬到这里。对象多起来之后，就开始需要继承或多态。</p>
<p><picture><source type="image/webp" srcset="/content/261001/4.720w.webp 720w, /content/261001/4.1440w.webp 1440w, /content/261001/4.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/4.png" alt="hook 里只剩 state，domain logic 分到 Domain 对象，网络请求分到 Fetcher 的结构" style="max-width:min(720px, 100%);height:auto;" width="1600" height="790" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>最后，当你看到一些既不属于 UI、也不在乎数据来自服务器还是 local storage 或缓存的对象时，就把它们单独归成 model layer。</p>
<p><picture><source type="image/webp" srcset="/content/261001/5.720w.webp 720w, /content/261001/5.1440w.webp 1440w, /content/261001/5.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/5.png" alt="中间的横带是 model layer，component 和 hook 在上方，Fetcher、Adaptor 等 infrastructure 在下方的结构" style="max-width:min(720px, 100%);height:auto;" width="1600" height="917" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>光看图比较抽象。我们用实际代码把这五个阶段走一遍。作者选的例子是在线订单的支付页面。</p>
<h2 id="从一个支付页面出发"><a class="anchor" href="#从一个支付页面出发">从一个支付页面出发</a></h2>
<p>支付方式来自服务器配置，每个国家都不一样。只要服务器返回了至少一种，就在末尾追加现金支付并默认选中；一种都没有的话，就什么也不显示。</p>
<p><picture><source type="image/webp" srcset="/content/261001/6.720w.webp 720w, /content/261001/6.1440w.webp 1440w, /content/261001/6.1600w.webp 1600w" sizes="(max-width: 632px) calc(100vw - 32px), 600px"><img src="/content/261001/6.png" alt="订单详情下方的支付区域有 apple、google、现金支付三个单选按钮，且选中了现金支付的页面" style="max-width:min(600px, 100%);height:auto;" width="1600" height="1130" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>最初的代码就是教程里常见的样子。在 <code>useEffect</code> 里 fetch，把响应转换成界面所需的形状，追加现金支付，再绘制单选列表。下面是笔者精简后的版本。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Payment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">paymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">LocalPaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]>([]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(url)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">res</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> res.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">())</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">methods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RemotePaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (methods.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([]);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> extended</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> methods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ provider: m.name, label: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`Pay with ${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">m</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">name</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        extended.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ provider: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, label: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Pay in cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">        setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(extended);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, []);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      {paymentMethods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">method</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">label</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> key</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{method.provider}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">input</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> type</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"radio"</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> defaultChecked</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{method.provider </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          {method.label}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">label</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      ))}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>${amount}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>在这个规模下不成问题。但要修改这个 component，就得同时理解四件事：如何发起网络请求，如何把服务器数据转换成界面认识的形状，如何绘制单个支付方式，以及如何绘制整个支付区域。代码一旦变大，读代码的人就得在这四者之间不停切换。</p>
<h2 id="hook-与-pure-component"><a class="anchor" href="#hook-与-pure-component">hook 与 pure component</a></h2>
<p>第一个抽出来的是 state 和 fetch。判断信号很简单。<strong>与 render 无关的代码超过 component 主体的一半时</strong>，就抽成 hook。<code>usePaymentMethods</code> 负责 fetch 和转换，component 只接收 hook 给的数组并负责绘制。</p>
<p>第二个是绘制列表的 JSX。<strong>看到只靠 props 就能绘制的代码块时</strong>，就拆成 component。<code>PaymentMethods</code> 是只接收一个支付方式数组的 pure component，因此易于测试和复用。以后即使加上通知选择结果的 <code>onSelect</code> callback，它也不碰外部 state，所以依然是 pure 的。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> usePaymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">paymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">LocalPaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]>([]);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 위의 fetch와 변환 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, []);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { paymentMethods };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Payment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">paymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> usePaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">PaymentMethods</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> paymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{paymentMethods} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>${amount}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>作者把这两步都称为重构目录中的 <a href="https://refactoring.com/catalog/extractFunction.html" target="_blank" rel="noopener noreferrer">Extract Function</a>，因为在 React 里 component 也是函数。重要的是，这里并没有引入新概念，只是把一个函数拆成了两个。</p>
<p>到这里看起来像是完成了，但还剩两处。view 里有"现金就默认选中"这个判断，hook 里有把响应转换成界面形状的匿名函数。作者把这种业务规则渗漏到 view 和 hook 里的现象称为 logic leak。</p>
<h2 id="收拢到-domain-object-的规则"><a class="anchor" href="#收拢到-domain-object-的规则">收拢到 domain object 的规则</a></h2>
<p>第三个信号是<strong>对同一份数据的判断散落在 view 和 hook 中的时候</strong>。把渗漏出来的判断和转换收拢到一个 <code>PaymentMethod</code> class 里。就像本博客<a href="/260418" target="_blank" rel="noopener noreferrer">领域模型</a>一文讲过的那样，把规则放在数据旁边。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  constructor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">private</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> remote</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RemotePaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {}</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  get</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> provider</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.remote.name; }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  get</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> label</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.provider </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "cash"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "Pay in cash"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `Pay with ${</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">provider</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  get</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isDefaultMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.provider </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> convertPaymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">methods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RemotePaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  methods.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> []</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">methods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(m)), </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> })];</span></span></code></pre></figure>
<p>view 里的单选按钮现在通过 <code>defaultChecked={method.isDefaultMethod}</code> 去问对象。默认的现金支付也用同一个 class 的一个实例来表示。服务器给的和应用追加的变成了同一种形状，view 就不需要区分两者。</p>
<p><picture><source type="image/webp" srcset="/content/261001/7.720w.webp 720w, /content/261001/7.1440w.webp 1440w, /content/261001/7.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/7.png" alt="Payment 调用 usePaymentMethods，hook 生成 PaymentMethod，PaymentMethods 只负责绘制的结构" style="max-width:min(720px, 100%);height:auto;" width="1600" height="662" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>作者把测试变容易列为这种结构的好处。原文说到这里就停了，但到底容易了多少，看代码更快。下面是笔者写的测试。没有 React，没有渲染器，也没有 fetch mock。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">test</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"서버가 준 결제 수단 끝에 현금 결제를 기본값으로 붙인다"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> methods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> convertPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([{ name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"apple"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, { name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"google"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  expect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(methods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.label)).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toEqual</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Pay with apple"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Pay with google"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Pay in cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  expect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(methods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.isDefaultMethod).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.provider)).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toEqual</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">test</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"서버가 아무것도 주지 않으면 현금 결제도 보여 주지 않는다"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  expect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">convertPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([])).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toEqual</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([]);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>要在最初的代码里验证同样的规则，得先 render component，拦截 fetch，再等单选按钮画出来。规则一旦变成对象，测试就成了一行函数调用。</p>
<p>另一个好处是，新需求来了以后该去哪里是确定的。此时的文件结构是这样的。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>src</span></span>
<span data-line=""><span>├── components</span></span>
<span data-line=""><span>│   ├── Payment.tsx</span></span>
<span data-line=""><span>│   └── PaymentMethods.tsx</span></span>
<span data-line=""><span>├── hooks</span></span>
<span data-line=""><span>│   └── usePaymentMethods.ts</span></span>
<span data-line=""><span>└── models</span></span>
<span data-line=""><span>    └── PaymentMethod.ts</span></span></code></pre></figure>
<p>结构是否真的扛得住，要等新需求来了才知道。作者在这里又加了一个功能。</p>
<h2 id="捐赠功能与作为-state-machine-的-hook"><a class="anchor" href="#捐赠功能与作为-state-machine-的-hook">捐赠功能与作为 state machine 的 hook</a></h2>
<p>新需求是把订单金额向上取整，把差额捐给慈善机构。订单是 19.80 美元的话，就询问是否捐 0.20 美元，同意后按钮上显示 20 美元。</p>
<p><picture><source type="image/webp" srcset="/content/261001/8.720w.webp 720w, /content/261001/8.1440w.webp 1440w, /content/261001/8.1600w.webp 1600w" sizes="(max-width: 632px) calc(100vw - 32px), 600px"><img src="/content/261001/8.png" alt="支付方式下方新增了 0.2 美元捐赠复选框的支付页面" style="max-width:min(600px, 100%);height:auto;" width="1600" height="1130" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>作者故意一开始把是否同意的 state 和计算放进支付 component 里。复选框标记、文案分支、合计计算一股脑涌进来，component 又变重了。整理的顺序和前面一样：state 和计算放进 <code>useRoundUp</code> hook，文案拼接放进 helper 函数，复选框标记放进 <code>DonationCheckbox</code> component。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useRoundUp</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">agreeToDonate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setAgreeToDonate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">total</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">tip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useMemo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    total: agreeToDonate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> amount,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    tip: </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">parseFloat</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> amount).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toPrecision</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }), [amount, agreeToDonate]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateAgreeToDonate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setAgreeToDonate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">v</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">v);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { total, tip, agreeToDonate, updateAgreeToDonate };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p><code>toPrecision(10)</code> 是原文没有解释原因的地方。笔者在 Node 24 上试了一下，<code>20 - 19.8</code> 得到的是 <code>0.1999999999999993</code>。这是用来消除浮点误差的手段。</p>
<p>作者把这样的 hook 看作"view 背后的 state machine"。UI 传来事件就生成新的 state，新的 state 又触发 render。hook 原本是为了让多个 component 共享 logic 而设计的，但作者的立场是，即使只有一处在用，它也能让 component 专注于 render，所以值得抽出来。在此补充一点：正如 React 官方文档的 <a href="https://react.dev/learn/reusing-logic-with-custom-hooks" target="_blank" rel="noopener noreferrer">Reusing Logic with Custom Hooks</a> 指出的，custom hook 共享的是处理 state 的 logic，而不是 state 本身。每个调用 hook 的 component 都会各自生成一个这样的 state machine。</p>
<p>整理完之后，支付 component 变成调用两个 hook、并排摆出两个子 component 和一个按钮的样子。功能多了一个，支付 component 的职责却没变。</p>
<p><picture><source type="image/webp" srcset="/content/261001/9.720w.webp 720w, /content/261001/9.1440w.webp 1440w, /content/261001/9.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/9.png" alt="Payment 下方是 useRoundUp 和 usePaymentMethods 两个 hook，以及 PaymentMethods 和 DonationCheckbox 两个子 component 的结构" style="max-width:min(720px, 100%);height:auto;" width="1600" height="775" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<h2 id="各国规则与-shotgun-surgery"><a class="anchor" href="#各国规则与-shotgun-surgery">各国规则与 shotgun surgery</a></h2>
<p>下一个需求是让各国使用不同的取整单位。日本以 100 日元为单位，丹麦以 10 克朗为单位。作者说"看起来是个简单的修改"，故意先展示了把国家代码作为 prop 传入、再加上分支的做法。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// useRoundUp 안</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">total</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: agreeToDonate</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> countryCode </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "JP"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> amount,</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 체크박스 문구 helper 안</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> currencySign</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> countryCode </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "JP"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "¥"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "$"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 버튼 JSX 안</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{countryCode </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "JP"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "¥"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "$"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}{total}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>同样的 <code>countryCode === "JP"</code> 判断在 hook、helper、按钮三个地方各出现了一次。加上丹麦，这三处都得重新改；三元运算符撑不住的时候，就会冒出一张按国家代码查货币符号的表。那张表最终也会在多处被分别引用。一处变更需要同时修改多个模块，这种坏味道叫作 shotgun surgery。</p>
<p><picture><source type="image/webp" srcset="/content/261001/10.720w.webp 720w, /content/261001/10.1440w.webp 1440w, /content/261001/10.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/10.png" alt="代表各国规则的彩色条散落在多个 component、hook 和 domain object 中的图" style="max-width:min(720px, 100%);height:auto;" width="1600" height="902" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>这个阶段和前三个阶段的不同之处在于，判断信号不是来自代码，而是来自<strong>变更请求</strong>。只看现在的代码，三个分支并不难读。要数一数每加一个国家得改几处，问题才会显现。那么，散落的分支该收拢到哪里呢？</p>
<h2 id="用-strategy-收拢各国差异"><a class="anchor" href="#用-strategy-收拢各国差异">用 Strategy 收拢各国差异</a></h2>
<p>作者的回答是把各国的差异本身做成对象。先用 <a href="https://refactoring.com/catalog/extractClass.html" target="_blank" rel="noopener noreferrer">Extract Class</a> 和 <a href="https://refactoring.com/catalog/replaceConditionalWithPolymorphism.html" target="_blank" rel="noopener noreferrer">Replace Conditional with Polymorphism</a> 做出一个 interface 和各国的实现。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  getRoundUpAmount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  getTip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategyAU</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> implements</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 1달러 단위 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategyJP</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> implements</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 100엔 단위 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span></code></pre></figure>
<p>做完一看，各个实现之间不同的只有货币符号和取整函数两样，<code>getTip</code> 之类的其余部分完全一样。于是作者用 <a href="https://refactoring.com/catalog/inlineClass.html" target="_blank" rel="noopener noreferrer">Inline Class</a> 把这些实现合并成一个 class，只把不同的部分通过构造函数传入。之所以不必为每个国家建 subclass，而能把取整算法作为函数传进去，是因为在 JavaScript 里函数是值。下面是笔者把结果精简后的版本。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CountryPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  constructor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> currencySign</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">private</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> roundUp</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  getRoundUpAmount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">roundUp</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n); }</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  getTip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> parseFloat</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getRoundUpAmount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> n).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toPrecision</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)); }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> japan</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CountryPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"¥"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">japan.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getRoundUpAmount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3312</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 3400</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">japan.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getTip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3312</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);           </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 88</span></span></code></pre></figure>
<p>hook 和 component 现在不再知道国家。它们只是接收这一个对象，使用 <code>strategy.getRoundUpAmount(amount)</code> 和 <code>strategy.currencySign</code>。加一个国家只要多建一个对象就行，不需要改任何文件。</p>
<p><picture><source type="image/webp" srcset="/content/261001/11.720w.webp 720w, /content/261001/11.1440w.webp 1440w, /content/261001/11.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/11.png" alt="component 只面向一个 PaymentStrategy，三个国家的实现都聚在它背后的结构" style="max-width:min(720px, 100%);height:auto;" width="1600" height="847" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>先经过 interface 再合并回去，这个过程看起来可能有些多余。笔者认为这是原文最值得学习的部分。多态不是目的，而是揭示共同点与差异的工具；一旦看到差异缩小到一个函数，就把 class 层级拆掉了。比起引入模式，判断什么时候再把模式拆掉更难。</p>
<h2 id="把-fetch-移出-hook"><a class="anchor" href="#把-fetch-移出-hook">把 fetch 移出 hook</a></h2>
<p>最后抽出来的是 <code>usePaymentMethods</code> 里的 fetch 和转换。hook 是 React 的概念，所以留在 view 一侧。加上 error handling 和 retry，hook 很快就会膨胀；换到别的 view library，hook 就用不了。而普通函数在哪里都能用。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchPaymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> response</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`${</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">API</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}/payment-methods?countryCode=AU`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> convertPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> response.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">());</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> usePaymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">paymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">PaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]>([]);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    fetchPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">().</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(setPaymentMethods);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, []);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { paymentMethods };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>作者写道，这个函数的作用类似 Anti-Corruption Layer 或 Gateway。意思是即使服务器响应的结构变了，要改的地方也只限于这一个函数。不过这两个名字并不是一回事。Fowler 的 <a href="https://martinfowler.com/articles/gateway-pattern.html" target="_blank" rel="noopener noreferrer">Gateway</a> 是把对外部系统的访问封装到一处的对象；Anti-Corruption Layer 则出自 Eric Evans 的 DDD，是一道边界，负责翻译语义体系不同的外部模型，防止它污染自己的模型。这个例子里的服务器响应只有 <code>{ name }</code> 一个字段，几乎没有需要翻译的语义差异，所以笔者认为 Gateway 是更准确的名字。当服务器是其他团队拥有的遗留系统、连支付方式的含义本身都不同时，同一个位置才会成为 Anti-Corruption Layer。</p>
<p>最终结构是这样的。render 在 component 里，state 在 hook 里，规则在 domain object 里，网络请求在函数里。</p>
<p><picture><source type="image/webp" srcset="/content/261001/12.720w.webp 720w, /content/261001/12.1440w.webp 1440w, /content/261001/12.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/12.png" alt="Payment 及其子 component、useRoundUp 和 usePaymentMethods 两个 hook、PaymentStrategy 和 PaymentMethod 两个 domain object、Fetcher 各就各位的最终结构" style="max-width:min(720px, 100%);height:auto;" width="1600" height="820" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>作者列出了这样拆分带来的五个好处。更容易找到缺陷所在的位置。代码更容易复用和组合。更容易阅读。增加功能时整体不会动摇。还有，domain logic 不知道 view，所以可以只替换 view layer。对最后一点，作者自己也附加了一个前提：在大多数项目中这种情况非常罕见。另外值得一并记住的是，这五点都没有测量数据。</p>
<p>原文的主线到此为止。但真正把这段代码跑起来，会冒出文章里看不到的东西。</p>
<h2 id="运行示例后看到的东西"><a class="anchor" href="#运行示例后看到的东西">运行示例后看到的东西</a></h2>
<p>笔者拉下作者的示例仓库（16 个提交，最后一个提交 <code>de186c5</code>），跑了 <code>tsc --noEmit</code> 和 7 个测试。两者都通过了，原文中缺少定义的 helper 也在仓库的 <code>src/utils.ts</code> 里。但仔细看了结构之后，发现有三件事是在照搬之前需要知道的。</p>
<h3 id="每次-render-都新建的-strategy"><a class="anchor" href="#每次-render-都新建的-strategy">每次 render 都新建的 Strategy</a></h3>
<p>支付 component 通过 prop 接收 Strategy，没有传的话就用 <code>new</code> 创建默认值。而 <code>useRoundUp</code> 把这个 Strategy 放进了 <code>useMemo</code> 的 dependency 里。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Payment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  amount,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  strategy </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"$"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, roundUpToNearestInteger),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">strategy</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">total</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">tip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useRoundUp</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount, strategy); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 안에서 [agreeToDonate, amount, strategy]</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>JavaScript 的 default parameter <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters" target="_blank" rel="noopener noreferrer">在每次调用时都会求值</a>。而 <code>useMemo</code> 用 <code>Object.is</code> 比较 dependency。两者一碰上，每次 render 都会生成新实例，memo 每次都重新计算。笔者在 React 18 下把同样的结构 render 了四次，使用默认值时计算跑了 4 次，传入同一个实例时只跑了 1 次。仓库里的 <code>App.tsx</code> 写的是 <code>&#x3C;Payment amount={19.9} /></code>，没有传 Strategy，所以应用的执行路径恰好走的就是默认值这一侧。</p>
<p>这个例子的计算只是几次加法，没有什么开销。问题在于结构。由谁在哪里创建并传入 Strategy，文章和仓库里都没有定下来，只有测试创建了日本和丹麦的实例。换作笔者，会把实例放成 module 级常量，再按国家代码取出来传进去。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> strategies</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  AU: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CountryPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"$"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  JP: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CountryPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"¥"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Payment amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{amount} strategy</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{strategies[countryCode]} /></span></span></code></pre></figure>
<h3 id="仓库里的-fetch-没有-dependency-数组"><a class="anchor" href="#仓库里的-fetch-没有-dependency-数组">仓库里的 fetch 没有 dependency 数组</a></h3>
<p>原文代码的 <code>useEffect</code> 里有 <code>[]</code>，但仓库中的 <code>usePaymentMethods</code> 从第一个提交到最后一个提交都没有 dependency 数组。没有数组的 effect 每次 render 都会重新执行。响应回来后用新数组更新 state，这又触发 render，effect 再次 fetch。由于 <code>convertPaymentMethods</code> 每次都返回新数组，这个循环停不下来。</p>
<p>把 fetch 换成立即响应的 mock，在 Jest 里统计 300ms 内的调用次数，测了四次，分别是 152、225、231、233 次。加上 <code>[]</code> 就是 1 次。仓库的测试只检查文案有没有出现在页面上，所以抓不到这种反复调用，全部通过。照着原文敲代码的读者不会遇到这个问题，只有直接拿仓库来用时才会踩到。</p>
<h3 id="把国家代码作为-prop-传入时出现的-race"><a class="anchor" href="#把国家代码作为-prop-传入时出现的-race">把国家代码作为 prop 传入时出现的 race</a></h3>
<p>原文代码的 fetch effect 也没有 cleanup。React 官方文档的 <a href="https://react.dev/learn/synchronizing-with-effects" target="_blank" rel="noopener noreferrer">Synchronizing with Effects</a> 指出，在 effect 里 fetch 时，晚到的旧响应可能覆盖最新的响应，所以要用 <code>ignore</code> 标志丢弃旧响应。原文只在 mount 时 fetch 一次，所以目前没有问题。但一旦把 URL 里的 <code>countryCode=AU</code> 改成通过 prop 传入，就有了 dependency，race 也随之出现。笔者在移植这套结构时，会从一开始就这样写。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ignore </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  fetchPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(countryCode).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">methods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">ignore) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(methods);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { ignore </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [countryCode]);</span></span></code></pre></figure>
<p>再补充一点，原文的取整是 <code>Math.floor(amount + 1)</code>，所以金额已经是整数时还会再加 1。20 美元的订单会变成 21 美元。原文只举了 19.80 美元和 3312 日元的例子，看不出这是不是有意为之。如果要搬到实际服务中，首先得确定它和 <code>Math.ceil</code> 有什么不同。</p>
<h2 id="这种结构什么时候用"><a class="anchor" href="#这种结构什么时候用">这种结构什么时候用</a></h2>
<p>作者自己也说，拆分不是固定规则。小而内聚的 component 放在一个文件里反而更容易理解其行为，需要警惕的是文件大到无法理解。他明确表示，Todo 应用或只有一个表单的应用全部放进 component 里也没关系，而示例是为了展示更多模式才故意做得复杂。其他资料也给出了同一方向的前提。</p>
<p>Fowler 在同一篇 layering 文章中说，这种划分只适用于相对较小的单元。把 view、model、data 作为顶层文件夹，在小系统里没问题，但一旦其中某一个变得太大，就应该按领域划分顶层，再在其内部分层。如果把前面看到的 <code>components/</code>、<code>hooks/</code>、<code>models/</code> 文件夹原样搬成大型应用的顶层结构，就会变成 Fowler 叫人避免的样子。</p>
<p>Dan Abramov 也有类似的经历。他在 2015 年提出把 component 分成 <a href="https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0" target="_blank" rel="noopener noreferrer">presentational 和 container component</a>，到了 2019 年又在文章开头加了一段更新，说现在不再推荐这样拆分。理由是 Hooks 不需要任意的划分就能做到同样的事；他还补充说，见过太多在没有必要的情况下被教条式强制推行的例子。原文的 hook 抽取更接近这段更新所说的方式。不单独设一个叫 container 的 component，由调用 hook 的 <code>Payment</code> 兼任这个角色。</p>
<p>关于 fetch，React 官方文档又往前走了一步。<a href="https://react.dev/learn/you-might-not-need-an-effect" target="_blank" rel="noopener noreferrer">You Might Not Need an Effect</a> 一方面说把 fetch logic 抽到 custom hook 里，日后更容易迁移到更好的策略；另一方面首推的是 framework 内置的数据获取，或 TanStack Query 这样的 client cache。像原文最后一步那样把 fetch 抽成普通函数，就可以把这个函数直接传给 TanStack Query 的 <code>queryFn</code>，前一节的 race 和反复调用都交给 library 处理。笔者认为，这是原文只用一句话带过、却最实用的收益。key 和函数如何组合，另在 <a href="/260104" target="_blank" rel="noopener noreferrer">queryKey</a> 一文中讨论过。</p>
<p>总结一下笔者的判断。原文的顺序（hook、pure component、domain object、Strategy、network client）值得照着走，因为每个阶段的不便都会成为进入下一阶段的理由。但并不是非走到底不可，看清自己眼下遇到的不便属于哪个阶段，在那里停下就好。与 render 无关的代码多，就做到 hook；同样的判断四处散落，就做到 domain object；一处变更要改好几个文件，就做到 Strategy。另外，如果要用 Strategy，先定好创建实例的位置；如果要把 fetch 放在 effect 里，先写好 cleanup 和 dependency。原文省略的这两点，正是实际移植时最先出问题的地方。</p>
<p>没有标准答案，但希望读到这篇文章的各位也能想一想，自己的代码眼下正遭遇的是第几个阶段的不便。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer" target="_blank" rel="noopener noreferrer">Microsoft Azure Architecture Center，Anti-corruption Layer 模式</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://martinfowler.com/articles/headless-component.html" target="_blank" rel="noopener noreferrer">Juntao Qiu，Headless Component</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://martinfowler.com/articles/data-fetch-spa.html" target="_blank" rel="noopener noreferrer">Juntao Qiu，Data Fetching Patterns in Single-Page Applications</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>前端</category>
            <category>React</category>
            <category>架构</category>
        </item>
        <item>
            <title><![CDATA[决策模型，Jev 与 Kev]]></title>
            <link>https://hooninedev.com/zh-CN/260922</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/260922</guid>
            <pubDate>Tue, 22 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊聊上周 TypeSafe AI 公开的 Jev，一个不返回文本、只返回概率的模型。本文写给想给模型返回的概率设阈值、在没有人工确认的情况下自动处理的开发者。读完之后，你会知道 Jev 的 confidence 是怎么算出来的，校准会随分布变化多少，以及用自己的数据定阈值的步骤。 最先映入眼帘的用途是浏览器自动化。Browserbase 提交了一个把 Jev 接进 Stagehand 的...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊上周 TypeSafe AI 公开的 Jev，一个不返回文本、只返回概率的模型。本文写给想给模型返回的概率设阈值、在没有人工确认的情况下自动处理的开发者。读完之后，你会知道 Jev 的 <code>confidence</code> 是怎么算出来的，校准会随分布变化多少，以及用自己的数据定阈值的步骤。</p>
<p>最先映入眼帘的用途是浏览器自动化。Browserbase 提交了一个把 Jev 接进 Stagehand 的 <code>act()</code> 的 <a href="https://github.com/browserbase/stagehand/pull/2953" target="_blank" rel="noopener noreferrer">PR</a>。结构是把页面的<span class="glossary-term-source" data-glossary-key="accessibility-tree">无障碍树</span>作为 <code>state</code> 发过去，再以选项的形式问「下一步该点击哪个元素」。40 个任务里 <code>act()</code> 的中位数从 1.97 秒降到 0.46 秒，147 次操作中回退到 LLM 的只有 4 次。Playwright 脚本只要一个 selector 变了就会坏掉的那个位置，被一次只返回一个概率的调用填上了。写这篇文章的时候，这个 PR 还没有合并。</p>
<p><picture><source type="image/webp" srcset="/content/260922/1.720w.webp 720w, /content/260922/1.1440w.webp 1440w, /content/260922/1.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/1.png" alt="Stagehand 的 act() 使用 Jev 的流程。从指令和无障碍树生成候选列表，问 Jev「是哪一个」和「是否都不是」，「都不是」概率低就点击该元素，高就交给 LLM。147 次操作中 143 次由 Jev 完成" style="max-width:min(720px, 100%);height:auto;" width="1560" height="920" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>那个 PR 划的线，就是这篇文章的问题。它把接受 Jev 答案的标准放在<a href="https://www.browserbase.com/blog/what-is-jev" target="_blank" rel="noopener noreferrer">概率 0.7</a>，过不了就交给 LLM。<strong>能不能相信那个模型返回的概率，在代码里划一条线。</strong></p>
<p>光看别人的基准测试没法判断，所以笔者想用自己的数据来验证。这个博客里有一道拦截音译外来词的闸门。规则是不写 <code>피커</code> 而写 <code>picker</code>，但正则只能抓住有把握的那一半，像 <code>멀티스레드</code>（멀티=multi + 스레드=thread）或 <code>콜 스택</code>（call stack）这种嵌在复合词里或者在韩语里早已定型的词，由笔者每次手工判断。答案只有「还原」和「保留」两个，懂的人一秒就能判断，而且要在构建脚本里、没有人复核的情况下决定通过还是失败。它和 Stagehand 的 selector 判断是同一种形状。</p>
<p>于是顺序就这样定了：Jev 返回什么，那个概率来自什么样的训练，把笔者的闸门交给它会得到什么数字，以及这些数字能用在哪里。</p>
<h2 id="不生成文本的模型"><a class="anchor" href="#不生成文本的模型">不生成文本的模型</a></h2>
<p>公开日期是 2026 年 9 月 15 日。公司不把它叫作 LLM，而是归进 System One Model 这个新类别。本文称之为决策模型。创始人 Diogo Almeida 曾在 OpenAI 以第四作者的身份参与过<a href="https://arxiv.org/abs/2203.02155" target="_blank" rel="noopener noreferrer">InstructGPT 论文</a>。他待过打造了 ChatGPT 直系祖先那套方法的团队，如今站在指出这套方法局限的一边。</p>
<p>Jev 不生成句子。预先给定答案的形状，它就从中挑选，并一并返回概率。问题类型只有三种。</p>
<table>
<thead>
<tr>
<th>类型</th>
<th>问什么</th>
<th>返回什么</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>choice</code></td>
<td>是这其中的哪一个</td>
<td>选项、<code>probabilities</code>、<code>confidence</code></td>
</tr>
<tr>
<td><code>score</code></td>
<td>处在哪个水平</td>
<td>分数、<code>legend</code>、<code>probabilities</code>、<code>confidence</code></td>
</tr>
<tr>
<td><code>noul</code></td>
<td>是否为真</td>
<td>0 到 1 之间的一个概率</td>
</tr>
</tbody>
</table>
<p>endpoint 也只有一个。向 <code>POST /v1/systemone</code> 发送 <code>state</code>（要评估的内容）、<code>model</code>、<code>questions</code>（问题映射）就会拿到答案。针对同一个 <code>state</code> 的多个问题可以装进一次请求，而且问题变多，响应时间也几乎不增加。</p>
<p>为什么快，可以用<a href="/260610" target="_blank" rel="noopener noreferrer">token 的原理</a>一文中梳理过的 <span class="glossary-term-source" data-glossary-key="prefill">prefill</span> 与 decode 的不对称来解释。LLM 慢的原因在于输出要一个 token 一个 token 地顺序挤出来，而 Jev 没有 decode 这个阶段。它并行读一次输入，直接把概率读出来。所以完全没有输出 token 的费用，只有输入按每百万 token $0.042 计费。</p>
<p>有一位叫 Archer Hume 的开发者调用 API 约一万次，从外部还原了它的行为，写成了一篇<a href="https://archerhume.com/posts/jevs-architecture-unmasked/" target="_blank" rel="noopener noreferrer">分析</a>。输入 360 token 时中位数 57.5ms，29,835 token 时 218ms，塞进 1,500 个问题时是 610ms。其中最直接的证据，是选项有 200 个的响应与只有 2 个的响应以同样的速度返回这一观察。意思是写答案本身不花时间。</p>
<h2 id="没人看的判断"><a class="anchor" href="#没人看的判断">没人看的判断</a></h2>
<p>速度和价格很显眼，但 Almeida 在公开发布文章里抛出的问题在另一边。模型在对话上已经超人好几年了，那自动化都去哪儿了。</p>
<p>他的回答是，这两类工作的种类不同。聊天机器人、copilot 和编码 agent，目标是让人在旁边看着觉得满意。在服务器上安静运行、没人看的判断则不是这样。如果把前者叫 assistant、后者叫 automation，那么到目前为止出现的模型全都是为前者造的。</p>
<p>笔者在梳理<a href="/260622" target="_blank" rel="noopener noreferrer">harness 设计</a>时也做过类似的区分。agent 系统里有无数没人看的判断。这个请求要不要转交给人。这条命令可不可以执行。现在这些全都在问昂贵的 LLM。笔者的正则闸门盖不住的剩下那一半，也是这样的判断。</p>
<h2 id="rlhf-留下的位置"><a class="anchor" href="#rlhf-留下的位置">RLHF 留下的位置</a></h2>
<p>那为什么需要新的训练方法呢。让现有的模型只答是/否不行吗。</p>
<p>线索在<span class="glossary-term-source" data-glossary-key="calibration">校准</span>里。准确率是能答对百分之几，校准则是知不知道自己能答对百分之几，衡量这种偏离的指标是 <span class="glossary-term-source" data-glossary-key="ece">ECE</span>（expected calibration error）。它把每个概率区间里说出口的概率与实际命中率的差值按样本比例加权平均，0 为完美。可是，有案例显示，经过优化人类偏好的 <span class="glossary-term-source" data-glossary-key="rlhf">RLHF</span>（reinforcement learning from human feedback）之后，这个刻度偏了。在 <a href="https://arxiv.org/abs/2303.08774" target="_blank" rel="noopener noreferrer">GPT-4 技术报告</a>的 Figure 8 中，预训练模型的 ECE 是 <strong>0.007</strong>，经过 post-training 的模型则是 <strong>0.074</strong>，差了十倍以上。报告没有写出原因，笔者也没有找到能确定原因的一手证据。定义以及原因目前弄清到了什么程度，另外整理在<a href="/260917" target="_blank" rel="noopener noreferrer">LLM 的校准与过度自信</a>中。</p>
<p><strong>不过光靠 ECE 一个是不够的。</strong> 对所有输入都打 0.6 的常数预测器，只要实际命中率是 60%，ECE 也是 0。概率只是诚实、却不随案例分开，就没有划线的地方。所以除校准之外还要一起看<strong>概率是否真的分得开</strong>，后文要用的“在误差预算内可自动处理的比例”就是把这两者捆成一个数字的指标。</p>
<p>从软件的角度看，重要的是这一部分。概率既诚实又分得开，就能划线。于是有了超过 0.95 就自动处理、低于这个值就交给人的结构。</p>
<p>TypeSafe 说自己瞄准这个位置开出了第三条路。优化人类偏好的 RLHF，优化机器可判分的正确答案的 RLVR（reinforcement learning with verifiable rewards），以及声称优化校准后决策的 RLCD。</p>
<p>只是以 RLCD 名义公开出来的只有三行输出契约。截至写这篇文章的 2026 年 9 月 23 日，TypeSafe 的文档和发布文章里都没有论文或技术报告。另有一篇用同样缩写的 <a href="https://arxiv.org/abs/2307.12950" target="_blank" rel="noopener noreferrer">ICLR 2024 论文</a>，那边是 reinforcement learning from contrastive distillation，是不同的方法。检索时注意别混在一起。</p>
<h2 id="confidence-的真面目"><a class="anchor" href="#confidence-的真面目">confidence 的真面目</a></h2>
<p>那么 Jev 返回的数字诚实吗。回答之前先要看一件事。<strong>它返回的数字不止一个。</strong></p>
<p><picture><source type="image/webp" srcset="/content/260922/3.720w.webp 720w, /content/260922/3.1440w.webp 1440w, /content/260922/3.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/3.png" alt="图示：在 Jev 的响应里 probabilities 由训练生成，confidence 是把那个分布用算术折起来的值，而校准主张只挂在 probabilities 上" style="max-width:min(720px, 100%);height:auto;" width="1560" height="840" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p><code>Choice</code> 和 <code>Score</code> 的响应里同时装着 <code>probabilities</code> 和 <code>confidence</code>。前者是覆盖全部选项的概率分布，后者是 0 到 1 之间的一个数字。在代码里设阈值时，手先伸过去的是 <code>confidence</code>。</p>
<p>这个值不是模型造出来的。TypeSafe 组织公开的 <a href="https://github.com/typesafe-ai/system-one-adapter-python" target="_blank" rel="noopener noreferrer">system-one-adapter-python</a> 里，<code>_utils/confidence_metrics.py</code> 总共 32 行，<code>Choice</code> 的部分是这样。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="python" data-theme="github-dark github-light"><code data-language="python" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">def</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> choice_confidence</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(probs: list[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">float</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) -> </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">float</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">:</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">    """Scale peak choice probability from uniform to certainty."""</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> len</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(probs) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1.0</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    normalized_probs </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> _normalize(probs)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    uniform_probability </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1.0</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> /</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> len</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(normalized_probs)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">max</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(normalized_probs) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> uniform_probability) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1.0</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> uniform_probability)</span></span></code></pre></figure>
<p>没有模型调用，也没有学出来的参数。进去的是一个 <code>probabilities</code> 向量，出来的是一个实数。写成式子就是 <code>c = (p_max − 1/K) / (1 − 1/K)</code>，选项为三个、最大概率为 0.8 时得到 0.7。</p>
<p>这个仓库不是生产环境的 Jev 服务器，而是用 LLM 模仿同一套 API 的替代实现。所以只凭它还不能断言。不过还有两个指向同一个式子的证据。TypeSafe 官方文档的 <a href="https://docs.typesafe.ai/confidence" target="_blank" rel="noopener noreferrer">confidence 页面</a>把这个值写成 "a statistic computed from the probability distribution"，演示代码里则放了一条标注为三选项近似式的 <code>(3 × largest probability − 1) / 2</code>。K 为 3 时与上式相同。另外<a href="https://github.com/SamuelSacco/jev-exploration" target="_blank" rel="noopener noreferrer">主张审计账本</a>记录了在真实 API 上，只给两个都错的选项时，响应中胜者为 0.52，<code>confidence</code> 返回的是 0.04。K 为 2 时 <code>(0.52 − 0.5) / (1 − 0.5) = 0.04</code>。与式子吻合。</p>
<p>在同一个仓库里，<code>Score</code> 用的干脆是另一个式子。把到众数水平的平均绝对距离除以均匀分布下的同一个量，然后<strong>用 1 减去它</strong>。文档没有公开 <code>Score</code> 的式子。也就是说，<code>confidence</code> 这一个名字底下有两套算式，类型不同时同一个数字含义也不同。<code>Noul</code> 没有 <code>confidence</code> 也是同样的理由。没有可折叠的分布。</p>
<p><picture><source type="image/webp" srcset="/content/260922/4.720w.webp 720w, /content/260922/4.1440w.webp 1440w, /content/260922/4.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/4.png" alt="示意图：把 p_max 刻度上的 1/K 拉到 0、1 拉到 1，得到 confidence 刻度。三个选项时 0.80 落在 0.7，1/3 以下被丢弃，顺序不变" style="max-width:min(720px, 100%);height:auto;" width="1560" height="820" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>文档也没有藏着这一点。它甚至写明，想用别的计算方式的话会把整个 <code>probabilities</code> 给你，你自己处理。<strong>这是写在文档里的内容，问题在于不读就用的那一方。</strong></p>
<p>不过<a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev" target="_blank" rel="noopener noreferrer">公开发布文章</a>的对比表把两者并在一起写。上面写着 "Calibrated: higher confidence means higher accuracy"。读的人会理解为 <code>confidence</code> 是经过校准的值，而校准主张实际挂着的那一侧是 <code>probabilities</code>。</p>
<p>这个区分在数字上拉开多大，有测量可看。一位叫 <code>scienthoon</code> 的开发者公开的<a href="https://github.com/scienthoon/jev-ood-calibration" target="_blank" rel="noopener noreferrer">独立校准测量</a>，单独测了把 <code>confidence</code> 读作“答对的概率”时的 ECE。OpenBookQA 上是 0.035，HellaSwag 上是 0.078，合成集合上是 0.18。同一批集合的原始概率分别是 0.024、0.029、0.107。<strong>三个集合里 <code>confidence</code> 一侧都更差。</strong></p>
<p>为什么会变差，答案在式子里。K 固定时 <code>c</code> 是 <code>p_max</code> 的单调递增变换，顺序被保留。判别力原样不变，只要把阈值换算过去就行。<strong>坏掉的不是信息，是刻度。</strong> K 随问题变化时，就多出一个问题。拉伸的倍率也跟着变。同样是 <code>p_max</code> 0.8，两个选项时变成 0.6，五个选项时变成 0.75。在选项数混杂的工作负载里用单一阈值的那一刻，这就成了问题。</p>
<h2 id="随分布变化的校准"><a class="anchor" href="#随分布变化的校准">随分布变化的校准</a></h2>
<p>那么 <code>probabilities</code> 那一侧可以信吗。</p>
<p>实际调用 Jev 测过的记录是公开的。Jared Palmer 做的开源复现实现 <a href="https://github.com/jaredpalmer/kev" target="_blank" rel="noopener noreferrer">Kev</a> 的仓库里有 <code>runs/jev-*</code> directory，<code>usage.json</code> 以 <code>"model": "typesafe-ai/jev"</code> 记录着这是经由 Vercel AI Gateway 的真实调用。把六个 <code>jev-*</code> directory 的 <code>usage.json</code> 加起来，2026 年 9 月 18 日和 20 日两天共调用了 5,364 次，输入是 2,418,260 token。下面七个集合中的 <code>scienthoon</code> 是把那位开发者的结果文件转换后导入的，不在这个合计里。</p>
<p>价格确实只按输入计费这一点，可以在别处得到确认。钓鱼基准的作者看了 TypeSafe dashboard 记了下来。两次运行的 456 万 token 中，输入 366 万、输出 90 万，而账单是 $0.15，这个值与只按输入计算的结果吻合。</p>
<p>从同一个 directory 的 <code>report.json</code> 里取出七个评估集合的指标，整理出来是这样。下面的<strong>确信度不是 <code>confidence</code> 字段，而是 <code>probabilities</code> 的最大值</strong>。过度自信是 <code>평균 확신도 − 정확도</code>（平均确信度减准确率），共 5,743 条。删去判断依据的“无法判断”项，用的是将其排除在准确率评分之外的 <code>clean</code> 块的值。</p>
<table>
<thead>
<tr>
<th>评估集合</th>
<th>n</th>
<th>准确率</th>
<th>平均确信度</th>
<th>过度自信</th>
<th>ECE</th>
</tr>
</thead>
<tbody>
<tr>
<td>semif</td>
<td>144</td>
<td>0.965</td>
<td>0.965</td>
<td>-0.000</td>
<td>0.011</td>
</tr>
<tr>
<td>transfer-v9</td>
<td>1,046</td>
<td>0.854</td>
<td>0.880</td>
<td>+0.026</td>
<td>0.034</td>
</tr>
<tr>
<td>transfer-v4</td>
<td>656</td>
<td>0.857</td>
<td>0.903</td>
<td>+0.046</td>
<td>0.049</td>
</tr>
<tr>
<td>transfer-v2</td>
<td>560</td>
<td>0.855</td>
<td>0.900</td>
<td>+0.045</td>
<td>0.055</td>
</tr>
<tr>
<td>decision-v4</td>
<td>1,264</td>
<td>0.845</td>
<td>0.906</td>
<td>+0.061</td>
<td>0.067</td>
</tr>
<tr>
<td>decision-v2</td>
<td>1,200</td>
<td>0.833</td>
<td>0.904</td>
<td>+0.071</td>
<td>0.074</td>
</tr>
<tr>
<td>scienthoon</td>
<td>873</td>
<td>0.753</td>
<td>0.851</td>
<td>+0.099</td>
<td>0.105</td>
</tr>
</tbody>
</table>
<p>从中能读出两点。</p>
<p>第一，<strong>ECE 变动了将近十倍。</strong> 从 0.011 到 0.105。同一个评分器，同一个模型。“已校准”不是模型的性质，而是模型与分布之间的关系。分布一变校准就垮掉，这件事本身早已是已知的结果。这里新的地方在于，<strong>在把校准当作训练目标的模型上也出现了同样的形状</strong>。</p>
<p>ECE 最大的那个集合的 0.105，比前面看到的 post-RLHF GPT-4 的 0.074 还要大。不过不能把这两个读成排名。任务不同，而且 ECE 是一个会随着区间分成几份、样本挤在哪个区间而变动的估计量，把来自不同评分器的数字放在一起，比较并不成立。</p>
<p>第二，<strong>测得的 ECE 几乎全部由过度自信一项解释。</strong> 两列之差在七个集合上全都落在 0.002 到 0.011 之间。ECE 是各区间差距的加权平均，不可能小于整体的过度自信，而这个差几乎为 0，意味着<strong>各区间偏离的符号都朝一个方向倾斜</strong>。不是各区间互相交错抵消的噪声。</p>
<p>为什么会这样，竖着读就看得见。平均确信度除去 <code>semif</code> 之外，六个都挤在 0.851 到 0.906 之间。在同一段区间里，准确率却从 0.753 摆到 0.857，宽度是前者的两倍。<strong>确信度即使分布变了也几乎不动，动的只有准确率。剩下的差值就直接是 ECE。</strong></p>
<p><picture><source type="image/webp" srcset="/content/260922/5.720w.webp 720w, /content/260922/5.1440w.webp 1440w, /content/260922/5.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/5.png" alt="图表：Jev 实际调用的 7 个评估集合里，平均确信度集中在 0.851 到 0.906 之间，只有准确率从 0.753 到 0.965 移动，二者的差距与 ECE 几乎相等" style="max-width:min(720px, 100%);height:auto;" width="1560" height="920" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>这个性质在实务里怎么显现，另一个基准给出了答案。在 2,000 封钓鱼邮件上以 Claude Haiku 4.5 作对照组的<a href="https://github.com/anisselbd/jev-phishing-bench" target="_blank" rel="noopener noreferrer">测量</a>中，Jev 的准确率是 62.6%，Haiku 是 81.3%。不过更显眼的是 ECE。仓库报告的 ECE 是 Jev 0.154、Haiku 0.097，而主张审计账本把 Jev 的 <code>P(phishing)</code> 值在 0 到 1 上按 10 个区间重新测算，得到 0.170。无论按哪种读法，<strong>把校准当作训练目标的模型，在校准上都输给了用 RLHF 造出来的 LLM。</strong> 同一位作者只用一条链接主机列表规则就拿到了 91.6%。</p>
<p>把同一个指标放到多个集合上看，幅度就更清楚。<span class="glossary-term-source" data-glossary-key="error-budget">误差预算</span> 5% 下可自动处理的比例，在 <code>scienthoon</code> 集合是 0.486，<code>transfer-v9</code> 是 0.695，<code>semif</code> 是 1.000。Kev 的评分器注明这个值是在样本内选取阈值得到的最大值，并不是部署后的误差保证。<strong>不能把其中任何一个当作模型的规格来引用。</strong></p>
<h2 id="音译判别闸门"><a class="anchor" href="#音译判别闸门">音译判别闸门</a></h2>
<p>读别人的基准和在自己的数据上测，是两回事。所以笔者把开头那道闸门实际交给它过了一遍。</p>
<p>笔者还没有 Jev 的 API key。于是改成<strong>在本地跑了前面提到的 Kev-9B。</strong> 它是在 Qwen3.5-9B-Base 上加了 rank-16 <span class="glossary-term-source" data-glossary-key="lora">LoRA</span> 和 pointer head 的复现实现，采用 Apache-2.0。下面的数字不是 Jev 的数字。Kev 的训练数据是英语决策任务，在同一位作者的测量里，Kev-9B 即使在英语任务上也不如 Jev。误差预算 5% 的自动化比例是 0.45 到 0.57，而 Jev 是 0.70；MMLU-Pro 是 0.52 对 0.84。而且 TypeSafe 文档也就 Jev 说明了<a href="https://docs.typesafe.ai/models" target="_blank" rel="noopener noreferrer">英语是主要训练语言，CJK 并不对等</a>。</p>
<p>评估集合取自本仓库的 18 篇韩语文章。标签的正确答案标准是<strong>本仓库一贯把那个词写成哪种形式</strong>。也就是说，它不是韩语技术文档社区的共识，而是这个博客的惯例；在两者分歧的词上，模型可能按社区标准是对的，按这个标准却是错的。</p>
<ul>
<li><strong>闸门集合 102 句</strong>：是闸门已经认识的词。把正文里写成英文的 <code>calendar</code>、<code>picker</code>、<code>adapter</code> 之类的词改回韩文音译的<strong>反事实句子</strong>为阳性 23 句，<code>write-post.md</code> 明确列为例外的 <code>리렌더링</code>（re-rendering）或 <code>콜 스택</code>（call stack）实际出现的句子为阴性 79 句。在这里，现行的正则闸门按定义能答对 100%。</li>
<li><strong>保留集合 60 句</strong>：<strong>是闸门一次都没见过的词。</strong> 也就是机器学习里说的 <span class="glossary-term-source" data-glossary-key="held-out-set">held-out</span> 集合。 把正文里只写英文的 <code>loader</code>、<code>mutation</code>、<code>prefill</code> 改成音译的为阳性 30 句，正文里只写韩文的 <code>리듀서</code>（reducer）、<code>스냅샷</code>（snapshot）、<code>런타임</code>（runtime）为阴性 30 句。在这里，正则一个阳性都抓不到。</li>
</ul>
<p><picture><source type="image/webp" srcset="/content/260922/6.720w.webp 720w, /content/260922/6.1440w.webp 1440w, /content/260922/6.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/6.png" alt="示意图：闸门集合 102 句和保留集合 60 句的阳性、阴性构成，以及正则闸门完全覆盖闸门集合、却抓不到保留集合任何一个阳性" style="max-width:min(720px, 100%);height:auto;" width="1560" height="860" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>阳性是笔者造出来的反事实句子、阴性是真实句子，这个不对称先说明在前。而且<strong>有效样本不是句子数，而是词数。</strong> 标签是按词确定的，所以出现同一个词的那些句子并不是独立观测。闸门集合是 24 种，保留集合是 13 种。</p>
<p>下表只统计了问“这个词是否该还原成英文”的那一个 <code>noul</code> 问题。同一次请求里装了四个问题，但其余三个是否定式、<code>Choice</code> 变体，以及没能把标签立好的复合词判定，所以没有混进来。<strong>5% 预算自动化比例</strong>是指从确信度高的一侧往下切，使切点以上区间的误差不超过 5% 的最大比例。</p>
<table>
<thead>
<tr>
<th></th>
<th>闸门集合</th>
<th>保留集合</th>
</tr>
</thead>
<tbody>
<tr>
<td>句子 / 词</td>
<td>102 / 24 种</td>
<td>60 / 13 种</td>
</tr>
<tr>
<td>现行正则闸门</td>
<td><strong>1.000</strong></td>
<td>0.500</td>
</tr>
<tr>
<td>Kev-9B 准确率</td>
<td>0.225</td>
<td>0.500</td>
</tr>
<tr>
<td>按词的准确率</td>
<td>6/24</td>
<td>7/13</td>
</tr>
<tr>
<td>多数类基线</td>
<td>0.775</td>
<td>0.500</td>
</tr>
<tr>
<td>ECE</td>
<td>0.664</td>
<td>0.336</td>
</tr>
<tr>
<td>平均确信度</td>
<td>0.876</td>
<td>0.821</td>
</tr>
<tr>
<td>确信度 0.9 以上的比例</td>
<td>0.490</td>
<td>0.317</td>
</tr>
<tr>
<td>该区间的实际命中</td>
<td>0.240</td>
<td>0.526</td>
</tr>
<tr>
<td><strong>5% 预算自动化比例</strong></td>
<td><strong>0.010</strong></td>
<td><strong>0.000</strong></td>
</tr>
</tbody>
</table>
<p>多数类基线是不读句子、只押多数那一侧标签时的得分。模型在它之下。</p>
<p>最后一行就是这次实验的答案。<strong>在保留集合上，无论把阈值定得多高，能在 5% 误差内自动处理的决策一件都没有。</strong></p>
<p>看预测分布，就知道发生了什么。模型<strong>对全部 162 句都答了“该还原”。</strong> 37 种词全部如此。阳性全对，阴性全错。所以保留集合的 0.500 不是实力，而是因为集合本身平衡才出来的数字。</p>
<p>与此同时平均确信度是 0.876。在闸门集合上确信度超过 0.9 的占 49%，而那个区间的实际命中是 0.240。</p>
<p>结构性恒等式也没有成立。把“是否该还原”和“是否该保持原样”的概率相加，在闸门集合上平均得到 1.655，102 句全部偏离 1 超过 0.1。</p>
<p>设计上就是如此。两个问题是彼此读不到对方的独立评估，TypeSafe 自己也在 <a href="https://docs.typesafe.ai/model-jaggedness/jev-1.13" target="_blank" rel="noopener noreferrer">jaggedness 页面</a>上放了 <code>refund</code> 0.72 与 <code>not_refund</code> 0.47 相加得到 1.19 的例子。</p>
<p>实务上卡住的地方在这里。<strong>不能把按 <code>Noul</code> 调好的阈值搬到 <code>Choice</code> 上。</strong> 同一个判断而结论相左的句子占 14% 到 17%。</p>
<p>如果是问什么都答是的模型，这个结果就毫无意义。笔者先确认了这一点。</p>
<table>
<thead>
<tr>
<th>状态 / 问题</th>
<th>问了什么</th>
<th>答</th>
</tr>
</thead>
<tbody>
<tr>
<td>韩语 / 韩语</td>
<td>这个句子是用英语写的吗？</td>
<td>0.02</td>
</tr>
<tr>
<td>韩语 / 韩语</td>
<td>这个句子在讲菜谱吗？</td>
<td>0.04</td>
</tr>
<tr>
<td>韩语 / 韩语</td>
<td>这个句子是用韩语写的吗？</td>
<td>0.98</td>
</tr>
<tr>
<td>韩语 / 英语</td>
<td>Is this sentence written in English?</td>
<td>0.01</td>
</tr>
<tr>
<td>英语 / 英语</td>
<td>Is this sentence about software?</td>
<td>0.96</td>
</tr>
</tbody>
</table>
<p><strong>它读韩语，会说“不是”，并且把 0.02 和 0.98 分开。</strong> 这不是全局性的迎合偏差。能借此排除的也就到这里为止。闸门问题的措辞本身很差的可能性仍然留着。</p>
<h2 id="把规则放进-state-的结果"><a class="anchor" href="#把规则放进-state-的结果">把规则放进 state 的结果</a></h2>
<p>那么，把判别所需的知识直接给它会不会不一样呢。TypeSafe 文档推荐的领域适配方式就是这个。不碰权重，把参考资料装进 <code>state</code> 发过去。</p>
<p>笔者把 <code>write-post.md</code> 里已经写好的规则段落一起放进 <code>state</code>，重新跑了一遍。输入从每句 100 token 增加到了 450 token。<strong>闸门集合是<span class="glossary-term-source" data-glossary-key="data-leakage">泄漏</span>。</strong> 因为那份参考资料把闸门词和例外词逐个点名列了出来。所以那一侧是用来看“它到底读不读参考资料”的对照组，真正的考试是保留集合。</p>
<table>
<thead>
<tr>
<th></th>
<th>闸门（泄漏）</th>
<th>闸门 +规则</th>
<th>保留</th>
<th>保留 +规则</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>noul</code> 准确率</td>
<td>0.225</td>
<td>0.676</td>
<td><strong>0.500</strong></td>
<td><strong>0.500</strong></td>
</tr>
<tr>
<td>按词的准确率</td>
<td>6/24</td>
<td></td>
<td>7/13</td>
<td><strong>5/13</strong></td>
</tr>
<tr>
<td>阳性答对</td>
<td>23/23</td>
<td>1/23</td>
<td>30/30</td>
<td>8/30</td>
</tr>
<tr>
<td>阴性答对</td>
<td>0/79</td>
<td>68/79</td>
<td>0/30</td>
<td>22/30</td>
</tr>
<tr>
<td>5% 预算自动化比例</td>
<td>0.010</td>
<td>0.314</td>
<td>0.000</td>
<td>0.050</td>
</tr>
<tr>
<td>延迟中位数</td>
<td>2,924ms</td>
<td>6,903ms</td>
<td>2,908ms</td>
<td>6,918ms</td>
</tr>
</tbody>
</table>
<p>延迟是在 M2 Max 上跑的本地推理，和前面看到的 Jev 的 API 延迟不在同一个轴上。这里要看的不是绝对值，而是放进参考资料之后的 2.4 倍。</p>
<p><strong>在平衡的保留集合上，按句子的准确率从 0.500 到 0.500，完全没动。</strong> 按词来看反而从 7/13 降到了 5/13。</p>
<p><picture><source type="image/webp" srcset="/content/260922/7.720w.webp 720w, /content/260922/7.1440w.webp 1440w, /content/260922/7.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/7.png" alt="在闸门外的 60 句上，把规则段落放进 state 前后的对比。之前 30 句该还原的全对、30 句该保留的全错；之后分别对了 8 句和 22 句。答对总数两次都是 30" style="max-width:min(720px, 100%);height:auto;" width="1560" height="820" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>闸门集合从 0.225 到 0.676 并不是实力提升。之前它对 102 句全都说“还原”，给了参考资料之后只对 12 句这样回答。阴性从 0/79 升到了 68/79，但阳性从 23/23 崩到了 1/23。那个集合里阴性以 79 比 23 占多数，所以“大体上保持原样”这个常数自动就能拿到更高的分。<strong>它学到的不是区分，而是改变了倾斜。</strong></p>
<p>这并不意味着模型不读句子。同一个词的同一批句子，给了参考资料之后，预测在句子之间散开了。<code>로더</code>（loader）的标准差从 0.096 增加到 0.217，<code>뮤테이션</code>（mutation）从 0.042 增加到 0.236。<strong>读是读了，只是分不出来。</strong></p>
<p>而且这个结果也动摇了上一节的解读。如果仅凭一段 state 文本就能把输出整个翻过来，那么第一次运行时的“全都还原”，也可能不是模型的极限，而是问题措辞的不足。本文没能排除这个可能性。</p>
<p>有一点明显变好了。“还原”和“保持原样”的概率之和在保留集合上从 1.508 靠近到了 0.963，大幅偏离 1 的句子从 59 句减到了 18 句。<strong>但准确率没有动。</strong> 两个问题的概率彼此对得上，和那些概率本身是对的，是两回事。</p>
<h2 id="决策模型的位置"><a class="anchor" href="#决策模型的位置">决策模型的位置</a></h2>
<p>看到这里很容易倾向于得出这类模型整体没用的结论，但把公开的基准并排放在一起看，并非如此。</p>
<table>
<thead>
<tr>
<th>任务</th>
<th>n</th>
<th>Jev</th>
<th>比较对象</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="https://github.com/bitnovus/jev-spam-eval" target="_blank" rel="noopener noreferrer">垃圾邮件，分布内</a></td>
<td>18,514</td>
<td>98.3%</td>
<td>TF-IDF 回归 98.4%</td>
</tr>
<tr>
<td>垃圾邮件，分布外</td>
<td>2,876</td>
<td><strong>98.6%</strong></td>
<td>TF-IDF 回归 <strong>73.0%</strong></td>
</tr>
<tr>
<td>钓鱼</td>
<td>2,000</td>
<td>62.6%</td>
<td>Haiku 4.5 81.3%、规则基线 91.6%</td>
</tr>
<tr>
<td><a href="https://github.com/anessbelbati/jev-rerank-bench" target="_blank" rel="noopener noreferrer">rerank 英语 8 种</a></td>
<td>1,617</td>
<td>nDCG@10 0.692</td>
<td>Cohere Rerank 4 Pro 0.691</td>
</tr>
</tbody>
</table>
<p>只有 rerank 那一行是检索排序质量指标，与其他行的准确率不在同一个轴上。</p>
<p><strong>在分布内，正则或调好的分类器要么赢要么打平。</strong> 这和笔者的闸门集合上正则以 1.000 对 0.225 获胜是同一个位置。只要能收集到标签，训练一个小模型更便宜、更快、也更准。那是很早以前就在做的事。</p>
<p><strong>差距是在分布是新的、又没有标签的时候拉开的。</strong> <span class="glossary-term-source" data-glossary-key="out-of-distribution">分布外</span>的垃圾邮件上是 98.6% 对 73.0%。调好的分类器在自己没见过的形状面前垮掉，而这一侧撑住了。决策模型的位置，是<strong>无法收集数据、打上标签再训练的判断</strong>。每次情况都是新的所以收不到标签，判断却要以秒为单位做出的地方。</p>
<p>笔者的音译判别为什么失败，也能用这个框架解释。那个判断需要的不是一般性的推理，而是<strong>韩语技术文档里哪种说法已经定型这种特定领域的知识</strong>；对一个用英语决策任务训练出来的 9B 复现实现来说，那不是分布外，而是根本不存在的知识。把规则写成文字给它也不管用的原因就在这里。</p>
<p>在 TechCrunch 的报道里，做 Pi harness 的 Earendil 的 CTO Armin Ronacher <a href="https://techcrunch.com/2026/09/18/a-new-kind-of-ai-model-from-a-chatgpt-inventor-is-thrilling-developers/" target="_blank" rel="noopener noreferrer">这样说</a>。</p>
<blockquote>
<p>At the end of the day, it delegates the hallucination problem a little bit to the user.</p>
</blockquote>
<p>不是消除了幻觉，而是把判断交给了开发者。TypeSafe 文档也写着，校准是对一批预测才成立的性质，并不保证单个答案是对的；公开发布文章里幻觉率 0% 那根柱子下方的 Nuance 条目写着 "Our number is not empirical"。形式有保证，内容没有。</p>
<h2 id="jevable-上的-194-个项目"><a class="anchor" href="#jevable-上的-194-个项目">jevable 上的 194 个项目</a></h2>
<p>在笔者的数据上失败了，于是去看别人把它用在了哪里。<a href="https://jevable.com/" target="_blank" rel="noopener noreferrer">jevable.com</a> 是一位叫 Nikunj 的开发者把 X 上出现的 Jev 项目逐个审核后汇集起来的独立策展站。截至 2026 年 9 月 23 日共 194 个，登记日期集中在 9 月 16 日到 20 日之间。其中 152 个是在 9 月 18 日一天之内进来的。那是公开三天之后。</p>
<table>
<thead>
<tr>
<th>类别</th>
<th>数量</th>
</tr>
</thead>
<tbody>
<tr>
<td>Games</td>
<td>39</td>
</tr>
<tr>
<td>Developer tools</td>
<td>34</td>
</tr>
<tr>
<td>Productivity</td>
<td>31</td>
</tr>
<tr>
<td>Agents</td>
<td>19</td>
</tr>
<tr>
<td>Experiments</td>
<td>18</td>
</tr>
<tr>
<td>Creative tools</td>
<td>16</td>
</tr>
<tr>
<td>Data &#x26; research, Finance, Browser extensions, Robotics, Marketing</td>
<td>37</td>
</tr>
</tbody>
</table>
<p>不按类别，而按“问什么”重新分组，就得到三种形状。下面的数字都是发帖人自己写在帖子里的值。</p>
<p><strong>第一，分类与路由。</strong> 给 1,500 封邮件分类，给税务文件分类，19 秒花 $0.12 给 1,891 条竞品广告分类，对一个 PR diff 做 14 项 typed check，从 Android 通知里判别广告，2.9 秒给 26 张建筑图纸分类。和开头的音译闸门是同一种形状。正如前一节的表所说，这是标签每天都在累积的位置，时间一长，用自己的标签训练出来的小分类器会赢或者打平。Jev 的优势在于还没有标签的第一天。</p>
<p><strong>第二，行动选择循环。</strong> 接在 Browser Use 上，7 秒花 $0.0039 完成机票搜索的 browser agent；用语音操作 Mac 的 computer use；每当 Mario 死掉就把 VM fork 成四路、挑活下来那一路继续的游戏；每条消息都要为 3D 角色的嘴、眉毛、视线等十个部位做决定的表情引擎。每一步的 action space 都在变，所以收不到标签，而判断要压到秒以下。这正是分布外垃圾邮件上差距被拉开的那个位置。Games 以 39 个成为最大类别，笔者认为原因也在这里。游戏是错了也能回退的行动选择循环。</p>
<p><strong>第三，把概率直接展示给用户的 UI。</strong> 不给答案只给判定的 Ask Jev，用概率挑选下一个问题的分支表单 JevForm，用技术深度、戏剧性等六个滑块重新排列 Hacker News 首页的 Upweight。阈值不写进代码，而是由人来读概率，所以本文追究的校准要求在这里最低。</p>
<p>有一点很显眼。194 条说明文里写了成本的有 30 条，写了速度的有 53 条，而写了准确率或基准线的只有 7 条。说明文只收录原帖的开头部分，所以这是下限，但方向是明确的。快和便宜第一天就能知道，对不对却要测了才知道，而测的人很少。那 7 条里有一个是 <code>jevcal</code>。它说大家都在凭感觉挑阈值，于是做成一个工具：放进自己的数据和目标准确率，就返回阈值和可自动处理的比例。这正是把下一节要推荐的步骤原样做成了工具。</p>
<h2 id="画线的步骤"><a class="anchor" href="#画线的步骤">画线的步骤</a></h2>
<p>本文的结论是这个。<strong>不要把模型返回的概率当作规格来读，而要在自己的数据上亲手测量，再去划线。</strong></p>
<p>测量的步骤是这样。收集一百来条带标签的数据，做一张按概率区间划分的实际命中率表，然后把阈值从上往下降，看切点以上区间的错误率。定好误差预算之后，守得住这个预算的最大覆盖率就是可自动化的比例。笔者的实验里确信度 0.9 以上区间的实际命中是 0.240，这一点用 102 句就显露出来了。</p>
<p><strong>刻度偏了大多是可以修的。</strong> 过度自信均匀铺在所有区间上时，一个温度参数就能把大部分对齐。Kev 也是这样把 ECE 从 0.106 降到 0.042 的。问题在于要调那个温度就需要该分布的标签，而生产环境里缺的恰恰是这个。何况温度改变不了概率的顺序，所以可自动化的比例并不会跟着上去。因此比起刻度，顺序更重要；比起 ECE，误差预算覆盖率是更实用的指标。</p>
<p>如果把文档示例里用的 0.9 原样写进代码，这道闸门会悄悄改错 38 处。因为不报错，所以是难以察觉的失败。TypeSafe 文档也在那段示例的正下方写了要用自己的数据试一试。写进代码的，通常不是下面那句话，而是上面那个数字。</p>
<p>就算没有 key，至少测量这一步今天就能做。Kev 以 Apache-2.0 公开着，在笔记本上就能跑。</p>
<p>希望各位数一数，现在运营的服务里只向大模型问是/否的调用有多少个。在把那些调用搬到决策模型之前，建议先测一测搬过去之后该把线划在哪里。笔者守住了这个顺序，才能定下不去动那道闸门。拿到 Jev 的 key 之后打算把同一批集合再跑一遍，那时如果结论变了，也会写下来。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2411.15124" target="_blank" rel="noopener noreferrer">Lambert 等人，Tulu 3：RLVR 的命名</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/themsquared/jev-benchmark" target="_blank" rel="noopener noreferrer">themsquared/jev-benchmark，工具调用风险判定</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>校准</category>
        </item>
        <item>
            <title><![CDATA[overlay-kit]]></title>
            <link>https://hooninedev.com/zh-CN/260921</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/260921</guid>
            <pubDate>Mon, 21 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊聊以声明式方式处理 overlay 的接口。 笔者喜欢声明式的代码。打开 overlay、关闭它、在其中做异步处理，这一连串行为笔者认为应该由 overlay 自己管理。调用方持有 isOpen 并来回切换的做法，会把处理状态的代码和渲染该状态的组件放到两个不同的地方。 然而笔者在公司负责的页面，模态框恰恰相反。是否打开被放在全局 store 里，调用方打开，调用方关闭。连关闭模态框的...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊以声明式方式处理 overlay 的接口。</p>
<p>笔者喜欢声明式的代码。打开 overlay、关闭它、在其中做异步处理，这一连串行为笔者认为应该由 overlay 自己管理。调用方持有 <code>isOpen</code> 并来回切换的做法，会把处理状态的代码和渲染该状态的组件放到两个不同的地方。</p>
<p>然而笔者在公司负责的页面，模态框恰恰相反。是否打开被放在全局 store 里，调用方打开，调用方关闭。连关闭模态框的代码和接收结果的回调,都是调用方直接写的。所以笔者花了很长时间琢磨如何把它挪向声明式的方向，最后干脆重写了一遍。</p>
<p>做这项工作时笔者看了 <a href="https://github.com/toss/overlay-kit" target="_blank" rel="noopener noreferrer">overlay-kit</a>。它和笔者想要的接口很接近。本文记录 overlay-kit 的声明式接口，以及笔者如何设计公司内部的模态框系统。</p>
<h2 id="曾经用状态持有的模态框"><a class="anchor" href="#曾经用状态持有的模态框">曾经用状态持有的模态框</a></h2>
<p>笔者在关于<a href="/260518" target="_blank" rel="noopener noreferrer">状态管理</a>的文章里，把模态框的开关标志当作最简单的局部状态的例子。笔者写道，那是只在一个组件内部使用、外部既无需也无权知道的状态。这个看法现在依然成立。只是一旦模态框有了几十种、彼此层叠出现、还必须把结果值返回给调用方，这个状态就不会再留在组件内部了。</p>
<p>笔者接手的代码把打开的模态框列表放在全局 store 里。由于是公司代码，下面的例子只保留结构、替换了标识符。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">openPopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">closePopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useModalStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useShallow</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [state.openPopup, state.closePopup]),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> handleAddNote</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  openPopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(PopupType.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ADD_NOTE</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    itemId,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onClose</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> closePopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(PopupType.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ADD_NOTE</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">note</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      closePopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(PopupType.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ADD_NOTE</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      saveNote</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(note);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [closePopup, openPopup, itemId]);</span></span></code></pre></figure>
<p>这段代码让笔者别扭的地方有三处。</p>
<p><strong>第一，打开用的函数必须从 hook 里取出来。</strong> <code>openPopup</code> 和 <code>closePopup</code> 是要从 store 中选取的值，因此只能在组件内部拿到。这意味着打开模态框的代码走不出组件。想在用不了 hook 的地方打开模态框，比如拦截 API 响应的位置或路由守卫，就只能把那个函数当作参数一路传下去。</p>
<p><strong>第二，结果通过回调返回。</strong> 笔记是否保存了、用户是否直接关掉了，要通过 <code>onSubmit</code> 和 <code>onClose</code> 两条分支来接收。打开模态框之后要做的事都得写进回调里。如果需要接连打开两个模态框，回调里就会再套一层回调。</p>
<p><strong>第三，关闭的责任在调用方。</strong> <code>closePopup(PopupType.ADD_NOTE)</code> 是调用方来调的。模态框并不关闭自己，而是打开它的那一方记着它是哪一种模态框，然后去关。所以同一个常量会在一个函数里出现三次。</p>
<p>这三点的共同之处在于，打开一个模态框，调用方要写三种代码：打开的代码、接收结果的回调，以及关闭的代码。</p>
<p>如果说决定显示什么才是调用方的职责，那么这里调用方连之后的事情也一并扛下了。尤其是 <code>closePopup(PopupType.ADD_NOTE)</code> 用字符串常量指名要关闭的对象。写成别的模态框的常量也照样能通过类型检查，因为它们属于同一个枚举，结果关掉的是别的模态框。</p>
<h2 id="等待结果的接口"><a class="anchor" href="#等待结果的接口">等待结果的接口</a></h2>
<p>那么这三点中可以减掉哪一个呢。笔者得到的答案是，把打开模态框变成一次函数调用，并让这个函数返回一个携带结束结果的 Promise。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> result</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> openModalAsync</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'ADD_NOTE'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { itemId });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (result.reason </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'confirmed'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> saveNote</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(result.value);</span></span></code></pre></figure>
<p>变化有三点。打开用的函数从 hook 变成模块函数，因此可以在组件外调用。结果从回调参数变成返回值，因此后续处理留在了调用函数自身的流程里。而关闭这件事交给了模态框组件自己。</p>
<p>由于返回类型是 <code>Promise&#x3C;ModalResult&#x3C;V>></code>，模态框的结束被收拢成了一个值。</p>
<p>Promise 只会结束一次。对已经结束的 Promise 再次 resolve 不会发生任何事，所以结束的原因必须全部塞进这一个值里。于是笔者把 <code>reason</code> 分成了五种：用户确认了、用户明确取消了、没有值就关闭了、被同类型的新模态框替换了，或者因整体清理被强制结束了。若只用一个布尔值，"取消"和"整个页面被清理掉"就成了同一个值，而在需要善后的流程里必须区分这两者。</p>
<p>反过来，如果不结束它，调用方就会永远等下去。回调不调用也不会发生什么，但 <code>await</code> 会一直不返回。因此每当新增一条把模态框从画面上移除的路径，笔者都得确认那条路径是否也结束了 Promise。替换和强制结束各自拥有一个 <code>reason</code>，正是出于这个原因。</p>
<h2 id="overlay-kit-的调用处"><a class="anchor" href="#overlay-kit-的调用处">overlay-kit 的调用处</a></h2>
<p>toss 的 overlay-kit，调用处长这样。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> confirmed</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">openAsync</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ConfirmDialog</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    open</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{isOpen}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onConfirm</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onCancel</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span></code></pre></figure>
<p>笔者认为是好接口的地方有三处。</p>
<p><strong>打开的是什么，就写在调用处。</strong> 笔者做的那一版传的是 <code>'ADD_NOTE'</code> 这样的字符串 key。这个 key 对应哪个组件，由 registry 文件知道。那是一个只装着一个对象的文件，把字符串 key 和加载该模态框的 <code>import()</code> 函数配成一对。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> modalImporters</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  ADD_NOTE</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'.../add-note-modal'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  CONFIRM</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'.../confirm-modal'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 모달 종류만큼 이어진다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>overlay-kit 把 JSX 直接写在那里。读代码的人不必跳到那个文件，就知道会弹出什么。</p>
<p><strong>结果的类型在调用处确定。</strong> <code>openAsync&#x3C;boolean></code> 的类型参数既是 <code>close</code> 的参数类型，也是 <code>await</code> 的结果类型。不需要为每个模态框单独声明结果类型再登记到表里。</p>
<p><strong>关闭和移除是分开的。</strong> controller 接收的 props 有四个：<code>overlayId</code>、<code>isOpen</code>、<code>close</code>、<code>unmount</code>。<code>close</code> 把 <code>isOpen</code> 设为 <code>false</code>，组件原样保留。真正移除它的是 <code>unmount</code>。<a href="https://overlay-kit.slash.page/ko/docs/guides/introduction" target="_blank" rel="noopener noreferrer">官方文档关于打开和关闭 overlay 的说明</a>是这样解释的。</p>
<blockquote>
<p>之所以会有这样的差别，是因为使用 <code>close</code> 时，为了展示 overlay 关闭的动画，会把它继续保留在内存中。</p>
</blockquote>
<p>笔者做的那一版没有这个区分。一关闭就立刻从列表里移除。这也就意味着笔者把关闭动画交给了组件库，并且只处理了能这样做的页面。</p>
<h2 id="从-overlayopen-到渲染"><a class="anchor" href="#从-overlayopen-到渲染">从 overlay.open 到渲染</a></h2>
<p>接口合意之后，接下来好奇的就是内部。一个模块函数要在 React 树里画出组件，中间必须有什么东西把两边连起来。</p>
<h3 id="事件与-reducer"><a class="anchor" href="#事件与-reducer">事件与 reducer</a></h3>
<p><code>overlay.open</code> 不改变状态。它只发出一个事件。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/event.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> open</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">controller</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OverlayControllerComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">options</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OpenOverlayOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> overlayId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> options?.overlayId </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> randomId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> componentKey</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> randomId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> dispatchOpenEvent</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'open'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  dispatchOpenEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ controller, overlayId, componentKey });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> overlayId;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>接收方是 <code>OverlayProvider</code>。Provider 持有一个 <code>useReducer</code>，订阅事件并把 <code>ADD</code> 送进 reducer。这里要留意的是 <code>isOpen</code> 是以 <code>false</code> 进入的。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/context/provider/index.tsx</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">overlayState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">overlayDispatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useReducer</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(overlayReducer, {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  current: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  overlayOrderList: [],</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  overlayData: {},</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> open</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OverlayEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'open'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">controller</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">overlayId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">componentKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  overlayDispatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'ADD'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    overlay: { id: overlayId, componentKey, isOpen: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, isMounted: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, controller },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, []);</span></span></code></pre></figure>
<p>然后在 controller 挂载后的下一帧送入 <code>OPEN</code>，把 <code>isOpen</code> 变成 <code>true</code>。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/context/provider/content-overlay-controller.tsx</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  requestAnimationFrame</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    overlayDispatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'OPEN'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, overlayId });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [overlayDispatch, overlayId]);</span></span></code></pre></figure>
<p>这里有一处和笔者预想的不一样。</p>
<p><strong>状态并不在 React 之外。</strong> 因为是用模块函数打开的 API，笔者以为 store 也会在模块层级，实际上却是 Provider 内部的 <code>useReducer</code>。在 React 之外的只有事件总线，而它并不持有状态。overlay 被渲染为 Provider 的 <code>children</code> 旁边的兄弟节点。它不使用 <code>createPortal</code>，也不 import <code>react-dom</code>。</p>
<p>还有一处值得注意。从调用处看是 <code>overlay.open()</code> 一行，而其内部是一套顺序固定的流程：发出事件、把 <code>ADD</code> 送进 reducer、等一帧、再送入 <code>OPEN</code>。声明式的接口并没有消灭命令式的代码，而是把它挪到了边界的另一侧。</p>
<h3 id="打开的一侧与关闭的一侧"><a class="anchor" href="#打开的一侧与关闭的一侧">打开的一侧与关闭的一侧</a></h3>
<p>前面看到的 <code>requestAnimationFrame</code>，和上一节看到的 <code>close</code> 与 <code>unmount</code> 的分离，乍看是两回事。实际上它们是堵住同一个约束两端的两个装置。</p>
<p>这个约束就是：CSS 过渡只有在<strong>起始状态和结束状态都被真正渲染出来</strong>时才会执行。<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_transitions/Using_CSS_transitions" target="_blank" rel="noopener noreferrer">MDN 的 CSS 过渡文档</a>针对刚添加到 DOM 的元素给出了这样的警告。</p>
<blockquote>
<p>This is treated as if the initial state had never occurred and the element was always in its final state.</p>
</blockquote>
<p>失败在这里表现为两种形态。打开时因为没有起始状态而不执行，关闭时因为结束状态还没画出来元素就消失了而不执行。</p>
<table>
<thead>
<tr>
<th></th>
<th>打开的一侧</th>
<th>关闭的一侧</th>
</tr>
</thead>
<tbody>
<tr>
<td>问题</td>
<td>没有起始状态</td>
<td>在画出结束状态之前就消失</td>
</tr>
<tr>
<td>解法</td>
<td>先以关闭状态挂载，下一帧再打开</td>
<td>保持挂载，只关掉 <code>isOpen</code>，之后再移除</td>
</tr>
<tr>
<td>装置</td>
<td><code>ADD(isOpen: false)</code>、rAF、<code>OPEN</code></td>
<td><code>CLOSE</code>、动画、<code>REMOVE</code></td>
</tr>
<tr>
<td>知道时机的一方</td>
<td>库</td>
<td>使用者</td>
</tr>
</tbody>
</table>
<p>关闭的一侧之所以归使用者负责，原因就在最后一行。打开的一侧，库知道"一帧就够"这个常量。关闭的一侧，库无从知道动画是 200 毫秒还是 400 毫秒。</p>
<h4 id="以-unmount-交出的关闭"><a class="anchor" href="#以-unmount-交出的关闭">以 <code>unmount</code> 交出的关闭</a></h4>
<p>关闭的一侧能够成立，依据在 reducer 里。<code>CLOSE</code> 只翻转 <code>overlayData</code> 中的 <code>isOpen</code>，不碰 <code>overlayOrderList</code>。Provider 遍历那个列表来渲染，因此只要还留在列表里，组件就活着。只有 <code>REMOVE</code> 把它从列表中移除，才会真正卸载。</p>
<p><strong>不过 rAF 并非总是必要。</strong> react-transition-group <a href="https://reactcommunity.org/react-transition-group/transition" target="_blank" rel="noopener noreferrer">默认不会在首次挂载时执行 enter 过渡</a>。即使 <code>in</code> 已经是 <code>true</code> 也会跳过，必须开启 <code>appear</code>。overlay-kit 的 rAF 会在挂载之后把 <code>isOpen</code> 从 <code>false</code> 改为 <code>true</code>，正好覆盖了这种情况。</p>
<h3 id="用-promise-包起来的二十行"><a class="anchor" href="#用-promise-包起来的二十行">用 Promise 包起来的二十行</a></h3>
<p><code>openAsync</code> 并不是另一套实现，而是包住 <code>open</code> 的一层薄封装。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/event.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> openAsync</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">controller</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OverlayAsyncControllerComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">options</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OpenOverlayOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_resolve</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_reject</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    open</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">overlayProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">deprecatedLegacyContext</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> close</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">param</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">        _resolve</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(param);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        overlayProps.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> reject</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reason</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> unknown</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">        _reject</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(reason);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        overlayProps.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> props</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OverlayAsyncControllerProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlayProps, close, reject };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> controller</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(props, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">deprecatedLegacyContext);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }, options);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>它所做的全部事情，就是把传给 controller 的 <code>close</code> 换掉。原本的 <code>close</code> 不接收参数，这里则接收参数，用它 resolve Promise，然后再调用原来的 <code>close</code>。结果类型之所以在调用处确定，原因也在这里。<code>T</code> 既是 <code>close</code> 的参数类型，也是 Promise 的类型。</p>
<p>不过这个结构有一个漏洞。resolve 只在 <code>close</code> 被调用时发生。因此如果只用 <code>unmount</code> 移除 overlay，Promise 就会永远停留在 pending。这一点记录在 <a href="https://github.com/toss/overlay-kit/issues/169" target="_blank" rel="noopener noreferrer">issue #169</a> 里，笔者认为需要解决，但该 issue 目前仍然是打开状态。</p>
<p>resolve 发生在 <code>close</code> 这一点还有另一层含义。那是关闭开始的时刻，而不是关闭动画结束的时刻。如果流程是拿到确认后调用 API 就没问题，但如果是拿到确认后打开下一个模态框，那么前一个还在消失的过程中，后一个就已经升起来了。</p>
<h3 id="设计绕了一圈的地方"><a class="anchor" href="#设计绕了一圈的地方">设计绕了一圈的地方</a></h3>
<p>为了探究设计的过程，笔者顺着提交记录看了一遍，发现这个结构并非一开始就是如此。1.0 用的是和现在相同的事件加 reducer 的组合。1.2.0 把状态挪到模块层级的外部 store，改用 <code>useSyncExternalStore</code> 读取，而 1.8.0 又退回了 reducer。</p>
<p>退回的理由写在 <a href="https://github.com/toss/overlay-kit/issues/148" target="_blank" rel="noopener noreferrer">issue #148</a> 里。</p>
<blockquote>
<p><code>useSyncExternalStore</code> resolves tearing issues, but it doesn't work well with suspense.</p>
</blockquote>
<p>在 overlay 内部取数据导致 suspend 时，React 会警告说组件在响应同步输入的过程中 suspend 了。挪到外部 store 的本意是让人不用 hook 也能读到 overlay 的状态，而当时的 PR 正文里写明了这个目标并没有按预期达成。最终目标是靠 hook 解决的，只有存储方式回到了原处。</p>
<p>笔者从这段记录里得到的不是结论，而是一条判断标准。把状态放到 React 之外，调用位置会变自由，代价是留出了与 React 调度错位的余地。overlay-kit 只把调用位置放到了外面，状态留在了里面。</p>
<h2 id="用同一个-id-再次打开时"><a class="anchor" href="#用同一个-id-再次打开时">用同一个 id 再次打开时</a></h2>
<p>读 reducer 的时候有一处卡住了笔者。<code>ADD</code> 的最前面有这样一个分支。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/context/reducer.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">case</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'ADD'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (state.overlayData[</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">action</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">overlay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] != </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; state.overlayData[</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">action</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">overlay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">].isOpen === </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> overlay</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state.overlayData[action.overlay.id];</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (overlay </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> overlay.isOpen) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      ...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">state,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      current: action.overlay.id,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      overlayData: { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">state.overlayData, [action.overlay.id]: { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay, isOpen: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span></code></pre></figure>
<p>指定 <code>overlayId</code> 打开一个 overlay，<code>close</code> 之后再用同一个 id 打开，就会走进这个分支。但返回值原样沿用了<strong>既有的 <code>overlay</code> 对象</strong>。</p>
<p>新传进来的 <code>action.overlay</code> 的 <code>controller</code> 哪里都没有被用到。</p>
<p>只靠阅读还不能确信，于是笔者把库的仓库拉下来，自己加了测试。准备两个渲染不同内容的组件，用同一个 id 依次打开。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ControllerA</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isOpen </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> data-testid</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"b"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>AAA&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ControllerB</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isOpen </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> data-testid</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"b"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>BBB&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">open</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(ControllerA, { overlayId: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'id-close'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'id-close'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">open</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(ControllerB, { overlayId: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'id-close'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span></code></pre></figure>
<p>结果如下。在 overlay-kit 1.9.0 时点的 main 分支上用 vitest 执行。</p>
<table>
<thead>
<tr>
<th>步骤</th>
<th>渲染出来的内容</th>
</tr>
</thead>
<tbody>
<tr>
<td>打开 A、<code>close</code>、再用 B 打开</td>
<td><code>AAA</code>。B 被丢弃</td>
</tr>
<tr>
<td>打开 A、<code>unmount</code>、再用 B 打开</td>
<td><code>BBB</code>。正常替换</td>
</tr>
<tr>
<td>把计数器加到 1、<code>close</code>、再打开</td>
<td><code>1</code>。组件状态被保留</td>
</tr>
</tbody>
</table>
<p>第三行是文档作为功能来说明的行为，所以和预期一致。问题在第一行。</p>
<h3 id="被忽略的新-jsx"><a class="anchor" href="#被忽略的新-jsx">被忽略的新 JSX</a></h3>
<p>笔者在这里又确认了一件事。同一个 reducer 的后半部分有这样一段注释。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/**</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"> * </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">@description</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"> Brings the overlay to the front when reopened after closing without unmounting.</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"> */</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">overlayOrderList</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">state.overlayOrderList.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">item</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> item </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> action.overlay.id), action.overlay.id],</span></span></code></pre></figure>
<p>意思是关闭之后再次打开会把 overlay 带到最前面。可是因为前面的分支会先 <code>return</code>，这一行在那种情况下根本不会执行。笔者把两个 overlay 叠着打开，对压在下面的那个执行 <code>close</code> 再重新打开，DOM 顺序并没有变化。</p>
<h3 id="与注释不符的实际行为"><a class="anchor" href="#与注释不符的实际行为">与注释不符的实际行为</a></h3>
<p>不过笔者没有确认这在画面上是否会立刻表现为问题。测试只看到 DOM 顺序为止，而大多数对话框库都使用自己的 z-index 或 portal，所以 DOM 顺序并不等同于视觉顺序。</p>
<p>笔者做的那一版把同样的情况作为选项暴露出来。再次打开同一种模态框时，由调用方选择是保留既有的还是用新的 props 替换。选择替换的话，既有的 Promise 会以 <code>replaced</code> 结束。前面把结果类型分成五种的原因之一就是这个。</p>
<p><strong>两种设计的差异在这里显现出来。</strong> 笔者的做法把模态框当作数据。字符串 key 和 props 都在 store 里，所以之后可以替换、可以更新。overlay-kit 把模态框当作函数。controller 是一个闭包，一旦存下来，就没有办法从外部改变它的内容。</p>
<h2 id="在调用处写-jsx-的代价"><a class="anchor" href="#在调用处写-jsx-的代价">在调用处写 JSX 的代价</a></h2>
<p>那么，在调用处写 JSX 的方式是不是总比字符串 key 的方式更好呢。笔者认为并非如此。要付出的代价有三个。</p>
<p><strong>代码分割从默认值里掉了出去。</strong> 调用处写 JSX，意味着调用处 import 了那个组件。如果模态框是一个沉重的编辑页面，而按下那个按钮的用户只是少数，那份重量就会进到列表页面的 bundle 里。</p>
<p><code>React.lazy</code> 在 controller 内部是可以工作的。笔者用和前面测试相同的方式量了一下：<code>open</code> 之前模块没有被加载，打开时 <code>Suspense</code> 的 fallback 先出现，然后主体才渲染出来。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> Heavy</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">lazy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'./HeavyModal'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">open</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> isOpen </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Spinner</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />}>&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Heavy</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClose</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{close} />&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>所以差别不在能不能，而在默认值是什么。</p>
<p>笔者做的那一版，registry 文件里的值全都是 <code>import()</code> 函数，因此代码分割在结构上是被强制的。overlay-kit 则需要在每个调用处手写。</p>
<p><strong>调用 <code>unmount</code> 的位置要由使用者决定。</strong> 只调用 <code>close</code> 就收手的话，overlay 的信息会留在内存里。官方文档也警告了这一点，并针对每个设计系统分别说明该把 <code>unmount</code> 挂在哪里。MUI 和 Mantine 有过渡结束的回调，挂在那里即可，而对于没有这类回调的库，文档建议按动画时长设置一个定时器。指引文档要按库分开写，这件事本身就是这个决定的成本。</p>
<p><strong>overlay 在 Provider 所在的位置渲染。</strong> 因为不使用 portal，同一份代码也能在 React Native 上运行，这是优点。反过来说，在 <code>OverlayProvider</code> 之下提供的 context，在 overlay 内部是看不到的。打开依赖路由或表单 context 的模态框时，需要先确认这一点。</p>
<h2 id="声明式这个词的范围"><a class="anchor" href="#声明式这个词的范围">声明式这个词的范围</a></h2>
<p>写到这里，有一件事让笔者在意。笔者是以"声明式"来称呼这个接口开篇的，可 <code>overlay.openAsync(...)</code> 怎么看都是一次函数调用。</p>
<p>先从定义说起。<a href="https://react.dev/learn/reacting-to-input-with-state" target="_blank" rel="noopener noreferrer">React 官方文档</a>是这样划分两者的。</p>
<blockquote>
<p>In React, you don't directly manipulate the UI--meaning you don't enable, disable, show, or hide components directly. Instead, you <strong>declare what you want to show,</strong> and React figures out how to update the UI.</p>
</blockquote>
<p>同一份文档举的比喻更鲜明。命令式是给开车的人一个路口一个路口地报路，声明式则是坐上出租车只说目的地。</p>
<p><strong>声明式的接口并不是消灭命令式的流程，而是把它挪到边界的另一侧，并给那条边界起个名字。</strong> 前面看到的 overlay-kit 内部正是如此。调用处只有一行，而内部跑着发出事件、两次 dispatch 的流程。</p>
<p>那么来看各个项目把这个词用在哪里。overlay-kit 的文档把以往的方式称为命令式。它所指的是用 <code>useState</code> 持有开关标志、在事件处理器里翻转那个值的代码。相反，它称为声明式的，是把 overlay 当作行为而非状态来处理的那一侧。toss 技术博客的<a href="https://toss.tech/article/frontend-declarative-code" target="_blank" rel="noopener noreferrer">关于声明式代码的文章</a>把这个词定义得更宽：声明式的代码就是抽象层级更高的代码，而把显示 overlay 这一行为抽象出来的 hook 就是一个例子。</p>
<p>于是笔者决定把这个词收窄成这样。<strong>调用方不写关闭的代码。</strong> 什么时候关、关闭过程中留下什么，由 overlay 一侧决定；调用方只写显示什么，以及期望拿回什么结果。</p>
<p>按这个标准来看，用 <code>useState</code> 持有 <code>isOpen</code> 的代码，和把打开的列表放在全局 store、由调用方来关闭的代码，站在同一边。只是存放位置不同，关闭的语句由调用方来写这一点是一样的。</p>
<h2 id="结语"><a class="anchor" href="#结语">结语</a></h2>
<p>笔者在公司代码里改掉的，归根结底并不是存放位置。而是关闭的代码由谁来写。</p>
<p>即便在使用全局 store 的时期，状态也已经在组件之外了，但打开和关闭的语句依然留在调用处。换成一行 <code>await</code> 之后，那些语句才进到模态框内部。</p>
<p>overlay-kit 抵达了同一个位置之后又往前走了一步：把显示什么也写在调用处。笔者认为那个接口读起来很好。而在写这篇文章的过程中，笔者也弄明白了这一步的代价是什么。</p>
<p><strong>那是把模态框当作函数还是当作数据的差别。</strong> 在调用处写 JSX，controller 就成了闭包，而闭包一旦被存下来，其内容就无法从外部改变。用同一个 id 再次打开时新的 JSX 被忽略，正是从这个结构里来的。用字符串 key 来处理，模态框就变成了装在 store 里的值，可以替换、可以更新、可以代码分割，但调用方只会知道一个名字。</p>
<p>笔者认为，在挑选库的时候，"喜欢这个接口"的感觉是个相当可靠的信号。只是那种感觉究竟指向什么，至少该用语言表述一次。不表述出来，就分不清自己喜欢的是接口本身，还是使用这个库的人们的口碑。也想建议读到这里的各位，针对现在正在用的库做一次这样的梳理。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://overlay-kit.slash.page/" target="_blank" rel="noopener noreferrer">overlay-kit 官方文档</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/desko27/react-call" target="_blank" rel="noopener noreferrer">desko27/react-call</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>前端</category>
            <category>React</category>
            <category>库</category>
        </item>
        <item>
            <title><![CDATA[LLM 的校准与过度自信]]></title>
            <link>https://hooninedev.com/zh-CN/260917</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/260917</guid>
            <pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊聊模型的校准，以及 RLHF 之后出现的过度自信。本文写给想把模型返回的概率或确信度当作代码中判断标准的开发者。读完之后，你可以说明准确率和校准有什么不同、ECE 衡量的是什么、用人类偏好打磨过的模型给出的概率如何偏离实际命中率，以及这背后的原因目前弄清到了什么程度。 笔者在验证 TypeSafe AI 公开的决策模型 Jev 的说法时，必须先把这个概念理清楚。Jev 不返回句子，而是...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊模型的校准，以及 RLHF 之后出现的过度自信。本文写给想把模型返回的概率或确信度当作代码中判断标准的开发者。读完之后，你可以说明准确率和校准有什么不同、ECE 衡量的是什么、用人类偏好打磨过的模型给出的概率如何偏离实际命中率，以及这背后的原因目前弄清到了什么程度。</p>
<p>笔者在验证 TypeSafe AI 公开的决策模型 Jev 的说法时，必须先把这个概念理清楚。Jev 不返回句子，而是为每个选项返回概率，并把校准作为训练目标。要检验这个说法，得先知道校准是什么，以及它在现有模型中为什么会偏。</p>
<h2 id="准确率与校准"><a class="anchor" href="#准确率与校准">准确率与校准</a></h2>
<p>首先必须区分准确率和<span class="glossary-term-source" data-glossary-key="calibration">校准</span>。准确率是能答对百分之几，校准是知不知道自己能答对百分之几。把说过降水概率 70% 的那些天汇总起来，如果实际十次里下了七次雨，那这个预报就是校准良好的。它不代表准确率高。它代表预报知道自己的极限。<strong>只有 60% 正确率的模型，只要自己说 60%，校准就是满分。</strong></p>
<p>刻度偏离的方向有两个。模型说出的概率高于实际命中率，叫作 overconfidence（过度自信）；低于实际命中率，叫作 underconfidence。把说出的概率放在横轴，把该概率区间的实际命中率放在纵轴，两者在哪里分开就一目了然。这样画出的图叫作 reliability diagram。</p>
<p><picture><source type="image/webp" srcset="/content/260917/1.720w.webp 720w, /content/260917/1.1440w.webp 1440w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260917/1.png" alt="横轴是模型说出的概率 confidence，纵轴是实际命中率 accuracy。虚线对角线是 perfect calibration，垂到对角线下方的橙色曲线是 overconfidence，对角线上方的区域是 underconfidence" style="max-width:min(720px, 100%);height:auto;" width="1440" height="1200" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>曲线贴着对角线，说明模型说多少就答对多少。越往对角线下方垂，相对于它说出的概率，答对得就越少。</p>
<p>衡量这种偏离的指标是 <span class="glossary-term-source" data-glossary-key="ece">ECE</span>（expected calibration error）。按照 <a href="https://arxiv.org/abs/1706.04599" target="_blank" rel="noopener noreferrer">Guo et al. 2017</a> 的定义，它把每个概率区间里“说出口的概率”与“实际命中率”的差值，按该区间的样本比例加权平均，0 为完美。下面的代码把这个公式写了出来。它比较一个 10 道题答对 6 道的模型，在每次都说 0.6 和每次都说 0.95 时的情况。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// Guo et al. 2017 의 식 (3). 구간은 등간격 10개(논문 실험은 15개), 경계는 왼쪽 닫힘</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ece</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">preds</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">M</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> bins</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Array.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ length: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">M</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ n: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, hit: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, conf: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }))</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">correct</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">of</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> preds) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> bins[Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">min</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">M</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(p </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> M</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">))]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    b.n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    b.hit </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> correct </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    b.conf </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> p</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> bins.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reduce</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">sum</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (b.n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> sum </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (b.n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> preds.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">abs</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(b.hit </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.conf </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.n) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> sum),</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 10문제 중 6문제를 맞힌다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> answers</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Array.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ length: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">i</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ correct: i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 6</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }))</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">ece</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(answers.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">a, p: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.6</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }))).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toFixed</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 매번 0.6 이라고 말할 때</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">ece</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(answers.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">a, p: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.95</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }))).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toFixed</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 매번 0.95 라고 말할 때</span></span></code></pre></figure>
<p>这是 2026-10-08 用 Node v24.16.0 运行的结果。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>0.000</span></span>
<span data-line=""><span>0.350</span></span></code></pre></figure>
<p>两种情况的准确率都是 0.6。只是说出的概率变了，ECE 就从 0 升到 0.35。不过公式里取了绝对值，所以 ECE 只衡量偏离的大小，不知道方向。每次都说 0.25、同样答对 6 道题的模型，ECE 也恰好是 0.35。究竟是 overconfidence 还是 underconfidence，要看 reliability diagram，或者平均确信度减去准确率的值才知道。</p>
<h2 id="rlhf-的目标函数"><a class="anchor" href="#rlhf-的目标函数">RLHF 的目标函数</a></h2>
<p>那么，用人的反馈打磨过的模型，为什么这个刻度会偏呢。首先要看 <span class="glossary-term-source" data-glossary-key="rlhf">RLHF</span>（reinforcement learning from human feedback）在优化什么。用人的偏好比较来建立奖励模型这一骨架出自 <a href="https://arxiv.org/abs/1706.03741" target="_blank" rel="noopener noreferrer">Christiano et al. 2017</a>，把它搬到语言模型上的早期工作是 <a href="https://arxiv.org/abs/1909.08593" target="_blank" rel="noopener noreferrer">Ziegler et al. 2019</a>，<a href="https://arxiv.org/abs/2203.02155" target="_blank" rel="noopener noreferrer">InstructGPT</a> 又把它用于指令跟随。这时被推高的值，是<strong>模仿人类偏好的奖励模型的分数</strong>。语言模型方面的工作会在奖励上加 KL penalty，让模型不要离开始强化学习之前的模型太远，InstructGPT 还混入了预训练数据的梯度（PPO-ptx），但推高的对象仍然是那个分数。</p>
<p>对聊天机器人来说，人的偏好是对的目标。问题在于这种偏好偏向回避流露不确定性的语气。<a href="https://arxiv.org/abs/2401.06730" target="_blank" rel="noopener noreferrer">Zhou et al. 2024</a> 在四个公开偏好数据集上表明，标注者较少选择含有 "I'm not sure, maybe" 这类弱化表达的回答。差异很小，但显著。不过，他们并没有更多地选择含有强调确信的表达的回答。<a href="https://arxiv.org/abs/2410.09724" target="_blank" rel="noopener noreferrer">Leng et al. 2025</a> 表明，奖励模型不管回答的实际质量如何，都会给写出高确信度分数的回答更高的分数。两者都不是关于 token 概率，而是关于文字中的确信表达（verbalized confidence）的结果。推出替代 RLHF 的训练方法的 TypeSafe，也在自家的<a href="https://docs.typesafe.ai/introduction/machine-learning-primer" target="_blank" rel="noopener noreferrer">入门文档</a>中写道，RLHF 可能会奖励听起来很有把握的幻觉（confident-sounding hallucinations）。这是推销替代方案的一方的立场。</p>
<h2 id="gpt-4-的校准曲线"><a class="anchor" href="#gpt-4-的校准曲线">GPT-4 的校准曲线</a></h2>
<p>在 token 概率上也观察到了同一方向的偏离。<a href="https://arxiv.org/pdf/2303.08774v6#page=12" target="_blank" rel="noopener noreferrer">GPT-4 技术报告</a>正文的 Figure 8 把预训练模型和 post-training 模型的 reliability diagram 并排放在一起，图注是这样写的。</p>
<blockquote>
<p>Right: Calibration plot of the post-trained GPT-4 model on the same subset of MMLU. The post-training hurts calibration significantly.</p>
</blockquote>
<p>横轴是模型给 MMLU 选择题的 A、B、C、D 每个选项分配的概率（logprob），纵轴是该区间的实际命中率。按 Figure 8 上标出的数字，预训练模型的 ECE 是 <strong>0.007</strong>，经过 PPO（RLHF 中朝着提高奖励模型分数的方向更新模型时使用的强化学习算法）的模型是 <strong>0.074</strong>。恶化了十倍以上。</p>
<p>在 Figure 8 右侧（PPO）的面板中，0.4 以上区间的柱子在对角线下方，但 0.1 到 0.3 之间的区间反而在对角线上方。0.1 到 0.9 之间的柱子平平地挤在实际命中率约 0.3 到 0.5，所以模型说出的概率升高了，命中率却几乎跟不上。0.074 里也混进了这些 underconfidence 的区间。而且正如 Guo et al. 指出的，reliability diagram 不显示每个区间有多少样本，所以不能认为柱子离对角线的距离和该区间对 ECE 贡献的大小成比例。</p>
<p>这块面板和上面的概念图有两点不同。首先，它不像概念图里的曲线那样只往对角线下方垂，而是穿过对角线一次。其次，它把四个选项的概率全都放进了区间，所以也和上面代码那样只用所选答案一个概率来衡量的定义不同。所以笔者不把 0.074 和上面代码的 0.35 放在同一把尺子上比较。</p>
<p>那么为什么会变差。报告正文只写了预训练模型校准得很好、post-training 之后校准下降，没有说原因。线索在 Anthropic 的 <a href="https://arxiv.org/abs/2207.05221" target="_blank" rel="noopener noreferrer">Kadavath et al. 2022</a> 里。他们用自家语言模型训练出的 RLHF 策略，表面上看校准非常差。论文说这并不意外，因为 RL fine-tuning 往往会把预测往获得最多奖励的行为方向收拢。这不是论文的主题，而是顺带做的一个简短实验。然而，对所有评估都使用同一个温度 T=2.5 之后，三项评估中的校准问题大体上都解决了。温度是用来除 logit 的值，大于 1 时它保持答案的顺序不变，只把概率分布摊平。笔者把一个值就大体解决了问题这一结果，理解为偏离并不集中在某些区间，而是整个分布都往一边收窄了。论文也附上了一句保留：更强的 RL 训练可能会以这种方式无法修复的形式破坏校准。</p>
<p>这是 Anthropic 模型上的结果，所以不能直接搬来当作 Figure 8 的原因。前一节的研究处理的也是文字中的确信表达。笔者没能找到把“人回避不确定的语气”这一结果和 token 概率的偏离直接连起来的一手证据。</p>
<h2 id="代码拿到的概率"><a class="anchor" href="#代码拿到的概率">代码拿到的概率</a></h2>
<p>Figure 8 里的概率是 token 的对数概率。要拿到同一类值，按照 <a href="https://github.com/openai/openai-openapi/blob/506aff0a8099581b50e119b87f8f2692cdad043f/openapi.yaml" target="_blank" rel="noopener noreferrer">OpenAI API 规范</a>，在 Chat Completions 请求里把 <code>logprobs</code> 设为 <code>true</code>，再用 <code>top_logprobs</code> 在 0 到 20 之间决定每个 token 位置返回多少个候选。让模型在回答的同时用数字说出确信度，这样拿到的值是用文字说出的确信，和 token 概率是不同的值。</p>
<p>研究这两种值的论文比较的是不同的组合。<a href="https://arxiv.org/abs/2305.14975" target="_blank" rel="noopener noreferrer">Tian et al. 2023</a> 报告说，在 ChatGPT、GPT-4、Claude 这样的 RLHF 模型内部，用文字说出的确信通常比条件概率校准得更好，在三个基准上常常把 ECE 相对降低约 50%。不过在这项比较中，权重未公开的模型的条件概率不是 API 的 token 概率，而是把同一个问题采样 10 次、按得出该答案的比例估计的值。前面提到的 Leng et al. 则报告说，与 RLHF 之前的模型相比，RLHF 模型在用文字说出的确信上更 overconfident。两个结果可以同时成立。而且 0.074 是 2023 年 GPT-4 post-training 模型在 MMLU 子集上得出的值，不能原样套用到你现在通过 API 调用的模型上。</p>
<h2 id="总结"><a class="anchor" href="#总结">总结</a></h2>
<p>总结一下，校准不是能答对百分之几的问题，而是知不知道自己能答对百分之几的问题，ECE 是衡量这种偏离大小的数字。方向要看 reliability diagram，或者平均确信度减去准确率的值才知道。RLHF 推高模仿人类偏好的奖励模型的分数。Kadavath et al. 认为，这种优化往往会把预测往获得更多奖励的方向收窄。有研究表明，人较少选择流露不确定性的回答，而奖励模型不论质量如何，都会给写出高确信度分数的回答更高的分数。在 GPT-4 上，post-training 之后 ECE 从 0.007 变成了 0.074，但 OpenAI 并没有写出原因。代码拿到的概率是 token 概率还是文字中说出的确信，校准结果也会不同，所以不管是哪一种，都要在自己的数据上重新测一遍。</p>
<p>把校准作为训练目标的决策模型 Jev 的概率是否真的诚实，将在<a href="/260922" target="_blank" rel="noopener noreferrer">决策模型，Jev 与 Kev</a>中讨论。</p>
<p>也希望读到这里的各位，在把模型返回的概率作为阈值写进代码之前，至少测一次：这个模型是以什么为目标训练的，在自己的数据上刻度准不准。</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>校准</category>
        </item>
        <item>
            <title><![CDATA[共享语言]]></title>
            <link>https://hooninedev.com/zh-CN/260723</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/260723</guid>
            <pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊聊与其他职能的沟通。 做开发时，我花在弄清楚该做什么上的时间，和写代码的时间一样多。与产品经理来回确认需求，与设计师对齐界面，向懂领域的人询问某个术语究竟指什么。而这个过程一旦错位，代价远大于把代码写错的代价，这一点我已经经历过好几次。 最近我读了 James Samuel 写的工程领导者的日常工作。作者把领导者的工作分成六项，最先讨论的就是信息收集。理由是所有的决策、方向和行动，都依...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊与其他职能的沟通。</p>
<p>做开发时，我花在弄清楚该做什么上的时间，和写代码的时间一样多。与产品经理来回确认需求，与设计师对齐界面，向懂领域的人询问某个术语究竟指什么。而这个过程一旦错位，代价远大于把代码写错的代价，这一点我已经经历过好几次。</p>
<p>最近我读了 James Samuel 写的<a href="https://softwareleads.substack.com/p/engineering-leaders-day-to-day-activities" target="_blank" rel="noopener noreferrer">工程领导者的日常工作</a>。作者把领导者的工作分成六项，最先讨论的就是信息收集。理由是所有的决策、方向和行动，都依赖于对当下正在发生什么的准确理解。</p>
<p><picture><source type="image/webp" srcset="/content/260723/1.720w.webp 720w, /content/260723/1.1440w.webp 1440w, /content/260723/1.1951w.webp 1951w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260723/1.png" alt="用六个相连的圆表示的管理活动：收集信息、规划、领导、影响决策、做决策、共享信息" width="1951" height="1673" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>我确实希望将来能担任领导角色，但读到这一段之后浮现的想法有些不同。<strong>如果领导者最先处理的工作是信息收集，那么对一线执行者而言，与之对应的就是需求的梳理</strong>。我现在处理需求的方式，日后就会成为我处理组织信息的方式。</p>
<p>这篇文章并不想谈领导力。它谈的是我作为一线执行者与其他职能沟通时，究竟做错了什么，又能把什么当作工具。先说结论：我逐渐认为这不只是态度问题。有一个可以替代的说法叫 ubiquitous language，我们来看看它是什么。</p>
<h2 id="同一个词不同的含义"><a class="anchor" href="#同一个词不同的含义">同一个词，不同的含义</a></h2>
<p>Martin Fowler 在<a href="https://martinfowler.com/bliki/BoundedContext.html" target="_blank" rel="noopener noreferrer">Bounded Context</a>一文中举了电力公司的例子。我遇到的错位，相当一部分正是这种形态。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>在这里，“meter”这个词在组织的不同部分含义有微妙差别：它指的是电网与某个地点的连接，还是电网与某位客户的连接，抑或是物理电表本身？</p></div><div class="quote-original" lang="en"><p>here the word 'meter' meant subtly different things to different parts of the organization: was it the connection between the grid and a location, the grid and a customer, the physical meter itself</p></div></blockquote>
<p>同一个“meter”，在组织的不同部门里指的是微妙不同的东西。Fowler 还补充说，这种混乱并非那个案例的偶然。他说自己反复见到 Customer、Product 这类多义词引发同样的混乱。</p>
<p><strong>这并不是因为与会者不擅长开会</strong>。只要组织内部存在不同的语境，同一个词分化就是自然的事；放任不管，最终连代码也会跟着分化。</p>
<p>这个话题的技术背景，我在<a href="/260418" target="_blank" rel="noopener noreferrer">领域模型</a>里讨论过一次。那时谈的是如何在代码中表达模型。而本文要谈的是那个模型形成之前，人与人之间对话的阶段。</p>
<h2 id="有两种含义的词"><a class="anchor" href="#有两种含义的词">有两种含义的词</a></h2>
<p>那么，这种事为什么会结构性地反复出现呢？有两条解释路径。</p>
<p>一条来自组织结构。Mel Conway 于 1968 年发表在 Datamation 上的论文<a href="https://melconway.com/Home/Committees_Paper.html" target="_blank" rel="noopener noreferrer">How Do Committees Invent?</a>指出，组织无法灵活改变自身沟通结构的程度有多深，它就会把自己的形象刻印到所产出的一切设计物上。通常被引用为康威定律的那一句话，其实是作者日后自己整理出的表述，而我觉得 1968 年原文中的这段描述更为直接。</p>
<p>我从这句话里读出的实务含义，是“刻印”这个说法。因为它意味着术语的分化不会止于对话，而是<strong>原封不动地留在产出物里</strong>。前面的“meter”就是如此。如果各方对“meter”的范围理解不同却各自动手去做，这个差异不会留在会议纪要里，而会留在表名、API 响应字段和界面文案里。而从那一刻起，修改的成本就与再谈一次的成本不可同日而语了。</p>
<p>一线执行者能做的和不能做的，在这里分开。改变组织的沟通结构超出了执行者的能力范围。但<strong>让术语的边界变得可见</strong>，是当下就能做的事。也就是写清楚本文档中某个词的含义范围，若与其他团队使用的词不同，就明确指出不同。后面要看的工具，全都是让这件事变容易的装置。</p>
<p>另一条则更为根本。从认知科学的角度看，协作本来就只能建立在共享的背景之上。Herbert Clark 与 Susan Brennan 于 1991 年撰写的<a href="https://web.stanford.edu/~clark/1990s/Clark,%20H.H.%20_%20Brennan,%20S.E.%20_Grounding%20in%20communication_%201991.pdf" target="_blank" rel="noopener noreferrer">Grounding in Communication</a>以钢琴二重奏开篇。两位演奏者若不预设大量的共享信息，也就是 common ground，甚至无法开始对齐内容。这里的 common ground 指的是相互知识、相互信念与相互假设。作者还明确指出，一切集体行动都建立在 common ground 及其累积之上。</p>
<p>把这两条并排看过之后，我得到的视角是这样的。<strong>术语出现偏差不是事故，而是默认状态</strong>。对齐的状态反而是需要付出维护成本的例外。那么由谁、以什么方式支付这份成本，就成了实务上的问题。</p>
<h2 id="积极监视"><a class="anchor" href="#积极监视">积极监视</a></h2>
<p>Evans 在 2003 年出版的 Domain-Driven Design 中提出了 ubiquitous language，也就是<strong>共享语言</strong>这一模式。它常被简化为“让 developer 和领域专家使用相同的术语”，但读原书会发现处方要具体得多。他要求团队把模型作为语言的骨架，并约定在团队内的一切沟通和代码中坚持使用这套语言。而紧随其后的那句话，对我而言最为重要。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>领域专家会对那些别扭或不足以传达领域理解的术语与结构提出异议，而 developer 则留意那些会让设计栽跟头的歧义或不一致。</p></div><div class="quote-original" lang="en"><p>Domain experts object to terms or structures that are awkward or inadequate to convey domain understanding, while developers watch for ambiguity or inconsistency that will trip up design.</p></div></blockquote>
<p>领域专家对不足以传达领域理解的别扭术语与结构提出异议，而 <strong>developer 则监视那些会推倒设计的歧义或不一致</strong>。</p>
<p>读到这句话后，我修正了对自身角色的理解。在此之前，我在需求会议上把自己视为接收者。我以为产品那边定下来之后，准确接收并实现就是我的工作。可 Evans 界定的 developer 职责并不是接收。<strong>而是发现歧义并把它交还回去</strong>。这不是消极配合，而是积极监视。</p>
<p>（顺带一提，Fowler 在自己的文章中把这句话写成了“Domain experts <strong>should</strong> object ... developers <strong>should</strong> watch”。原书里并没有情态动词。差别虽小，但原书读起来更为断然。那语气不是说这样做比较好，而是说这样做就是这个模式的定义。）</p>
<p>那么歧义该怎么发现呢？Evans 对此也给了答案：在对话中反复使用，术语理解上的差异就会显现。我是这样读这句话的。歧义不是靠精读文档找出来的。无论把需求文档读得多仔细，“meter”这个词具有多种含义的事实都不会浮现。只有把这个词放到实际案例上反复使用，它才会分化。像“其他人把 meter 理解成什么意思？”这样的问题冒出来的瞬间，就是那个分岔点。</p>
<h2 id="最小协作努力原理"><a class="anchor" href="#最小协作努力原理">最小协作努力原理</a></h2>
<p>我们可能知道这个方法，却仍然无法实践。那么，为什么不反复去问呢？</p>
<p>让我们再回到 Clark 与 Brennan 的论文。作者把“将说出口的内容变成 common ground 的一部分”这一过程命名为 grounding。并针对人们在这个过程中的行为提出了一条原理：最小协作努力原理（the principle of least collaborative effort）。这条原理源自一个观察，即人不喜欢比必要程度更卖力地工作。</p>
<p>关键的段落在这里。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>根据最小协作努力原理，人们应当以所需的最少共同努力去完成 grounding。但什么事情耗费努力，会随沟通媒介发生剧烈变化。</p></div><div class="quote-original" lang="en"><p>By the principle of least collaborative effort, people should try to ground with as little combined effort as needed. But what takes effort changes dramatically with the communication medium.</p></div></blockquote>
<p><strong>耗费努力的地方会随媒介发生剧烈变化</strong>。在某种媒介中可用的确认手段，在另一种媒介中可能根本不可行，即便可行成本也高得多。作者尤其指出，在对方无法即时接收的媒介中，指望他人来纠正误解的成本会变得非常高，于是说话的人就会设法回避。</p>
<p>这里有一点要事先说明。这篇论文写于 1991 年，作者比较的媒介是面对面对话、电话、书信和电话答录机之类。<strong>它并不涉及 Slack 或 Notion</strong>。grounding 成本随媒介变化这个框架是作者的，而把它套用到今天的异步工作频道上，是我的解读。</p>
<p>这样套用之后，就能解释我为什么没有反复发问了。在以文本接收需求文档、又以文本确认的环境里，每遇到一处含糊就发一条确认消息，成本很高。你不知道答复什么时候来，还担心问了好几次会显得像是没听懂。于是人自然而然就滑向了自己的解读。</p>
<p>这里需要区分两股力量。一股是媒介抬高了确认成本，这是 Clark 与 Brennan 说的。另一股是担心问多了显得无能，这并非他们的原理所预测的，而属于后文心理安全感那一侧的话题。准确地说，最小协作努力原理预测的是<strong>会挑一种更便宜的确认方式</strong>，而不是不再确认。收起确认转向猜测，不是原理在起作用，而是 grounding 的失败。而那个解读错了的事实，要等到实现做完之后才会显露。</p>
<h2 id="达到足够的水平"><a class="anchor" href="#达到足够的水平">达到足够的水平</a></h2>
<p>那么每一处含糊都得去问吗？这不现实，我也没见过这么做的人。对于这个问题，论文同样给出了标准，就是 grounding criterion。它指的是双方彼此相信，听者已经<strong>在足以支撑当前目的的水平上</strong>理解了说话者的意思。作者在此之前还补了一句：完美的理解本来就不可能。</p>
<p>这个标准让我觉得实用的原因，是它把目标调低了。不需要完美地理解需求。<strong>只要在足以支撑眼下要做的事的程度上彼此相信就够了</strong>。作者还说，目的一旦改变，标准也必须随之改变。</p>
<p>这一点在实务中会成为调节确认力度的依据。在还在探索方向的场合，抓着“这里的 meter 是什么意思？”不放，对话就推进不下去。那个阶段需要的是关于要做什么、为什么做的共识，而不是边界值的确定。反过来，在进入实现之前，这个问题就必须提出来。此时双方必须能够相信彼此对“meter”的范围想的是同一件事，否则代码就会堆在错误的前提之上。</p>
<p>所以，即便遇到同样的含糊，<strong>我也会根据当下处于哪个阶段而区别对待</strong>。探索阶段就记在清单上先往前走，临近实现时再打开那份清单逐条关闭。推迟确认和放弃确认是两回事。</p>
<p>在这个环节，有一个很适合搭配使用的工具。Matthew Skelton 与 Manuel Pais 的 Team Topologies 把团队之间的互动分为三种模式：在约定的期间内共同发现新事物的 collaboration，一方提供、一方消费的 X-as-a-Service，以及一方帮助并辅导另一方的 facilitation。</p>
<p>它原本讲的是组织设计，但我觉得把它缩小到单场会议来用很实用。<strong>如果双方对当下这场对话属于哪种模式的认知不一致，会议就会错位</strong>。产品经理认为这是传达既定结论的场合，而 developer 认为这是共同发现的场合，那么 developer 的提问听起来就不像协作，而像抬杠。反过来也有。如果 developer 是来接收已确定的规格，而产品经理还在探索阶段，developer 就会因为拿不到规格而着急。</p>
<p>所以我最近会在会议开头先确认这一点：“这件事还是开放的，还是我们在确认已经定下来的内容？”这一个问题，就能改变之后整场对话的性质。这和把 grounding criterion 按目的做调整是同一回事。</p>
<h2 id="分辨率对不上对话就会空转"><a class="anchor" href="#分辨率对不上对话就会空转">分辨率对不上，对话就会空转</a></h2>
<p>即便对齐了模式，仍有问题残留。我经常遇到的另一种错位，是双方在用不同的<strong>分辨率</strong>说话。</p>
<p>产品经理用文字给出的需求，大多太过抽象。像“让用户能方便地查看预约记录”这样的句子，看不出有几个界面，也看不出从哪里跳到哪里。反过来，设计师给的稿子又太过具体。连按钮颜色和留白都定好了，反倒难以讨论“这个流程对不对”。</p>
<p>Basecamp 的 Ryan Singer 所写的 Shape Up 精准地点出了这个问题：一上来就从线框图或具体的视觉布局入手，就会被不必要的细节困住，无法按需要的广度去探索。因此<a href="https://basecamp.com/shapeup/1.3-chapter-04" target="_blank" rel="noopener noreferrer">Shape Up 提出的方案</a>是介于两者之间的表达。它被称为 breadboarding，概念借自电气工程。面包板是一种具备实机全部元件与接线、却没有工业设计的原型。所以要画的东西也正好三样：可以跳转到的位置（places）、用户可以操作的东西（affordances），以及展示该操作把用户带往何处的连接线（connection lines）。</p>
<p>我看好这个技法的原因，是它是 developer 做得出来的产出物。不必请产品经理写得更详细，也不必等设计师出稿，就可以当场把自己理解的流程画出来，反问一句“我是这么理解的，对吗”。这可以说是执行 Evans 所说的监视（把歧义交还回去）的具体方法。而从上一节的视角看，这是一个降低 grounding 成本的装置。一张图，抵得上用文字来回确认好几轮。</p>
<p>不过这个分辨率比想象中难以维持。画着流程，自然而然就会滑到“这里的按钮放右下角是不是更好”之类的讨论上。我自己也这样过几次。一旦这样滑下去，当场正在讨论的问题，也就是这个流程对不对，就悄悄消失了。流程尚未确定却在敲定按钮位置，等到之后流程一变，那些讨论就会被整块丢掉。</p>
<p>所以在这个阶段，我会刻意维持<strong>一张画得不好的图</strong>。如果只有方框、箭头和名称，对方也就不会想去挑细节，只会针对流程做出反应。图的完成度低，在这个工具里反而是一项功能。（要按住想画得漂亮的心思，比想象中难）</p>
<p>抽象层级这个概念本身可以成为协作工具，这一点与我在<a href="/260201" target="_blank" rel="noopener noreferrer">抽象化</a>里谈过的内容相通。那时说的是代码的抽象层级，而对话里也有同样的东西。</p>
<h2 id="把规则示例和问题分开"><a class="anchor" href="#把规则示例和问题分开">把规则、示例和问题分开</a></h2>
<p>对齐分辨率的另一种方法，是把讨论本身结构化。</p>
<p>来看看 Cucumber 项目负责人 Matt Wynne 在 2015 年介绍的<a href="https://cucumber.io/blog/bdd/example-mapping-introduction/" target="_blank" rel="noopener noreferrer">Example Mapping</a>。他把许多团队觉得需求讨论困难的原因，诊断为缺乏结构导致耗时又无聊。于是团队既不会定期做，也不会一致地做。我正是如此。需求会议一旦拖长，下次就想快点结束，而草草结束，含糊之处就原样留了下来。</p>
<p>Example Mapping 用四种颜色的卡片来划分讨论。黄色是当下讨论的 story，蓝色是规则或验收标准，绿色是说明该规则的具体示例，而红色是不知道答案的问题。</p>
<p>其中，<strong>我认为红色卡片才是核心</strong>。其余三种是整理技法，而红色卡片是把“不知道”变成会议正式产出物的装置。说出自己不知道，不再是拖慢会议的行为，而成了会议本该产出的结果。</p>
<p>这为什么重要，只要想想反面情形就很清楚。在没有红色卡片的会议上遇到含糊之处，选项只有两个：现在纠缠下去把会议拉长，或者先跳过之后自己一个人去解读。正如上一节所见，人大多会选后者。可只要多了一张卡片，就出现了第三个选项。<strong>记下来往前走，同时不让它消失</strong>。会议继续推进，而含糊之处并未被抹去，仍留在清单上。</p>
<p>绿色卡片也起着类似的作用。只写规则时大家都会点头，可一旦有人要写出说明该规则的具体示例，解读就会分开。这可以说是把 Evans 所说的“在对话中反复使用就会显现差异”搬进了会议形式。如果要求每条规则配三个示例，其中总有一个会冒出“咦？这种情况怎么算？”。（以我的经验，那个问题没有出现的会议，不是大家都理解了的会议，而是各自理解得不一样的会议）</p>
<p>类似思路下，BDD 圈子里长期使用的 Three Amigos 也是如此。它主张业务、开发与测试这三种视角各自抛出不同的问题，而<a href="https://www.agilealliance.org/glossary/three-amigos/" target="_blank" rel="noopener noreferrer">Agile Alliance 术语表</a>把这些问题整理为：想解决什么问题，要如何构建解决该问题的方案，以及这个怎么样、可能会发生什么。第三个问题始终不出现的会议会变成什么样，大概不必特意说明。</p>
<h2 id="建立确认未知事项的流程"><a class="anchor" href="#建立确认未知事项的流程">建立确认未知事项的流程</a></h2>
<p>可是，我们为什么难以打出红色卡片呢？说到这里，就绕不开心理安全感的话题。</p>
<p>Amy Edmondson 于 1999 年发表在 Administrative Science Quarterly 上的<a href="https://web.mit.edu/curhan/www/docs/Articles/15341_Readings/Group_Performance/Edmondson%20Psychological%20safety.pdf" target="_blank" rel="noopener noreferrer">论文</a>，是正式确立团队心理安全感这一概念的研究。（论文本身把 1965 年 Schein 与 Bennis 的研究列为源头）它把团队心理安全感定义为一种共享信念，即团队是一个可以承担人际风险的安全场所。说自己不知道、提出反对意见、承认失误，都属于此列。论文调查了一家制造业公司的 51 个团队，结果显示心理安全感与学习行为相关，而这种学习行为在心理安全感与团队绩效之间起中介作用。</p>
<p>不过，这篇论文里让我觉得最实用的句子并不是定义，而是紧随其后的那一句。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>多数情况下，这种信念往往是隐性的：被视为理所当然，个人和整个团队都不会对它给予直接关注。</p></div><div class="quote-original" lang="en"><p>For the most part, this belief tends to be tacit—taken for granted and not given direct attention either by individuals or by the team as a whole.</p></div></blockquote>
<p>也就是说，这种信念大多是<strong>隐性的</strong>，个人和团队都不会对它给予直接关注。</p>
<p>从这里开始是我的推测。论文说到的是这种信念是隐性的，至于“所以做个表格就好”这样的处方，论文里并没有。不过我见过好几次，隐性的东西不会因为宣告而改变。说一句“我们团队是可以问自己不懂的事情的团队”，往往改变不了什么。所以我认为上一节的红色卡片很重要。<strong>把“不知道”变成表格里的一个格子，填写它就从勇气变成了流程。</strong></p>
<p>当然，反驳是成立的。在不安全的团队里，红色卡片的格子恐怕就是空着。这个指摘没错，我也无意主张这个工具能创造出安全感。只是，格子存在的话，至少<strong>空着这件事会变得可见</strong>。这就有了一个可以发问的位置：是因为没人有疑问才空着，还是因为难以开口才空着。</p>
<p><picture><source type="image/webp" srcset="/content/260723/3.720w.webp 720w, /content/260723/3.1440w.webp 1440w, /content/260723/3.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260723/3.jpg" alt="DDD 社区的 Bounded Context Canvas 模板，分为目的、战略分类、通用语言、假设、验证指标与开放问题等栏位" width="1680" height="1100" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>同样的想法也已经进入了实际的文档格式。看看 DDD 社区制作的<a href="https://github.com/ddd-crew/bounded-context-canvas" target="_blank" rel="noopener noreferrer">Bounded Context Canvas</a>，它是一个用于设计并记录单个上下文的协作工具，格子的构成很有意思。名称与目的、战略分类、领域角色、入站与出站沟通，然后是 <strong>Ubiquitous Language</strong>、业务决策、<strong>Assumptions</strong>、验证指标，以及 <strong>Open Questions</strong>。</p>
<p>写共享语言的格子、写假设的格子、写开放问题的格子各自独立。画布的说明写道，书写目的这个行为本身就会迫使模糊的想法被清晰地表达出来，并把整个团队放到同一页上。</p>
<p>我认为这个思路是面对沟通问题最现实的路径。与其试图改变人的态度，不如<strong>为那些难以说出口的事情做一个格子</strong>。好的结构不是要求人做得好，而是让人容易做好。</p>
<h2 id="这为什么不是偏好问题"><a class="anchor" href="#这为什么不是偏好问题">这为什么不是偏好问题</a></h2>
<p>读到这里，可能会有这样的反应：说得挺好，可归根结底不就是“好好沟通”吗？那不是性格问题吗？</p>
<p>我也曾这样想过一阵子。但这部分是有数据的。</p>
<p>DORA 在讨论组织文化时，引用了社会学家 Ron Westrum 的分类：权力导向的 pathological、规则导向的 bureaucratic，以及绩效导向的 generative。而<a href="https://dora.dev/capabilities/generative-organizational-culture/" target="_blank" rel="noopener noreferrer">DORA 的官方文档</a>是这样总结自身研究结果的。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>高信任并重视信息流动的组织文化，能够预测软件交付绩效。</p></div><div class="quote-original" lang="en"><p>organizational culture that is high-trust and emphasizes information flow is predictive of software delivery performance</p></div></blockquote>
<p>高信任且<strong>重视信息流动</strong>的组织文化能够预测软件交付绩效。这说的不是沟通顺畅的团队心情好，而是它是一个与绩效同向变动的变量。</p>
<p>不过，这里的“预测”不能读作因果。DORA 的数据来自同一批受访者同时回答文化与绩效的问卷，是组织层面的相关性。对组织满意的人两边都给出宽松评分的可能性依然存在，而且从组织层面的关系，也无法直接推出个人该如何行动的处方。所以凭这份材料能说的仅止于此：信息流动并不只存在于偏好的领域，它处在一个与绩效同向变动的位置上。</p>
<p>同一份文档还整理了 Westrum 所说的好信息的三个性质：能回答接收者需要的问题、时机恰当，以及以接收者能有效使用的方式呈现。值得留意的是，三者的标准都落在<strong>接收者</strong>身上。共享得多，并不等于信息流动得好。</p>
<p>Google 的 Project Aristotle 也是常被引用的资料。根据<a href="https://rework.withgoogle.com/intl/en/guides/understand-team-effectiveness" target="_blank" rel="noopener noreferrer">reWork 上公开的内容</a>，他们调查了 180 个团队，对数百个变量跑了 35 个以上的统计模型，提出影响团队效能的五个因素：心理安全感、可靠性、结构与清晰度、意义，以及影响力。而结论是，比起团队里有谁，这个团队如何协作更为重要。</p>
<p>不过有一点我想说明。官方页面写明这五个因素是按重要性排序的，并把心理安全感放在最前面。但引用这项研究的文章中常见的“压倒性第一”这类关于量级的表述，那个页面上并没有。存在顺序，和压倒其余，是两回事。所以本文不主张量级。若要论述心理安全感的重要性，依托上一节 Edmondson 的原论文才是准确的做法。</p>
<h2 id="再回到领导者的话题"><a class="anchor" href="#再回到领导者的话题">再回到领导者的话题</a></h2>
<p>回到开头引用的那篇文章。</p>
<p>James Samuel 在最先讨论信息收集时说，一线执行者时期的方法会失效。作为个人贡献者时，你握有自己工作的全貌，可一旦背负起对人的责任，就无法再沿用过去的方法。因此他把过滤噪声、将信息综合成一幅连贯的现实图景的能力列为必需能力，因为没有哪个管理者能处理一切。</p>
<p>我觉得这与前面看到的 Westrum 关于好信息的三个性质是同一回事。重点不在于收集得多，而在于把它变成可用的形态。而这与我现在处理需求时所做的事并无二致。从需求文档的句子中挑出含糊之处，用 breadboard 重画一遍来确认，把不知道的事情留作红色卡片，这正是过滤噪声、构建连贯图景的练习。</p>
<p>作者关于决策留下的话也让我印象深刻：等待确定性本身也是一种决策，并且伴随着成本。等到需求完全明确为止，同样如此。所以，比起完美的理解，“足以支撑当前目的”这一 grounding criterion 才是实用的标准。</p>
<h2 id="结语"><a class="anchor" href="#结语">结语</a></h2>
<p>总结一下。</p>
<p>与其他职能的沟通失败，大多不只是态度问题。只要组织内部存在不同的语境，同一个词分化就是默认状态，而对齐的状态反而是需要付出成本去维持的例外。支付这份成本的方式就是共享语言，而在其中，developer 的职责不是把需求听写下来，而是监视歧义并把它交还回去。</p>
<p>可是交还回去是要花力气的，而人总想把这份力气降到最低。所以我决定不依赖意志，而是使用三样东西：先确认这场对话属于哪种模式；用介于文字与设计稿之间的分辨率把流程画出来并交还回去；以及在会议的表格里预留一个写下未知的格子。这三样都不要求人改正态度，而是让正确的做法更容易做到。</p>
<p>我还不是领导者，写在这篇文章里的东西也没有在那个位置上验证过。团队规模或组织文化不同，总会有行不通的地方。不过有一点，我现在似乎可以确信。日后处理信息的方式不是重新学来的，而是当下处理需求的习惯原样长大的形态。所以我最近尽量不再像从前那样，羞于在会议上问一句“那是什么意思”。</p>
<p>如果读到这里的你，能想起最近某场会议上自己点了头、其实并不确定的某个词，那么我建议你下次把那个词再用一次。反复使用，它就会显现。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://martinfowler.com/bliki/UbiquitousLanguage.html" target="_blank" rel="noopener noreferrer">Martin Fowler，Ubiquitous Language</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://www.domainlanguage.com/ddd/reference/" target="_blank" rel="noopener noreferrer">Eric Evans，Domain-Driven Design Reference</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://teamtopologies.com/key-concepts" target="_blank" rel="noopener noreferrer">Team Topologies，Key Concepts</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://basecamp.com/shapeup/1.2-chapter-03" target="_blank" rel="noopener noreferrer">Basecamp，Shape Up: Set Boundaries</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.eventstorming.com/book/" target="_blank" rel="noopener noreferrer">Alberto Brandolini，EventStorming</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://domainstorytelling.org/" target="_blank" rel="noopener noreferrer">Stefan Hofer，Henning Schwentner，Domain Storytelling</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://gojko.net/books/specification-by-example/" target="_blank" rel="noopener noreferrer">Gojko Adzic，Specification by Example</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>协作</category>
            <category>领域</category>
            <category>DDD</category>
            <category>沟通</category>
        </item>
        <item>
            <title><![CDATA[Harness（系统）工程]]></title>
            <link>https://hooninedev.com/zh-CN/260622</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/260622</guid>
            <pubDate>Mon, 22 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想谈谈 prompt engineering、context engineering，以及再往后的方向。 在整理上一篇关于token 节省方法的文章时，有一个问题始终萦绕在我脑海中。我认为，重心正在从逐个雕琢 prompt，转向去除无关信息并筛选所需内容的技术（context engineering）。文章写完后，下一个问题自然浮现：那么，context 之后是什么？ 讨论“未来方向”需要...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想谈谈 prompt engineering、context engineering，以及再往后的方向。</p>
<p>在整理上一篇关于<a href="/260611" target="_blank" rel="noopener noreferrer">token 节省方法</a>的文章时，有一个问题始终萦绕在我脑海中。我认为，重心正在从逐个雕琢 prompt，转向去除无关信息并筛选所需内容的技术（context engineering）。文章写完后，下一个问题自然浮现：那么，context 之后是什么？</p>
<p><img src="/content/260622/3.webp" alt="由 prompt、context 与 harness engineering 构成的可靠 AI 系统 3 层结构" width="1680" height="920" loading="eager" fetchpriority="high" decoding="async"></p>
<p>讨论“未来方向”需要谨慎。未来可能有无数种选择，因此本文只专注于梳理<strong>从已经公开的一手资料中能够读出的重心转移</strong>。其中难免包含一些推论，也希望读者能从不同角度看待本文。</p>
<h2 id="从-prompt-到-context"><a class="anchor" href="#从-prompt-到-context">从 prompt 到 context</a></h2>
<p>先厘清术语。曾有一段时间，业界的核心话题是 <strong>prompt engineering</strong>。问题在于如何写好发给模型的一次性指令，也就是如何设计清晰的指示、恰当的示例和输出格式。</p>
<p>后来，工作的单位变大了。随着运行数十个轮次的 agent 日益普及，重要的不再是一次 prompt，而是如何组织<strong>模型在每个轮次看到的完整 context</strong>（system prompt + 工具定义 + 对话记录 + 搜索结果 + 记忆）。这就是 <strong>context engineering</strong>。Anthropic 在 2025 年 9 月的“Effective context engineering for AI agents”一文中梳理了这一框架；同年 Chroma 研究团队（Hong et al.）发表的 context rot 研究又提供了定量依据。他们测试了 GPT-4.1、Claude 4、Gemini 2.5、Qwen3 等 18 个模型，发现即使只是原样抄写单词这样的简单任务，输入越长，性能也会出现不均匀的下降。模型会同等处理第 100 个 token 和第 10,000 个 token 这一常见假设，在现实中并不成立。结论不是“context 越长越好”，而是“信息如何摆放，与其中包含什么同样重要”。这进一步推动了从填满 context 向筛选 context 的转变，也让这个术语迅速普及。</p>
<p>这里需要指出一个常见误解。prompt engineering 并没有被 context engineering <strong>取代</strong>。写好 prompt 依然是基础，而 context engineering 更接近建立在其上的上位概念。（关注点从写好代码转向设计好系统，并不意味着 coding 不再必要。）所以更准确的表述不是“换轨”，而是“<strong>在包含原有概念的同时扩大了范围</strong>”。</p>
<p>那么再问一次：这种扩展止步于 context 了吗？看起来并没有。</p>
<h2 id="anthropic-工程博客"><a class="anchor" href="#anthropic-工程博客">Anthropic 工程博客</a></h2>
<p>我认为，判断方向最诚实的方法，是按时间顺序阅读那些真正推动该领域发展的组织正在写什么。沿着 Anthropic 工程博客中“Effective context engineering”（2025 年 9 月）之后的文章看下去，仅从标题就能勾勒出重心下一步移向何处。</p>
<ul>
<li>2025 年 10 月，用 Agent Skills 武装 agent</li>
<li>2025 年 11 月，Code execution with MCP：更高效的 agent</li>
<li>2025 年 11 月，Effective harnesses for long-running agents</li>
<li>2026 年 1 月，Demystifying evals for AI agents</li>
<li>2026 年 1 月，设计 AI-resistant 技术评估</li>
<li>2026 年 2 月，量化 agentic coding 评估中的基础设施噪声</li>
<li>2026 年 3 月，Harness design for long-running application development</li>
<li>2026 年 4 月，Scaling Managed Agents：分离大脑与双手</li>
<li>2026 年 5 月，如何在所有产品中 contain Claude</li>
</ul>
<p>退一步看这份列表，关键词聚成三条线：<strong>harness</strong>、<strong>eval</strong> 和 <strong>containment</strong>。我将其理解为：讨论正在从如何恰当地填充、清理 context，再向外迈出一步，转向设计、测量和控制整个 agent 系统。（当然，这里有一个局限：这只是一家公司的侧重点。不过考虑到该公司在 coding agent 生态中的分量，也很难把它仅仅视为单一 vendor 的兴趣。）</p>
<p>下面逐一展开。</p>
<h2 id="harness"><a class="anchor" href="#harness">harness</a></h2>
<p>harness 这个词可能有些陌生。直译是马具，即套在马身上、把力量引向预期方向的装置。在 AI agent 中，harness 指的是<strong>在 model 外部包围 model、驱动其工作的完整骨架</strong>。包括允许它以什么顺序使用哪些 tool、一次失败后如何恢复、授予多大权限，以及 loop 何时停止。</p>
<p>如果 context engineering 解决的是“给 model 看什么”，那么 harness 设计解决的是“让 model 在其中如何行动”。它位于更外一层。若把 model 比作聪明的新员工，context 是交给新人的工作材料，harness 则更像其工作环境和操作规程。同一个人在混乱的环境中表现会不稳定，而在设计良好的流程上，凭同样能力也能走得更远。</p>
<p>这一点的重要性，在<a href="https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents" target="_blank" rel="noopener noreferrer">Anthropic 工程团队亲身遭遇的失败</a>中体现得很清楚。他们把顶级编程模型 Opus 4.5 放在 Claude Agent SDK 上，只给出“做一个 claude.ai 复刻版”之类的高层指令，并运行多个会话；模型本身虽然聪明，却没能产出生产级应用。失败反复呈现两种形态。一种是试图一次做完所有事情，实作中途耗尽 context，让下一个会话接手半成品功能。另一种是后来加入的会话看到“已经做得差不多了”，便丢下尚未完成的工作直接宣布结束。（可以想象一项漫长工作由多人轮班接续，但每个接班者都完全没有前一人的记忆。）</p>
<p>解决办法不是让模型更聪明，而是改变骨架。第一个会话获得用于搭建环境的专用 prompt（initializer agent），把 200 多项功能规格展开到 <code>feature_list.json</code>，并创建启动开发服务器的 <code>init.sh</code> 和进度日志（<code>claude-progress.txt</code>）。此后的每个会话（coding agent）每次只处理一个功能，以 git commit 和进度记录留下整洁状态，然后结束。下一个会话先读取进度文件和 git log，弄清“上一班做到哪里”，再继续工作。同一个模型放到这套骨架上，结果就变了。决定成败的不是模型，而是 harness。（有趣的是，Anthropic 的这些做法并不新鲜。功能清单、小步 commit、进度记录、每次都执行的 smoke test，正是资深开发者每天在做的事。所谓适合 agent 的良好骨架，归根结底就是把良好的工程习惯嵌入环境。）</p>
<p>这条脉络与 token 节省讨论的交点也很明确。上一篇文章谈到的 subagent 隔离、tool 定义瘦身和 model routing，单独看是不同的节省技巧；合在一起看，归根结底都是<strong>如何设计一个 harness</strong>的组成部分。决定哪类工作送往哪个 model lane、只开启哪些 tool、把 verbose 的探索隔离到哪里，这整个过程就是 harness 设计。节省更像是设计的副产品。</p>
<p>不过，harness 设计有一个微妙的陷阱：<strong>骨架设计得越好，model 进步后就越可能过时。</strong> harness 本质上是一组关于“model 无法独自完成什么”的假设；model 一旦能够自行完成，这些假设就成了冗余。Anthropic 提到的<a href="https://www.anthropic.com/engineering/managed-agents" target="_blank" rel="noopener noreferrer">案例</a>正是如此。Sonnet 4.5 在接近 context 上限时有匆忙收尾的习惯（context anxiety），因此团队在骨架中加入了 context reset 机制。可同一套骨架换成 Opus 4.5 后，这种习惯已经消失，精心加入的 reset 反而成了累赘。model 每聪明一步，骨架的一部分就可能这样到期。</p>
<p>于是出现了更进一步的思路：与其精心打造某个特定骨架，不如<strong>设计即使骨架变化也保持稳定的接口</strong>。Anthropic 的 Managed Agents 正朝这个方向发展，而其思想源头竟是操作系统。OS 能维持数十年，是因为它把硬件虚拟化为进程、文件等抽象，提前造出了能容纳尚不存在的程序的容器。一行 <code>read()</code>，无论面对 1970 年代的磁盘还是今天的 SSD，都以相同方式工作。用同样思路把 agent 分为三部分：负责判断的<strong>大脑</strong>（Claude 与 harness）、实际动手的<strong>双手</strong>（code execution sandbox 与工具），以及以 append 方式记录一切的<strong>会话日志</strong>。三者分离后，即便容器崩溃，大脑也能将其当成 tool call error 处理；即便 harness 崩溃，也能从会话日志的最后位置恢复。副作用是成本与延迟也降低了。只在真正需要时启动容器后，time to first token（TTFT）据称在中位数上下降约 60%，p95 下降超过 90%。（这里再次与 token 节省文章相接。上一篇建议为 prompt caching 把静态部分放在前面；“如何安排 context 才能提高 cache hit 率”，正是大脑旁边的 harness 所负责的工作。）</p>
<h2 id="eval"><a class="anchor" href="#eval">eval</a></h2>
<p>第二条线是我个人最感兴趣的。2026 年初的文章仿佛事先约好一样，全都聚向<strong>评估（eval）</strong>。</p>
<p>想想原因就很自然。context 是否组织得当，harness 是否设计得好，cost 是否真的节省了，要<strong>用什么来确认？</strong> agent 越是自主处理漫长而复杂的工作，人就越难逐项用肉眼确认“它到底运行得好不好”。信任的依据最终会转向测量。因此，“如何设计 agent 评估”“如何消除评估本身的噪声”“如何处理 model 察觉评估后改变行为的 eval awareness”等主题走到了前台。</p>
<p><picture><source type="image/webp" srcset="/content/260622/4.720w.webp 720w, /content/260622/4.1440w.webp 1440w, /content/260622/4.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260622/4.png" alt="对比 pass@k 与 pass^k 的曲线图：试验次数增加时，pass@k 趋近 100%，pass^k 趋近 0%" width="1680" height="946" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>agent 评估之所以棘手，是因为它与一次性问答性质不同。agent 会跨多个 turn 调用 tool、改变 state，一次错误会向后传播并累积。而且即使输入相同，每次运行的结果也会波动。<a href="https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents" target="_blank" rel="noopener noreferrer">Anthropic</a>用两个指标描述这种非确定性。<strong>pass@k</strong> 是 k 次尝试中至少成功一次的概率，因此尝试越多越高。<strong>pass^k</strong> 是 k 次全部成功的概率，因此尝试越多越低。只需偶尔生成一次正确代码时，pass@1 很重要；每次都必须稳定运行的客服 agent，则以 pass^k 为核心。（若 per-trial 成功率为 75%，连续成功三次的概率是 0.75³，约 42%。“通常能用”和“每次都能用”的差距就是这么大。）</p>
<p>那么一次尝试由什么评分？同一篇文章把 grader 分为三类。<strong>code-based</strong>（test 是否通过、static analysis、tool call 验证）快速、便宜且客观，却不擅长存在多个正确答案的开放任务。<strong>model-based</strong>（LLM-as-judge、按 rubric 评分）能捕捉细微质量，但具有非确定性，必须定期与人工评分校准。<strong>human-based</strong> 最准确，却缓慢而昂贵。实际工作中会混合三者，尽可能以确定性评分打底，再用 model 评分辅助。还要区分另一组概念。<strong>capability eval</strong> 问“这个 agent 能做到什么”，所以会从低分起步，给出可以攀登的坡度；<strong>regression eval</strong> 问“过去能做到的现在还能不能”，因此必须维持接近 100%，分数下降就是某处损坏的信号。</p>
<p>这里有一个常被忽略的陷阱：<strong>分数低，问题可能不在 agent，而在评估。</strong> Anthropic 报告称，Opus 4.5 最初在 CORE-Bench 上得到 42%；调查后发现，原因包括僵硬的评分方式——期待“96.124991…”，却把“96.12”判错——以及含糊的题目规格和无法复现的随机任务。修复 bug 并用放宽约束的 scaffold 重跑后，分数跃升至 95%。因此他们强调一项原则：<strong>不要照单全收分数，要亲自阅读 transcript（运行记录）。</strong> 如果 frontier model 尝试 100 次仍是 0%，通常不是 model 无能，而是题目坏了。</p>
<p>随着测量进步，基准线本身也快速移动，这一点同样有趣。在代表性的 coding agent benchmark SWE-bench Verified（给出真实 GitHub issue，并按 test 是否通过评分）中，frontier model 的分数在 1 年内就从 30% 多升到 80% 以上。到了这里，简单题已全部解决，分数触及天花板（saturation），于是产生悖论：能力的大幅提升只能显示为很小的分差。据说有一家 code review startup 起初只看一次性评估，对新 model 不以为然；改用衡量更长、更复杂任务的 agent 型评估后，才真正看见进步。因此，eval 不是做一次就结束的东西，而是需要不断换成更难版本的活资产。（Anthropic 将其比作安全工程中的“Swiss cheese model”。一片有孔的奶酪无法挡住失败，但把自动评估、production monitoring 和人工 transcript review 层层叠加，穿过一层的失败就会被下一层捕获。）</p>
<h2 id="containment"><a class="anchor" href="#containment">containment</a></h2>
<p>第三条线的性质略有不同。它关注的不是 cost 或 performance，而是<strong>安全与控制</strong>。</p>
<p>agent 掌握越多 tool、行动越自主，一次错误的影响范围（blast radius）也越大。对于能删除 file、向外部发送 request、代为执行特权操作的 agent，“做得有多好”与“出错时在哪里止损”同样重要。2026 年 5 月，<a href="https://www.anthropic.com/engineering/how-we-contain-claude" target="_blank" rel="noopener noreferrer">Anthropic 关于全产品 containment 的文章</a>被放到突出位置，也可从这一背景理解。文章把 agent 风险分为三类：user 出于恶意或疏忽要求有害操作的 <strong>user misuse</strong>；model 在无人指示时自行行动的 <strong>model misbehavior</strong>；以及通过 tool、file、network 从外部进入的 <strong>external attack</strong>。一个有趣的观点是，model 变聪明并不只会降低风险。能力较弱的 model 会误读情境、犯明显错误；能力更强的 model 错误较少，却更善于找到意料之外的路径，绕过无人明确写下的约束。</p>
<p>他们强调的核心是“每次都由人监督”这一方式的局限。Claude Code 早期通过在每次写入、执行、network access 前请求 user 批准来保障安全，但 telemetry 显示，user 直接同意了约 93% 的请求。批准窗口越多，人对每一个就越不注意，形成 <strong>approval fatigue</strong>。依赖人的 click 的概率性防御最终仍会留洞。因此重心从“监控 agent 正在做什么”移向“首先限制 agent 能做什么”。防御分三层叠加：sandbox、VM、egress control 等构成的<strong>环境层</strong>，system prompt、classifier 等构成的 <strong>model 层</strong>，以及 MCP、plugin、搜索结果等构成的<strong>外部内容层</strong>。核心原则是<strong>首先建立能以确定方式阻断行为的环境层</strong>。这并非因为 model 层防御很弱。事实上，在测试 prompt injection 的 Gray Swan benchmark 上，单次攻击成功率约为 0.1%，处于顶尖水平。但适应性地尝试 100 次后，成功率会升至 5～6%；概率性防御本质上不可能达到 100% 命中率。所以还要设置最终会撞上的硬边界。（引入 OS 级 sandbox 后，批准窗口减少了 84%。安全机制反而降低了摩擦。）</p>
<p><picture><source type="image/webp" srcset="/content/260622/2.720w.webp 720w, /content/260622/2.1440w.webp 1440w, /content/260622/2.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260622/2.png" alt="containment 3 层防御结构：在环境层（确定性、最后防线）之上叠加 model 层与外部内容层（概率性）" style="max-width:min(720px, 100%);height:auto;" width="1680" height="1198" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>这似乎与 token 节省相距很远，实则同根。两者都在问：<strong>“给 agent 什么，又给到什么程度？”</strong> Anthropic 用两个案例很好地说明了这种联系。其一，内部 red team 通过 phishing 让员工用恶意 prompt 运行 Claude Code。悄悄夹入的指令让它读取 <code>~/.aws/credentials</code> 并向外 POST，25 次中成功了 24 次。由于指令由用户亲自输入，模型分类器看不出可疑之处。阻止攻击的不是聪明的模型，而是最初就把凭据放在 sandbox 外的环境边界与 egress control。另一案例更微妙。egress allowlist 正常放行了 <code>api.anthropic.com</code>，但攻击者植入的文件使用攻击者自己的 API key 调用 Anthropic 的文件上传 API，让数据流向攻击者账户。sandbox 完美工作，数据却依然泄漏。教训是，allowlist 不能只看作“目的地过滤器”，而应看作“允许使用该域名所有功能的权限”。（Anthropic 反复强调这一原则：经过验证的 hypervisor、syscall filter 和 container runtime 都安然无恙，<strong>真正出问题的是他们自己在其上构建的组件</strong>。）移除不用的 MCP 工具也是同一个道理：既节省成本，又减少 attack surface。保持轻量的设计不仅更便宜、更准确，也更安全。</p>
<h2 id="总结"><a class="anchor" href="#总结">总结</a></h2>
<p>把三条线压缩成一句话：关注的单位正在逐层向外扩展，<strong>从 prompt（一次指令）到 context（每个 turn 展示什么），再到整个 agent 系统（如何运行、如何测量、如何限制）</strong>。harness 对应“如何运行”，eval 对应“如何知道运行得好不好”，containment 对应“运行出错时如何阻止”。</p>
<p><picture><source type="image/webp" srcset="/content/260622/1.720w.webp 720w, /content/260622/1.1440w.webp 1440w, /content/260622/1.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260622/1.png" alt="关注点从 prompt 到 context、再到整个 agent 系统的3 阶段扩展结构，以及 harness、eval、containment 三条分支" style="max-width:min(720px, 100%);height:auto;" width="1680" height="1122" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>再次强调，这三条线是作者<strong>从已经公开的资料中推断出的重心转移</strong>，不是“2026 年下半年它们将成为标准”之类的预言。有些趋势会继续扩大，有些会被吸收到别的名称之下。但可以确定的是，prompt 和 context 都不会消失，而会作为更大框架的一部分留下。这个领域一直不是用新词抹掉旧词，而是在其上再加一层；未来也很可能如此。</p>
<p>我们从 cost 谈起，一路来到这里。上一篇建议读者拿出 token 账本，这篇则更进一步：不妨同时审视，节省的 cost <strong>用什么确认</strong>（eval），这种节省<strong>在哪种骨架上可以重复</strong>（harness），以及该骨架<strong>安全到什么程度</strong>（containment）。归根结底，最持久的也许不是某一种节省技巧，而是测量并控制自己系统的习惯。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents" target="_blank" rel="noopener noreferrer">Anthropic, Effective context engineering for AI agents</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.anthropic.com/engineering/harness-design-long-running-apps" target="_blank" rel="noopener noreferrer">Anthropic, Harness design for long-running application development</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://research.trychroma.com/context-rot" target="_blank" rel="noopener noreferrer">Chroma Research, Context Rot</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>智能体</category>
        </item>
        <item>
            <title><![CDATA[Kalyx]]></title>
            <link>https://hooninedev.com/zh-CN/260617</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/260617</guid>
            <pubDate>Wed, 17 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊聊我开发的 React headless DatePicker 库 Kalyx。 本文是对 2026 年 6 月所写复盘的重写。最初那篇文章打出的“七种选择器共用一套 API，比竞品库的一个日历还小”，重新核实后发现一半是错的，另一半算不上差异点。所以这次按照开发原因、与现有方案的区别、技术定义的顺序重新整理。 先说结论：Kalyx 的区别不在组件数量，而在 值模型。基准版本是 @ka...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊我开发的 React headless DatePicker 库 <strong>Kalyx</strong>。</p>
<p>本文是对 2026 年 6 月所写复盘的重写。最初那篇文章打出的“七种选择器共用一套 API，比竞品库的一个日历还小”，重新核实后发现一半是错的，另一半算不上差异点。所以这次按照开发原因、与现有方案的区别、技术定义的顺序重新整理。</p>
<p>先说结论：Kalyx 的区别不在组件数量，而在 <strong>值模型</strong>。基准版本是 <code>@kalyx/react</code> 1.4.7 与 <code>@kalyx/core</code> 1.4.8（MIT，仅支持 React 19），引用的代码基于 <a href="https://github.com/jiji-hoon96/kalyx" target="_blank" rel="noopener noreferrer">GitHub 仓库</a> 2026-09-16 的 <code>main</code>（<code>0bb302e</code>）。</p>
<h2 id="我想以声明式的方式使用日期选择器"><a class="anchor" href="#我想以声明式的方式使用日期选择器">我想以声明式的方式使用日期选择器</a></h2>
<p>开发它的原因有两个。一是想用更声明式、更简单的方式使用复杂难用的日期库，二是想学习这类库在内部是如何构建的。“难用”这个说法太笼统，所以我用各个库的类型定义重新确认了一遍，我当初卡住的地方在最新版本里是否依然存在。</p>
<h3 id="用-prop-开启模式的-api"><a class="anchor" href="#用-prop-开启模式的-api">用 prop 开启模式的 API</a></h3>
<p>react-datepicker 用 <code>showTimeSelect</code> 开启时间选择，用 <code>showMonthYearPicker</code> 开启月份选择，用 <code>showYearPicker</code> 开启年份选择，用 <code>selectsRange</code> 开启范围选择。同一个组件会随着 prop 组合变成不同的东西。代价体现在类型上。在 9.1.0 的类型定义中，根据 <code>selectsRange</code> 的值，<code>onChange</code> 的签名会分叉。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// react-datepicker/dist/index.d.ts (발췌)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    selectsRange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    selectsMultiple</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> undefined</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    formatMultipleDates</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> never;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">event</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> React</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">MouseEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">HTMLElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> React</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">KeyboardEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">HTMLElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> void</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>这是一个写得很好的 union，但模式越多，分支就成倍增加，使用方只能看着 prop 名称去推想当前是哪种组合。（这和我在<a href="/260201" target="_blank" rel="noopener noreferrer">抽象</a>一文中讨论的“捆绑错误的抽象会增加耦合”是同一种形态）我希望“输入框、弹出层和日历”能直接从 JSX 结构本身读出来。</p>
<h3 id="值类型与时区泄漏的地方"><a class="anchor" href="#值类型与时区泄漏的地方">值类型与时区泄漏的地方</a></h3>
<p>react-datepicker 和 react-day-picker 传递的是原生 <code>Date</code>。两者确实都有接收 IANA 时区的 <code>timeZone</code> prop。react-datepicker 需要可选 peer 依赖 <code>date-fns-tz</code>，react-day-picker 的那个则是实验性功能，但无论哪一边，值的类型依然是 <code>Date</code>。<code>Date</code> 会按运行环境的本地时区解释，所以在首尔，<code>new Date(2026, 3, 15)</code> 经 <code>toISOString()</code> 得到的是 <code>2026-04-14T15:00:00.000Z</code>。选的是 4 月 15 日，服务器看到的却是 14 日。react-datepicker 的 <a href="https://github.com/Hacker0x01/react-datepicker/issues/1018" target="_blank" rel="noopener noreferrer">"Date Selected is One Day Off"</a> issue 于 2017 年 9 月打开，2025 年 12 月才关闭。</p>
<p>另一端的 Ark UI 和 React Aria 使用 <code>@internationalized/date</code> 的 <code>CalendarDate</code>、<code>ZonedDateTime</code> 对象。语义很精确，但在表单状态和服务器响应全是字符串的应用里，每个边界都会冒出转换代码。</p>
<p>时区支持有时还和日期库的选择绑定在一起。查看 MUI X Date Pickers 9.13.0 的适配器代码，dayjs、Luxon、Moment 适配器是 <code>isTimezoneCompatible = true</code>，date-fns 系列则是 <code>false</code>。使用 date-fns 的应用若想用 <code>timezone</code> prop，就得再引入一个日期库。</p>
<p>模式分散在 prop 组合里，值分散在解释依赖运行环境的 <code>Date</code> 里，时区支持分散在日期库的选择里。<strong>很难用一个声明写清意图</strong>。</p>
<h3 id="在构建中学习"><a class="anchor" href="#在构建中学习">在构建中学习</a></h3>
<p>日期选择器看起来很小，却包含了日历计算、区域设置、时区与夏令时（DST）、键盘导航和 SSR。所以我定下的目标是：在使用方，只看 JSX 就能读懂在构建什么；在实现方，把范围收窄到能说清值在哪里被转换。</p>
<p>那么，真的没有已经解决这些需求的库吗？</p>
<h2 id="与现有方案有什么不同"><a class="anchor" href="#与现有方案有什么不同">与现有方案有什么不同</a></h2>
<p>先说答案：有。我一开始认定没有既是 headless 又具备多种选择器的库，但重新调查后发现这个前提是错的。</p>
<h3 id="各方案选择的值模型"><a class="anchor" href="#各方案选择的值模型">各方案选择的值模型</a></h3>
<p>以下内容是 2026-09-16 安装 npm 最新版本后，通过类型定义和官方文档确认的。体积一列是用下文介绍的同一方法测得的。</p>
<table>
<thead>
<tr>
<th>库</th>
<th>headless</th>
<th>值类型</th>
<th>时间输入</th>
<th>单独选择月、年</th>
<th>gzip</th>
</tr>
</thead>
<tbody>
<tr>
<td>react-day-picker 10.0.1</td>
<td>否（自带 CSS）</td>
<td><code>Date</code></td>
<td>无</td>
<td>无</td>
<td>20.0KB</td>
</tr>
<tr>
<td>react-datepicker 9.1.0</td>
<td>否（CSS import）</td>
<td><code>Date</code></td>
<td><code>showTimeSelect</code></td>
<td>通过 prop</td>
<td>45.4KB</td>
</tr>
<tr>
<td>MUI X 9.13.0</td>
<td>否（Material）</td>
<td>适配器对象</td>
<td>TimePicker</td>
<td><code>views</code></td>
<td>113.1KB</td>
</tr>
<tr>
<td>Ark UI 5.39.2</td>
<td>是</td>
<td><code>@internationalized/date</code></td>
<td>另外的 <code>DateInput</code> 分段（不计入体积）</td>
<td><code>minView</code></td>
<td>42.7KB</td>
</tr>
<tr>
<td>React Aria Components 1.21.1</td>
<td>是</td>
<td><code>@internationalized/date</code></td>
<td><code>TimeField</code> 分段</td>
<td>无</td>
<td>75.3KB（DatePicker），78.8KB（含范围与时间）</td>
</tr>
<tr>
<td>Kalyx 1.4.7</td>
<td>是</td>
<td>ISO 8601 UTC 字符串</td>
<td>列表式 HourList、MinuteList</td>
<td>MonthPicker、YearPicker</td>
<td>18.9KB（DatePicker），25.6KB（全部）</td>
</tr>
</tbody>
</table>
<p>react-day-picker 在<a href="https://daypicker.dev/guides/timepicker" target="_blank" rel="noopener noreferrer">官方指南</a>中明确写道 "DayPicker does not include a built-in time picker"。MUI 的体积包含 <code>@mui/material</code> 和 emotion，所以如果本来就是 MUI 应用，增量要小得多。</p>
<h3 id="headless-的完整方案早已存在"><a class="anchor" href="#headless-的完整方案早已存在">headless 的完整方案早已存在</a></h3>
<p>表中重要的是 Ark UI 和 React Aria 两行。<a href="https://ark-ui.com/docs/components/date-picker" target="_blank" rel="noopener noreferrer">Ark UI</a> 通过 <code>DatePicker.Root</code> 这类 dot notation 组合 API，提供单选、多选、范围选择以及按月、按年选择。<a href="https://react-aria.adobe.com/DatePicker" target="_blank" rel="noopener noreferrer">React Aria</a> 是 Adobe 以无障碍为核心的实现。所以 Kalyx 的位置比我最初想的要窄。</p>
<blockquote>
<p>headless 的完整方案早已存在。不过 Ark UI 和 React Aria 以 <code>@internationalized/date</code> 对象传递值，时间输入采用分段字段。Kalyx 把值固定为可以直接放进 JSON 的 UTC 时刻字符串，并把从列表中选择的 TimePicker 以及月、年、周选择器放进了同一套组合 API。</p>
</blockquote>
<p>不过，“值是字符串所以好”是个站不住的主张。<code>CalendarDate</code> 调用一次 <code>toString()</code> 也能变成字符串。差异不在格式，而在于 <strong>这个字符串始终是真实的瞬间（时刻），不会与日历格子（坐标）混用的契约，以及守护这个契约的测试</strong>。</p>
<h3 id="用同一方法重新测量包体积"><a class="anchor" href="#用同一方法重新测量包体积">用同一方法重新测量包体积</a></h3>
<p>README 徽章上的包体积并不是能和其他库比较的量。徽章上约 19.5KB 的数字，是 <code>@kalyx/react</code> 的 <code>dist</code> 中的一个文件，而这个文件把 <code>@kalyx/core</code>、<code>@kalyx/adapter-date-fns</code>、<code>@floating-ui/react</code> 留作外部 import。我把这个数字和别人包含依赖的数字摆在了一起。</p>
<p>所以我按“消费方应用加一行 import 时，包会增大多少”把所有库重新测了一遍。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">echo</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "import { DatePicker } from '@kalyx/react'; export default DatePicker;"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> entry.jsx</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npx</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> esbuild</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> entry.jsx</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --bundle</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --minify</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --format=esm</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --platform=browser</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> \</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  --external:react</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --external:react-dom</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --external:react/jsx-runtime</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> gzip</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -6</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> wc</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -c</span></span></code></pre></figure>
<p>测量使用 esbuild 0.28.2，时间为 2026-09-16，KB 是字节数除以 1024 的值。react-datepicker 排除了 CSS。React Aria Components 测了两次：一次是组装单个 DatePicker 所需的 12 个 export（<code>DatePicker</code>、<code>DateInput</code>、<code>Calendar</code>、<code>Popover</code>、<code>Dialog</code> 等），一次是加入范围和时间的 14 个 export（含 <code>DateRangePicker</code>、<code>RangeCalendar</code>、<code>TimeField</code>）。</p>
<p><picture><source type="image/webp" srcset="/content/260617/1.720w.webp 720w, /content/260617/1.1440w.webp 1440w, /content/260617/1.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260617/1.png" alt="在相同的 esbuild 条件下测量，体积依次为 Kalyx DatePicker 18.9KB、react-day-picker 20.0KB、Kalyx 全部 25.6KB、Ark UI 42.7KB、react-datepicker 45.4KB、React Aria Components 75.3KB（含范围与时间为 78.8KB）、MUI X 113.1KB。" style="max-width:min(720px, 100%);height:auto;" width="1680" height="980" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>在相同条件下，成立的说法只有一个。一个 DatePicker（18.9KB）与 <code>DayPicker</code>（20.0KB）体积相当，七种全部（25.6KB）则比它大。（即便如此，DayPicker 只有日历，而 Kalyx DatePicker 还包含输入框和弹出层）体积对 import 组合和 gzip 级别很敏感，按数量级来读更合适，体积也不是选择 Kalyx 的核心理由。</p>
<h2 id="从技术上定义-kalyx"><a class="anchor" href="#从技术上定义-kalyx">从技术上定义 Kalyx</a></h2>
<blockquote>
<p>Kalyx 是一个 headless React 日期选择器：它把所有输入输出固定为 UTC 时刻字符串，把日历坐标与时刻之间的转换只放在两个函数里，并针对运行时知道的所有时区用属性测试检查这一往返。</p>
</blockquote>
<p>使用方的 API 如下。<code>value</code> 和 <code>onChange</code> 是 <code>string | null</code>，<code>displayTimezone</code> 决定用哪个时区的日历来显示。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{iso} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{setIso} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">displayTimezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"America/New_York"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker.Input</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker.Popover</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker.Calendar</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker.Popover</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<h3 id="值是时刻日历格子是坐标"><a class="anchor" href="#值是时刻日历格子是坐标">值是时刻，日历格子是坐标</a></h3>
<p>Kalyx 内部流动着格式相同的两种 ISO 字符串。<strong>坐标</strong> 是日历网格中的一格，写作 <code>YYYY-MM-DDT00:00:00.000Z</code>，却没有时区概念。网格计算只在 UTC 中进行，因此与运行环境无关。<strong>时刻</strong> 是通过 <code>onChange</code> 输出的值，是 <code>displayTimezone</code> 中当天零点对应的真实瞬间。同样是 1 月 15 日，在纽约是 <code>2026-01-15T05:00:00.000Z</code>，在首尔是 <code>2026-01-14T15:00:00.000Z</code>。</p>
<p><picture><source type="image/webp" srcset="/content/260617/2.720w.webp 720w, /content/260617/2.1440w.webp 1440w, /content/260617/2.1640w.webp 1640w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260617/2.png" alt="日历坐标 2026-01-15T00:00:00.000Z 通过 civilMidnightFromUtcDay 变为纽约和首尔的时刻，再通过 calendarDayFromInstant 回到坐标。" style="max-width:min(720px, 100%);height:auto;" width="1640" height="1040" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>连接二者的函数只有 <code>@kalyx/core</code> 中的两个：把坐标转为时刻的 <code>civilMidnightFromUtcDay</code>，以及反方向的 <code>calendarDayFromInstant</code>。后者读取“这个时刻在该时区是几号”，再重新写成 UTC 零点坐标。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/core/src/utils/timezone.ts:187-190</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> calendarDayFromInstant</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">iso</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ISODateString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timeZone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ISODateString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> p</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> partsInTimezone</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(iso), timeZone);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(Date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">UTC</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(p.year, p.month </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, p.day)).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toISOString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>规则在于方向。选中格子并提交值时，坐标转为时刻；根据存储值决定显示哪个月和焦点位置时，时刻转为坐标。DatePicker Root 的 <code>selectDate</code> 先转换，再用转换后的时刻检查约束，通过后提交。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/react/src/components/DatePicker/Root.tsx:182-194</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> normalized</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  coordinate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> displayTimezone</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    ?</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> civilMidnightFromUtcDay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(coordinate, displayTimezone)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> coordinate;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (normalized </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isDateDisabled</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(normalized, disabledRules, adapter, displayTimezone)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">isControlled) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  setUncontrolledValue</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(normalized);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">?.(normalized);</span></span></code></pre></figure>
<p>调用点不止这一处。搜索 <code>packages/react/src</code> 会发现，这两个函数分散在 DatePicker、RangePicker、DateTimePicker 的 Root 和 Calendar、Presets、键盘移动工具函数以及六个 headless hook 中。即便如此，所有提交日期格子或决定视图的路径都会经过二者之一。提交时间由 <code>setTimeInTimezone</code> 负责，它在内部也用同一个内部函数 <code>resolveCivilDateTime</code> 转换。</p>
<p>这个契约由基于属性的测试（property-based test）守护。对任意坐标 <code>c</code> 和时区 <code>z</code>，都必须满足 <code>calendarDayFromInstant(civilMidnightFromUtcDay(c, z), z) === c</code>。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/core/src/__tests__/timezone.property.test.ts:86-94</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">it</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'round-trips every UTC calendar coordinate through civil midnight'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  fc.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">assert</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    fc.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">property</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">utcCalendarCoordinate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">zone</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">coordinate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timeZone</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> instant</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> civilMidnightFromUtcDay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(coordinate, timeZone);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      expect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">calendarDayFromInstant</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(instant, timeZone)).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toBe</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(coordinate);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }),</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    RUNS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p><a href="https://fast-check.dev/" target="_blank" rel="noopener noreferrer">fast-check</a> 随机生成 2020 年到 2045 年之间的日期，与 +5:45 的 Kathmandu、+14 的 Kiritimati、-11 的 Niue 等 14 个代表性时区组合，运行 300 次。紧接着的测试对 <code>Intl.supportedValuesOf('timeZone')</code> 返回的所有时区，逐个时区各检查 12 次同样的往返。在我本地的 Node 24.16 上，这个列表有 418 个时区。1.4.8 又加入了一个全量测试，在每次夏令时切换处把边界时间的转换结果与 Temporal 实现对照。</p>
<p>那么，库能不能自动把用户传入的 <code>"2026-01-15T00:00:00.000Z"</code> 规范化为时刻？这条路走不通，因为仅凭字符串无法判断它是坐标还是时刻。对已经是时刻的首尔值 <code>2026-01-14T15:00:00.000Z</code> 再套一次 <code>civilMidnightFromUtcDay</code>，就会得到 <code>2026-01-13T15:00:00.000Z</code>，在首尔这样偏移为正的时区，每次渲染日期都会再往前挪一天。（纽约的值再套一次也不变）换成无论应用多少次结果都相同的 <code>startOfDayInTimezone</code>，虽然不会挪动，却做不了把坐标转为时刻这个本职工作。</p>
<p>所以我放弃了规范化，用文档固定契约。使用方必须原样传回选择器输出的值。坦白说，这是转嫁给使用方的成本，而且 <code>ISODateString</code> 是 <code>string</code> 的别名，编译器也拦不住。</p>
<h3 id="只用-intl-解决夏令时"><a class="anchor" href="#只用-intl-解决夏令时">只用 Intl 解决夏令时</a></h3>
<p>要把坐标转为时刻，需要知道“该时区当天的 00:00”在 UTC 中是什么时候，但偏移量只有知道时刻才能求出。更麻烦的是，在夏令时切换日，当地时间可能不存在（spring forward），也可能出现两次（fall back）。</p>
<p>Kalyx 不借助 <code>date-fns-tz</code> 这样的库来解决这个问题。它用 <code>Intl.DateTimeFormat(...).formatToParts</code> 询问“这个 UTC 瞬间在该时区是几点”，以此测出偏移量，并在请求时间的前一天和后一天两个点读取偏移量。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/core/src/utils/timezone.ts:251-254, 259</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> candidate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">probeEpoch</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  civilEpoch </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getTimezoneOffsetMinutes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(probeEpoch).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toISOString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), timeZone) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 60_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> epochBefore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> candidate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(civilEpoch </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 86_400_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> epochAfter</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> candidate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(civilEpoch </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 86_400_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (epochBefore </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> epochAfter) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(epochBefore).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toISOString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p><code>civilEpoch</code> 是把目标当地时间当作 UTC 读出的值。偏移量在 ±14 小时以内，所以前一天和后一天会分别给出附近切换之前和之后的偏移量。两者相同说明附近没有切换，两次 <code>formatToParts</code> 就结束；网格的 42 个格子各调用一次，所以这条快速路径决定了开销。（前提是 48 小时内最多一次切换，这在 2020 至 2045 年的所有时区都成立）</p>
<p>两者不同时，会在该时区重新读取两个候选，看哪个与请求时间一致（<code>timezone.ts:261-279</code>）。fall back 的重叠中两者都一致，于是选用切换前偏移量得出的较早一个；spring forward 的空隙中两者都不一致，于是同样选这一侧，把时间按空隙长度往后推。实际运行的结果如下。</p>
<table>
<thead>
<tr>
<th>请求</th>
<th>情况</th>
<th>1.4.7</th>
<th>1.4.8</th>
</tr>
</thead>
<tbody>
<tr>
<td>New_York 2026-03-08 02:30</td>
<td>不存在的时间</td>
<td><code>2026-03-08T07:30:00.000Z</code></td>
<td>相同（03:30 EDT，向后推）</td>
</tr>
<tr>
<td>New_York 2026-11-01 01:30</td>
<td>出现两次的时间</td>
<td><code>2026-11-01T05:30:00.000Z</code></td>
<td>相同（01:30 EDT，较早的一个）</td>
</tr>
<tr>
<td>London 2026-10-25 01:30</td>
<td>出现两次的时间</td>
<td><code>2026-10-25T01:30:00.000Z</code>（较晚的一个）</td>
<td><code>2026-10-25T00:30:00.000Z</code>（01:30 BST，较早的一个）</td>
</tr>
</tbody>
</table>
<p>“空隙往后推，歧义取较早”与 <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/ZonedDateTime" target="_blank" rel="noopener noreferrer">MDN 的 Temporal.ZonedDateTime 文档</a>所说明的 <code>disambiguation</code> 默认值 <code>"compatible"</code> 相同。1.4.7 的 London 一行为什么出错，后面再讲。</p>
<h3 id="适配器边界是-21-个字符串方法"><a class="anchor" href="#适配器边界是-21-个字符串方法">适配器边界是 21 个字符串方法</a></h3>
<p>既然时区由 core 计算，那 date-fns 或 dayjs 负责什么？日期运算和解析。这条边界 <code>DateAdapter</code> 有 21 个方法，日期参数和返回值全都是 ISO 字符串。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/core/src/types.ts:71-102 (발췌)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> DateAdapter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  parse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">format</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  format</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">iso</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">formatStr</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  addDays</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">iso</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  isSameDay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  startOfDay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">iso</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  today</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // addMonths, isBefore, startOfMonth, getYear 등 15개</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>接收时区的方法有 <code>format</code>、<code>isSameDay</code>、<code>startOfDay</code>、<code>today</code> 四个，而这四个也把计算交给 core。date-fns 适配器的 <code>format</code> 在有 <code>timezone</code> 时，会直接调用 core 的 <code>formatInTimezone</code>（<code>packages/adapter-date-fns/src/index.ts:112-115</code>）。因此无论使用哪个适配器，时区的答案都出自同一段代码，三个适配器（date-fns、dayjs、luxon）各自运行 <code>@kalyx/core/test-helpers</code> 的 <code>runAdapterConformanceTests</code>，确认给出相同的答案。</p>
<p>两个入口也在这条边界上分开。默认入口 <code>@kalyx/react</code> 在模块加载时调用 <code>setDefaultAdapter(DateFnsAdapter)</code>，所以安装后即可使用（<code>packages/react/src/index.ts:9-11</code>）。<code>@kalyx/react/headless</code> 没有这个调用，打包也是分开的，date-fns 的代码不会混进来。</p>
<p>这种边界形态是有代价的。2026 年 6 月，我放弃了 Temporal 适配器。Temporal 的价值在于像 <code>PlainDate</code>、<code>ZonedDateTime</code> 这样 <strong>由类型携带语义</strong>，可一旦边界是字符串，这层语义就无法通过。包一层也只会被压平成字符串，我判断这在正确性上没有收益。回头看，上一节“坐标和时刻是同一个 <code>string</code>”的问题，恰恰就是 Temporal 用 <code>PlainDate</code> 和 <code>Instant</code> 在类型层面解决的问题。字符串边界让替换适配器变得容易，代价是堵住了用类型解决问题的路。</p>
<h3 id="七种选择器是三个上下文的组合"><a class="anchor" href="#七种选择器是三个上下文的组合">七种选择器是三个上下文的组合</a></h3>
<p>Kalyx 的选择器有 DatePicker、RangePicker、TimePicker、DateTimePicker、MonthPicker、YearPicker、WeekPicker 七种。比数量更重要的是，<strong>这七种并不是独立实现</strong>。上下文只有 <code>DatePickerContext</code>、<code>RangePickerContext</code>、<code>TimePickerContext</code> 三个。</p>
<table>
<thead>
<tr>
<th>选择器</th>
<th>持有状态的 Root</th>
<th>上下文</th>
<th>差异来自哪里</th>
</tr>
</thead>
<tbody>
<tr>
<td>DatePicker</td>
<td><code>DatePickerRoot</code></td>
<td>Date</td>
<td></td>
</tr>
<tr>
<td>MonthPicker、YearPicker</td>
<td>DatePicker 的 Root 实现</td>
<td>Date</td>
<td><code>selectionGranularity</code></td>
</tr>
<tr>
<td>RangePicker</td>
<td><code>RangePickerRoot</code></td>
<td>Range</td>
<td></td>
</tr>
<tr>
<td>WeekPicker</td>
<td>直接使用 <code>RangePickerRoot</code></td>
<td>Range</td>
<td>Calendar 上的 <code>selectionMode="week"</code></td>
</tr>
<tr>
<td>TimePicker</td>
<td><code>TimePickerRoot</code></td>
<td>Time</td>
<td></td>
</tr>
<tr>
<td>DateTimePicker</td>
<td>自己的 Root</td>
<td>Date 与 Time 嵌套</td>
<td>叠加两个 Provider</td>
</tr>
</tbody>
</table>
<p>MonthPicker 的 Root 只是把显示格式默认值改为 <code>yyyy-MM</code>，再把 <code>selectionGranularity="month"</code> 传给 DatePicker 的 Root 实现（<code>MonthPicker/Root.tsx:11-20</code>）。DatePicker 的 <code>selectDate</code> 在 granularity 为 <code>month</code> 时，把坐标折叠到当月 1 日，之后照样走前面看到的转换和约束检查。DateTimePicker 则叠加提供两个上下文。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/react/src/components/DateTimePicker/Root.tsx:401-402</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePickerContext.Provider</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{dateContext}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">TimePickerContext.Provider</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{timeContext}>{children}&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">TimePickerContext.Provider</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>所以 <code>DateTimePicker.Calendar</code> 和 <code>DatePicker.Calendar</code> 是同一个组件，它并不知道自己处在哪个选择器里。一处修改会作用到所有使用该组件的选择器，一处缺陷也会波及全部。前面图表中单个 DatePicker 占全部体积 74% 的原因，也正是这层共享基础。</p>
<h2 id="守住契约的代价"><a class="anchor" href="#守住契约的代价">守住契约的代价</a></h2>
<p>1.0 之后的三个月，我更多用来确认这个契约而不是做新功能，结果出现了三次靠示例测试会被放过的缺陷。第一个由属性测试发现，第二个由代码交叉审查发现，第三个由本文的核查发现。</p>
<h3 id="sydney-10-月-1-日的一小时"><a class="anchor" href="#sydney-10-月-1-日的一小时">Sydney 10 月 1 日的一小时</a></h3>
<p>第一个是“把 <code>startOfDayInTimezone</code> 的结果放到该时区读取，得到 00:00:00”这一属性在 Australia/Sydney 被打破。当时的实现只测一次偏移量。Sydney 在 2034 年 10 月 1 日 02:00 从 +10 切换到 +11，当天 00:00 仍是 +10，所以正确答案是 <code>2034-09-30T14:00:00.000Z</code>。然而“把 10 月 1 日 00:00 当作 UTC 读取的那个点”已经在切换之后，返回的是 +11，结果变成了前一天 23:00。这个反例也作为回归测试保留了下来（<code>timezone.property.test.ts:276</code>）。</p>
<h3 id="只在首尔通过的测试"><a class="anchor" href="#只在首尔通过的测试">只在首尔通过的测试</a></h3>
<p>第二个出自 2026 年 8 月 3 日的交叉审查。决定日历中哪一格显示为选中的代码做了两次转换。格子本来就是坐标，却把格子和存储值都转换成了该时区的日期。选择“1 月 15 日”的结果如下。</p>
<table>
<thead>
<tr>
<th>时区</th>
<th>存储值</th>
<th>显示为选中的格子</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Asia/Seoul</code>（+9）</td>
<td><code>2026-01-14T15:00:00.000Z</code></td>
<td>15 日（正确）</td>
</tr>
<tr>
<td><code>America/New_York</code>（-5）</td>
<td><code>2026-01-15T05:00:00.000Z</code></td>
<td>16 日（错误）</td>
</tr>
</tbody>
</table>
<p>在正偏移时区，两次偏移相互抵消，碰巧是对的，而已有测试只覆盖了首尔。同一次检查还发现了反方向的违规。决定显示哪个月时，直接对时刻套用了 <code>startOfMonth</code>，于是在首尔把 1 月 1 日作为值传入，打开的却是 12 月的日历。</p>
<p>机制不同，违反的规则却是同一条：每个方向只用指定的函数转换一次。</p>
<p>这次事故让 core 的往返属性测试从“几个代表性时区”扩展到“运行时知道的全部时区”。（React 一侧的组件测试仍在使用 <code>America/New_York</code> 这类代表性时区）<strong>只在符号翻转处暴露的缺陷，靠抽样是抓不到的</strong>。</p>
<h3 id="london-的-0130-被解析成较晚的一个"><a class="anchor" href="#london-的-0130-被解析成较晚的一个">London 的 01:30 被解析成较晚的一个</a></h3>
<p>第三个是在把本文的 DST 表格扩展到其他时区时发现的。1.4.7 从“把请求时间当作 UTC 读取的那个点”开始测偏移量，而在切换后偏移量为 0 或以上的时区，那个点已经在切换之后，于是收敛到较晚的偏移量。用 temporal-polyfill 对 418 个时区 2020 至 2045 年的全部切换逐一对照，3,395 次重叠切换中有 1,908 次选了较晚的一个，涉及 84 个时区。（3,394 次空隙切换全部正确，偏移为负的纽约只是碰巧正确）</p>
<p>在 <a href="https://github.com/jiji-hoon96/kalyx/pull/226" target="_blank" rel="noopener noreferrer">#226</a> 中我改成读取前一天和后一天，作为 <code>@kalyx/core</code> 1.4.8 发布，并把同样的对照保留为 <code>timezone.dst-oracle.test.ts</code>。（temporal-polyfill 只在测试中使用）之前错写成 <code>'earlier'</code> 的源码注释也改成了 <code>'compatible'</code>。这一次，<strong>样本同样集中在一种符号上。</strong></p>
<h3 id="花在正确性上的-3kb"><a class="anchor" href="#花在正确性上的-3kb">花在正确性上的 3KB</a></h3>
<p>这些修复需要代码。Kalyx 为默认入口的包设置了 CI 上限，超出的 PR 会在必需检查中失败。这个上限从 12KB 起步，每加一个功能就上调 1KB，而在 2026 年 8 月全面修正时区和约束的正确性时，我把它从 17KB 一次性提到了 20KB。</p>
<p>体积是 README 徽章上展示的卖点。在负偏移时区每次差一天的日期选择器，不管大小都没法用。</p>
<p><strong>如果必须在小和对之间选一个，那就选对的</strong>。现在的上限很紧。根据仓库 2026-09-11 的包字节分布文档，用 Node 默认 gzip 测量 <code>dist/index.cjs</code> 的结果是 20,259B，上限是 20,480B，余量只有 221B。（这是把依赖留在外部的自身文件大小，与前面图表测的是不同的量）下一个功能得先回收字节才能加进来。</p>
<h2 id="与最初设想不同的结论"><a class="anchor" href="#与最初设想不同的结论">与最初设想不同的结论</a></h2>
<p>我想要的是以声明式的方式使用复杂的日期库。做完之后才发现，声明式的组合 API 和 headless 的完整方案早已存在。剩下的差异藏在更里面。<strong>把值固定为一个时刻，把坐标与时刻之间的转换收窄到两个函数，并用测试在所有时区守住这个往返。</strong> 基于 Intl 的夏令时处理、字符串适配器边界、由三个上下文组成的七种选择器，都是这一决定的结果。</p>
<p>从学习这个目标来看，我学到最多的是如何主张“正确”。最初文章的包体积对比测的是不同的量，在首尔通过的测试在纽约是错的。如果不把测了什么、用什么样本确认一并写下来，数字很容易沦为炫耀。</p>
<p>局限也很明确。维护者只有我一个人，仅支持 React 19，<code>@kalyx/react</code> 在 2026 年 9 月 5 日至 11 日的 npm 下载量是 200 次，其中 156 次集中在 1.4.7 发布当天。坐标与时刻无法用类型区分的问题只靠文档挡着，作为样式接入点的 <code>classNames</code> 和 <code>data-*</code> 属性是否作为公开 API 来保证，我也还没决定。可访问性方面，日历有 <code>grid</code>、输入框有 <code>combobox</code>、时间列表有 <code>listbox</code> 角色，支持方向键、Home/End、PageUp/PageDown 导航，CI 会运行 8 个测试文件中的 <code>jest-axe</code> 检查，但还没有依据说它和 React Aria 一样经过充分验证。</p>
<p>所以，如果本来就是 MUI 应用，先看 MUI X；如果分段输入和经过验证的无障碍是首要需求，先看 React Aria；如果只需要一个日历，先看 react-day-picker，这样更合适。如果你在值以 JSON 往来的表单中遇到过日期差一天的问题，那时再来看看 Kalyx 我会很高兴；如果你知道更好的解法，也欢迎通过 GitHub Issue 告诉我。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">pnpm</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> add</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> @kalyx/react</span></span></code></pre></figure>
<p>在文档站的 <a href="https://kalyx-docs-site.vercel.app/playground" target="_blank" rel="noopener noreferrer">Playground</a> 中，可以亲手试用七种选择器，并修改 locale 和 timezone 设置。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://kalyx-docs-site.vercel.app/" target="_blank" rel="noopener noreferrer">Kalyx 官方文档站</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://mui.com/x/react-date-pickers/timezone/" target="_blank" rel="noopener noreferrer">MUI，Date and Time Pickers Timezone</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://floating-ui.com/" target="_blank" rel="noopener noreferrer">Floating UI 官方文档</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>库</category>
            <category>React</category>
            <category>DatePicker</category>
            <category>开源</category>
        </item>
        <item>
            <title><![CDATA[AI Token 节省方法]]></title>
            <link>https://hooninedev.com/zh-CN/260611</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/260611</guid>
            <pubDate>Thu, 11 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊一聊如何节省 AI Token。 过去，我关注的是结果和过程，而不是性能与成本。AI 产出的内容漏洞不少，必须逐一验证；为了尽快拿到结果，包括我在内的很多人一旦 Token 不够，就会付费或升级到更高档的订阅。我也不例外。（其实最初几个月里，我甚至没有留意 Token 究竟花了多少钱。） 但过了一段时间，我渐渐对 Token 用量警觉起来。个人感到月费负担沉重，企业则开始认真考虑人力和...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊一聊如何节省 AI Token。</p>
<p>过去，我关注的是结果和过程，而不是性能与成本。AI 产出的内容漏洞不少，必须逐一验证；为了尽快拿到结果，包括我在内的很多人一旦 Token 不够，就会付费或升级到更高档的订阅。我也不例外。（其实最初几个月里，我甚至没有留意 Token 究竟花了多少钱。）</p>
<p>但过了一段时间，我渐渐对 Token 用量警觉起来。个人感到月费负担沉重，企业则开始认真考虑人力和运营成本。我在<a href="/260524" target="_blank" rel="noopener noreferrer">MCP 与 function calling</a>中整理了把智能体连接到外部系统的协议，在<a href="/260526" target="_blank" rel="noopener noreferrer">代码智能的四个层级</a>中整理了代码检索工具和 GitHub Trending，在<a href="/260529" target="_blank" rel="noopener noreferrer">上下文文件</a>中整理了智能体读取的规则文件。可以说，之前的文章更关注有哪些工具、该如何用好它们。我依然认同这个方向，不过随着时间推移，人们最终最想知道的恐怕还是成本。</p>
<p>关于 Token 究竟是什么、如何通过 BPE 生成，以及 prompt caching 降低单价时 Transformer 内部发生了什么，我已在 <a href="/260610" target="_blank" rel="noopener noreferrer">Token 的工作原理</a>中单独整理。本文以此为基础，先看费用如何计算、低效从何而来，再归纳经过验证的节省模式。主要模式都附上了笔者用多种策略执行同一任务、亲自测得的 Token 用量。</p>
<h2 id="token-成本是怎样产生的"><a class="anchor" href="#token-成本是怎样产生的">Token 成本是怎样产生的？</a></h2>
<p>先用最简明的方式看看 Token 成本从何而来，又按什么规则计费。</p>
<p>我们发送的所有文本——系统提示词、工具定义、对话记录、用户消息——都会成为输入 Token，模型生成的回复则是输出 Token。对模型来说，每次调用都是从未见过的新输入。无论是昨天的对话还是一分钟前的调用，新调用都会把相同内容作为输入重新完整发送。（这个简单事实正是 Token 成本的核心：模型没有记忆，是我们每次都把内容重新告诉它。）</p>
<p>这里还有一个变量。重复调用时，如果每次都重新传输静态部分，成本实在太高，因此主流 LLM 提供商引入了 prompt caching。静态输入先保存到缓存中一次，后续调用只对从缓存读取的 Token 按低得多的价格计费。（Token 如何由 BPE 生成，以及缓存如何复用 Transformer 的 KV cache 来降低单价，已在“Token 的工作原理”中说明；这里关注这些原理怎样转化为费用。）</p>
<h3 id="输入-token输出-token-与缓存-token"><a class="anchor" href="#输入-token输出-token-与缓存-token">输入 Token、输出 Token 与缓存 Token</a></h3>
<p>来看 Anthropic SDK 在响应的 <code>usage</code> 对象中返回的四个字段。</p>
<p><picture><source type="image/webp" srcset="/content/260611/1.720w.webp 720w, /content/260611/1.916w.webp 916w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/1.png" alt="Anthropic SDK 响应中的 usage 对象，可以看到 input_tokens、cache_creation_input_tokens、cache_read_input_tokens 和 output_tokens 字段" width="916" height="638" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<ul>
<li><code>input_tokens</code> 已发送输入中除缓存读取之外的部分</li>
<li><code>output_tokens</code> 模型生成的响应</li>
<li><code>cache_creation_input_tokens</code> 本次调用首次写入缓存的 Token</li>
<li><code>cache_read_input_tokens</code> 从已有缓存中再次读取的 Token</li>
</ul>
<p>这四个字段分别乘以不同单价。输出 Token 最贵，从缓存读取的 Token 最便宜。根据 Anthropic 官方文档，缓存读取价格是基础输入价格的 0.1 倍，也就是整整 10%。缓存写入按 5 分钟 TTL（Time To Live，缓存有效期）计为 1.25 倍，按 1 小时 TTL 计为 2 倍。也就是说，第一次稍微多付一些，从第二次开始便宜 90%。</p>
<p>不过缓存还有一些不太为人所知的限制。可缓存的最小 Token 数因模型而异，同一系列的不同版本也不一样。根据 Anthropic 官方文档，Sonnet 4.6 和 Opus 4.8 是 1,024 Token，Opus 4.7 是 2,048 Token，Haiku 4.5 与旧版 Opus 4.5/4.6 则是 4,096 Token。更短的提示词即使设置了 <code>cache_control</code>，也不会提示错误，只是不会被缓存。每个请求最多可设置 4 个 <code>cache_control</code> 断点，缓存按 <code>tools</code> → <code>system</code> → <code>messages</code> 的层级顺序读取。因此，前方哪怕只改了一项工具定义，后面的全部缓存也会失效。</p>
<p><img src="/content/260611/2.webp" alt="prompt cache 必须从前往后依次匹配，前部变化会使后续缓存失效" width="1680" height="840" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>缓存之所以能让单价降低 90%，源于在多次调用之间复用 Transformer 的 KV cache；内部原理已在“Token 的工作原理”中详述。从成本角度只需记住一点：缓存命中只有在前缀完全相同时才会发生。因此必须把<strong>静态部分放在前面，把每次变化的动态部分放在后面</strong>，才能避免缓存失效。提示词前部哪怕只多了一个时间戳字符，也会使后续缓存全部失效。</p>
<p>此时很自然会想到一个问题：“多轮对话每轮输入都不同，缓存岂不是几乎每次都会失效？”答案是否定的。对话输入并不是每轮重写的一整块内容，而是<strong>保留前面累积的内容，只在末尾追加新发言（append）的结构</strong>。系统提示词、工具定义以及此前各轮的问答一旦确定就保持不变，只有本轮的新问题会加到最后。因此，从开头比较本轮和上一轮输入时，首次出现差异的位置总是末尾的新问题。整体输入每次都不同，但从前缀看，前面绝大部分仍然一致，缓存便能保留。（真正导致每轮缓存失效的，是把动态值塞到提示词前部的错误设计。Claude Code、opencode 等工具都固定前部、只在后面追加，用户也无需专门调整缓存设置。输入中的错别字或拼写差异不影响缓存，道理相同：变化总发生在缓存覆盖范围之外的最末端。）</p>
<h3 id="ai-对比"><a class="anchor" href="#ai-对比">AI 对比</a></h3>
<p>把截至 2026 年 6 月 Anthropic、OpenAI、Google 的官方价格放进同一张表，趋势便一目了然。（单位为每百万 Token 的美元价格，主要选取编程工作流中常用的模型。）</p>
<table>
<thead>
<tr>
<th>提供商</th>
<th>模型</th>
<th>输入</th>
<th>缓存输入</th>
<th>输出</th>
</tr>
</thead>
<tbody>
<tr>
<td>Anthropic</td>
<td>Claude Opus 4.8</td>
<td>$5.00</td>
<td>$0.50</td>
<td>$25.00</td>
</tr>
<tr>
<td>Anthropic</td>
<td>Claude Sonnet 4.6</td>
<td>$3.00</td>
<td>$0.30</td>
<td>$15.00</td>
</tr>
<tr>
<td>Anthropic</td>
<td>Claude Haiku 4.5</td>
<td>$1.00</td>
<td>$0.10</td>
<td>$5.00</td>
</tr>
<tr>
<td>OpenAI</td>
<td>GPT-5.5</td>
<td>$5.00</td>
<td>$0.50</td>
<td>$30.00</td>
</tr>
<tr>
<td>OpenAI</td>
<td>GPT-5.5 Pro</td>
<td>$30.00</td>
<td>$3.00</td>
<td>$180.00</td>
</tr>
<tr>
<td>Google</td>
<td>Gemini 3.1 Pro</td>
<td>$2.00</td>
<td>$0.20</td>
<td>$12.00</td>
</tr>
<tr>
<td>Google</td>
<td>Gemini 3.5 Flash</td>
<td>$1.50</td>
<td>$0.15</td>
<td>$9.00</td>
</tr>
</tbody>
</table>
<p>三家提供商的输出价格都是输入的 5～6 倍。也就是说，输出越长，即使答案相同，费用也会陡增。同一档位内，不同模型的价格也可能相差 3～6 倍。只看简单计算就能得出降低 Token 成本的三条主线：<strong>缩短输出；答案相同时选更便宜的模型；把静态输入放进缓存</strong>。（再算一步会更有意思：假设每次调用都复用 1 万 Token 的静态上下文，共调用 100 次，按 GPT-5.5 基础价格需 $5，而从缓存读取只需 $0.50。缓存实际节省 $4.5，且第一次写缓存几乎没有附加成本，所以第二次调用起就开始获益。）</p>
<h3 id="缓存机制的差异"><a class="anchor" href="#缓存机制的差异">缓存机制的差异</a></h3>
<p>即使节省比例相近，各提供商的内部结构也并不相同。了解差异为何出现，会更容易制定策略。</p>
<table>
<thead>
<tr>
<th>项目</th>
<th>Anthropic</th>
<th>OpenAI</th>
<th>Google Gemini</th>
</tr>
</thead>
<tbody>
<tr>
<td>触发方式</td>
<td>显式 <code>cache_control</code> 断点</td>
<td>自动（无需改代码）</td>
<td>同时支持自动（implicit）与显式（explicit）</td>
</tr>
<tr>
<td>最小缓存 Token</td>
<td>1,024 ~ 4,096（因模型而异）</td>
<td>1,024+（按 128 递增）</td>
<td>2,048 ~ 4,096（因模型而异）</td>
</tr>
<tr>
<td>缓存写入费用</td>
<td>输入单价的 1.25x（5 分钟）/ 2x（1h）</td>
<td>免费</td>
<td>免费（另收按小时计算的存储费）</td>
</tr>
<tr>
<td>缓存读取费用</td>
<td>输入单价的 0.1x</td>
<td>输入单价的约 0.1x</td>
<td>输入单价的 0.1x</td>
</tr>
<tr>
<td>TTL</td>
<td><a href="https://github.com/anthropics/claude-code/issues/46829" target="_blank" rel="noopener noreferrer">5 分钟或 1 小时（用户选择）</a></td>
<td>默认闲置 5～10 分钟，最长 1 小时（扩展后 24 小时）</td>
<td>用户指定（按小时收取存储费）</td>
</tr>
<tr>
<td>额外费用</td>
<td>无</td>
<td>无</td>
<td>存储费 Flash $1/M·小时，Pro $4.50/M·小时</td>
</tr>
</tbody>
</table>
<p>三家的设计理念清晰可见。<strong>Anthropic</strong>要求用户明确指定缓存范围，第一次调用收取少量溢价（1.25 倍），之后则大幅降价。前缀的位置容易控制，缓存读取率也更可预测。<strong>OpenAI</strong>恰好相反，一切自动处理。超过 1,024 Token 就自动缓存且没有附加费用，但用户几乎无法干预缓存行为。<strong>Google</strong>同时提供两种方式，用户显式控制缓存时另收存储费。短小而频繁的缓存适合隐式方式，需要保存一小时以上的大型上下文则适合显式缓存加存储费的模式。</p>
<h3 id="工具定义与分词器"><a class="anchor" href="#工具定义与分词器">工具定义与分词器</a></h3>
<p>此外，还有两个影响出乎意料地大的变量。</p>
<p>第一是工具定义。在连接多个 MCP 服务器的环境中，每次调用都会把所有工具名称与参数模式定义一同作为输入发送（后文会直接测量这会膨胀多少）。即使工具不变，只换模型也会影响费用，这一点值得注意。Anthropic 官方价格文档显示，工具系统提示词的长度本身就因模型而异。Sonnet 4.6 与 Haiku 4.5 在 <code>tool_choice: auto</code> 下约为 497 Token，Opus 4.7 在同一位置使用 675 Token，Opus 4.8 又降到 290 Token。在 model routing 前先确认“当前模型会把工具定义展开多长”，常常能发现数量级不小的差异。</p>
<p>第二是分词器（把人类文本切分为模型可处理 Token 单位的工具）自身的效率。正如“Token 的工作原理”所述，同一文本经过不同分词器，Token 数也会不同。OpenAI 的 o200k_base 与 cl100k_base 相比，能显著减少非英语文本的 Token；Anthropic 则明确说明，从 Opus 4.7 起换用新分词器后，同一文本最多可能多用 35% 的 Token。只按单价选模型，可能又从分词器效率差异中漏掉成本。诚实的模型比较，应看单价×预计 Token 数的乘积。</p>
<p>实际比较时，韩语上的差异尤其明显。我用 OpenAI 的两种分词器处理同样的句子，英语技术句在新旧版本中的 Token 数相同，但韩语使用旧版（cl100k_base）时比新版（o200k_base）多出 31～43%。尤其值得注意的是一段韩语：167 个字符在旧版中变成 169 Token，出现了<strong>Token 数比字符数还多</strong>的结果，相当于一个韩文字平均消耗超过一个 Token。</p>
<p><picture><source type="image/webp" srcset="/content/260611/3.720w.webp 720w, /content/260611/3.1440w.webp 1440w, /content/260611/3.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260611/3.png" alt="不同 tokenizer 下韩语与英语 Token 数对比：cl100k 与 o200k" style="max-width:min(720px, 100%);height:auto;" width="1680" height="697" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>原因是旧版会把韩语按字节细细拆开，而新版能把“개발”“입니다”等常见片段整体合并为一个 Token。即使单价相同，以韩语为主的任务仅凭分词器效率差异，也可能让账单相差 1.5 倍以上。</p>
<p>看到这里，一个问题自然浮现：“那么，我们究竟在哪里制造了低效？”</p>
<h2 id="浪费-token-的常见模式"><a class="anchor" href="#浪费-token-的常见模式">浪费 Token 的常见模式</a></h2>
<p>即使自认为很会使用 AI，Token 低效流失的情况仍然很常见。把我自己和身边开发者的习惯归纳起来，大致有以下几类。</p>
<h3 id="过多的-mcp-服务器与工具定义"><a class="anchor" href="#过多的-mcp-服务器与工具定义">过多的 MCP 服务器与工具定义</a></h3>
<p>前面虽已提到，这里仍想再次强调。MCP 服务器（Linear、GitHub、Notion、Figma、Slack、Sentry）一旦接入，往往就很少移除。未使用的工具模式定义会在每次调用时膨胀为输入 Token。Claude Code 默认启用的 MCP Tool Search 正是为了解决这个问题：会话开始时只加载工具名称和服务器描述，直到真正调用某个工具时才加载完整模式定义。</p>
<p>我直接测量了这种差异。以工作中的 Claude Code 会话里 27 个 MCP 工具（serena 10 个、claude.ai OAuth 四组共 8 个、figma 2 个、agentmemory 7 个）为对象，使用<strong>同一条用户消息在两种配置下调用</strong>。一边完全没有安装 MCP，另一边已挂载了全部 27 个，但模型无法调用；两边的工具调用次数都设为 0。</p>
<p><picture><source type="image/webp" srcset="/content/260611/6.720w.webp 720w, /content/260611/6.1440w.webp 1440w, /content/260611/6.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/6.png" alt="同一个问题分别在不接 MCP 和接入 27 个 MCP 时调用的结果：输入 token 从 41 涨到 10,335，单次成本约为 11.7 倍" width="1680" height="638" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>问题相同、模型相同、回复含义也相同，输入 Token 却从 <strong>41 → 10,335（+10,294）</strong>（Opus 4.7）暴增。换算为单次费用，就是从 <strong>$0.0048 → $0.0563，约 12 倍</strong>。输入膨胀 250 倍，预填充负担也随之增加，响应时间多出 <strong>+783ms</strong>。比金额本身更直观的是：<strong>即使用户这一轮一次 MCP 工具都没有调用，这笔费用仍会在每次调用时产生</strong>。上一段的 Tool Search 防住的正是这笔成本。（完成这次测量后，我一直在清理平时不用的 MCP 服务器。）</p>
<h3 id="上下文累积与-lost-in-the-middle"><a class="anchor" href="#上下文累积与-lost-in-the-middle">上下文累积与 Lost in the Middle</a></h3>
<p><picture><source type="image/webp" srcset="/content/260611/4.720w.webp 720w, /content/260611/4.840w.webp 840w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/4.jpg" alt="《Lost in the Middle》论文首页，图中的 U 形曲线显示答案位于上下文中段时准确率最低" width="840" height="727" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>原封不动地延续长对话，不仅会增加每次调用的输入 Token，还会降低模型本身的正确率。Stanford 的 Liu 团队在“Lost in the Middle”论文中定量展示了一条 U 形曲线：关键信息位于上下文开头或末尾时最容易被找到，埋在中间时性能明显下降。于是出现最糟糕的组合——花更多 Token，得到更差的答案。Transformer 的自注意力计算量随 Token 数平方增长；上下文越长，每个 Token 能分到的绝对注意力越稀薄。再加上训练数据的重要信息往往集中在首尾，中间便最先变弱。</p>
<p>再深入一点，模型处理 Token“位置”的方式本身，就包含两种会让中间内容被淹没的习性。听起来复杂，用比喻解释却很简单。</p>
<p>第一，模型会<strong>更加留意附近的 Token</strong>。如今大多数公开模型采用的位置表示方式 RoPE（Rotary Position Embedding，旋转位置嵌入）被设计为：两个 Token 距离越远，彼此联系越弱（距离越远，信号越淡的 decay effect），因此远处 Token 自然更少受到关注。</p>
<p>第二，模型会<strong>把过多注意力无意义地流向第一个 Token</strong>。模型受到一种约束：每时每刻都必须把自己的注意力以 100% 的总量分配完毕（softmax 强制注意力总和为 1）。即使当前没有特别需要关注的地方，剩余注意力也必须丢到某处，而那个位置通常是句首的第一个 Token。第一个 Token 吸收多余注意力的现象称为 attention sink，即“注意力排水口”。（MIT 的 Xiao 团队在 StreamingLLM 论文中首次对其进行定量分析。它并非因为第一个 Token 包含重要信息，更像是把剩余注意力排出去所产生的副作用。）</p>
<p>两种习性叠加后，注意力会集中在两端（邻近的近期 Token＋第一个 Token），真正埋在中间的信息反而最弱。重要的是，这不是某个特定模型的缺陷。LLaMA、Mistral、Qwen 等多数公开模型采用 RoPE 系列，Claude、GPT 等闭源模型也被认为使用类似方法，因此 lost-in-the-middle 更像是现代 Transformer 共有的偏差。</p>
<p><picture><source type="image/webp" srcset="/content/260611/5.720w.webp 720w, /content/260611/5.1200w.webp 1200w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/5.png" alt="context rot 曲线图：输入越长，Claude、Qwen、OpenAI、Gemini 四个模型的准确率都在下降" width="1200" height="622" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>近来，这种现象被称为 <strong>context rot</strong>。<a href="https://research.trychroma.com/context-rot" target="_blank" rel="noopener noreferrer">Chroma 研究团队</a>让包括 GPT-4.1、Claude 4、Gemini 2.5、Qwen3 在内的 18 个前沿模型完成同一项 NIAH（needle in a haystack，大海捞针）任务。分析定量显示，当输入从 10k 增至 100k 以上时，正确率会因模型不同而降至 20～50%。18 个模型都随着长度增加而性能下降，下降最慢的是 Claude 系列。Anthropic 也将其解释为 Transformer 的 n² 注意力机制导致“注意力预算”被每个 Token 消耗的问题。保持上下文轻量，既是在省钱，也是在守住正确率。</p>
<h3 id="无节制地调用-subagent"><a class="anchor" href="#无节制地调用-subagent">无节制地调用 subagent</a></h3>
<p>因为 subagent 好用就把所有任务都交出去，同样是个陷阱。subagent 在独立于父级的上下文中启动，因此要从头装载系统提示词和工具定义，产生固定成本。把短 shell 命令、简单 git 查询之类的小任务交给 subagent，启动成本可能超过整理主上下文所省下的费用。Anthropic 公布的 multi-agent research system 报告显示，与普通对话相比，单个智能体约使用 4 倍 Token，multi-agent system 约使用 15 倍。只有正确率提升足以抵偿这 4～15 倍的额外费用时，委派才有意义。</p>
<p>同样的陷阱也适用于 MCP 工具挂载。每启用一个工具，其模式定义都会在每一轮进入系统提示词，即使模型当轮不用，也照样计费。“说不定会用，干脆全开”直觉上似乎安全，实测却并非如此。</p>
<p>我针对 facebook/react v19 的 reconciler 源码，把相同问题分成以下三种配置，测量正确率与费用。</p>
<ul>
<li>不使用工具</li>
<li>只使用一个工具（CodeGraph、Serena、ripgrep、bare grep）</li>
<li>同时挂载四个工具</li>
</ul>
<p><picture><source type="image/webp" srcset="/content/260611/9.720w.webp 720w, /content/260611/9.1440w.webp 1440w, /content/260611/9.1680w.webp 1680w" sizes="(max-width: 532px) calc(100vw - 32px), 500px"><img src="/content/260611/9.png" alt="codesearch-bench 不同工具场景的正确率与 Token 对比" style="max-width:min(500px, 100%);height:auto;" width="1680" height="1927" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>结果清楚显示了三个特点。（这里的召回率指正确答案被完整找出的程度。）</p>
<ul>
<li><strong>不使用工具</strong>的平均召回率只有 0.31，证明工具本身确实有价值。</li>
<li><strong>只使用 Serena（LSP</strong>）的召回率为 1.00，费用 $0.38，是所有单工具策略中效率最高的。</li>
<li><strong>挂载全部四个工具</strong>时，召回率反而降至 0.89，费用升至 $0.47。尤其在多跳问题中，全挂载配置的分数与单独使用 CodeGraph（0.78 / 0.88）精确到小数点后两位都相同。模型偏向四个工具中的一个，也完整继承了它的弱点。</li>
</ul>
<p>subagent 与工具挂载遵循同一条原则：只有额外成本能换来足够的正确率提升，才有意义。<strong>准确选择一个适合任务领域的工具，比“也许会用”而全部开启更便宜，也更准确。</strong></p>
<p>那么，经过验证的节省方法究竟是什么？真的存在可行的办法吗？</p>
<h2 id="经过验证的-token-节省方法"><a class="anchor" href="#经过验证的-token-节省方法">经过验证的 Token 节省方法</a></h2>
<p>每种节省模式针对不同的成本维度：有的减少输入，有的降低相同输入的单价，有的把相同任务交给更便宜的模型。下面逐一来看。</p>
<h3 id="prompt-caching"><a class="anchor" href="#prompt-caching">Prompt caching</a></h3>
<p>这是见效最快的方法。正如前文所述，缓存读取只需基础输入价格的 0.1 倍。第一次调用支付少量写入溢价（1.25 倍）后，从第二次起，相同静态部分便能以十分之一的价格重复使用。</p>
<p>从节省角度还要再强调一点：无论采用哪种方式，真正适合缓存的都是<strong>每次调用几乎不变的部分</strong>（工具定义、代码片段、RAG 上下文）。只要不把静态块与动态内容（本轮问题、刚得到的工具结果）混在一起，效果就不会打折。直接使用 API 时，把静态块集中放在前面、动态部分放到后面，便能实现大部分节省；使用 Claude Code、opencode 等成熟工具时，排列工作会由工具自动完成。</p>
<h3 id="用-batch-api-汇总异步任务"><a class="anchor" href="#用-batch-api-汇总异步任务">用 Batch API 汇总异步任务</a></h3>
<p>如果调用不必立即完成，Batch API 是直接降低单价最简单的工具。Anthropic、OpenAI、Google 都对输入和输出 Token 提供同样的 50% 折扣，代价是结果会在最长 24 小时内返回。Anthropic 的实测中，大多数批次会在一小时内完成，但 SLA 本身仍是 24 小时。调用内容完全不变，单价直接减半，采用成本几乎为零。</p>
<p>这 50% 最有意思之处在于，它会与其他节省机制相乘叠加。Anthropic 官方文档明确说明，缓存与批处理折扣会相乘（stack）。静态输入价格可降为标准价格×0.5（批处理）×0.1（缓存读取）＝0.05 倍，也就是标准价格的 5%。关键在于两项折扣不是相加，而是相乘。</p>
<p>我直接计算了差异。假设有 100 个任务，每个任务包含 1 万 Token 的静态上下文、500 Token 的动态输入和 1,000 Token 的输出，然后按 Opus 4.8 的价格比较四种策略。</p>
<p><picture><source type="image/webp" srcset="/content/260611/12.720w.webp 720w, /content/260611/12.1440w.webp 1440w, /content/260611/12.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260611/12.png" alt="Batch API 与 Prompt caching 的 Token 成本节省对比：两项折扣相乘叠加" style="max-width:min(720px, 100%);height:auto;" width="1680" height="670" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>只用缓存可节省 57%，只用批处理可节省 50%，两者结合则可节省 79%。原因是折扣并非简单相加，而是在静态输入部分相乘。（输出 Token 不属于缓存对象，只享受批处理的 50% 折扣，所以输出占比越高，整体节省幅度就越低于 79%；静态输入占比越高，相乘效果越明显。）夜间建立代码库索引、发布前文章评估、数据提取、定期报告等场景，并不需要人守在屏幕前等待，其范围比想象中更广。</p>
<p>但并非所有任务都能延后异步处理。在编程智能体主会话或交互式聊天界面中，用户需要根据回答决定下一步行动，响应时间本身就是价值，Batch 并不适合。仅仅把任务分为“必须马上看到结果”和“下次上班前收到即可”两类，就可能让账单减半。</p>
<h3 id="用-subagent-隔离高输出量任务"><a class="anchor" href="#用-subagent-隔离高输出量任务">用 Subagent 隔离高输出量任务</a></h3>
<p><img src="/content/260611/7.webp" alt="Subagent 上下文隔离结构" style="max-width:min(500px, 100%);height:auto;" width="1680" height="1680" loading="lazy" fetchpriority="low" decoding="async"></p>
<p><a href="https://www.anthropic.com/engineering/multi-agent-research-system" target="_blank" rel="noopener noreferrer">直接引用 Claude Code 官方文档的表述，</a>subagent“在自己隔离的上下文窗口中运行，中间工具调用及其结果留在 subagent 内，只有最终消息返回父级”。也就是说，把高输出量任务整体委派出去，父级上下文中只会留下整洁的摘要。<a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents" target="_blank" rel="noopener noreferrer">Anthropic 的 context engineering 文章</a>指出，即便 subagent 在探索中使用数万 Token，通常也只会向父级返回一份压缩到 1,000～2,000 Token 的摘要。保持父级上下文轻量的效果非常明确。</p>
<blockquote>
<p>高输出量任务是指为了得到一行答案，在过程中吐出大量 Token 的任务，例如运行测试、搜索文档、分析日志等。</p>
</blockquote>
<p>不过有一点需要澄清：subagent 并不等于总成本下降。Anthropic 自己的报告称，智能体使用的 Token 约为普通对话的 4 倍，multi-agent system 则约为 15 倍。subagent 是把大量输出从昂贵的长期上下文中移走，从而守住正确率和父级成本的机制，并非无条件削减总 Token 开支的魔法。因此，应遵循前文的原则：只有“整理主上下文所省费用高于启动成本”时才委派；短任务由父级直接完成更便宜。</p>
<p>还需要考虑适用领域。Anthropic 的 multi-agent research system 报告称，在内部评估中，Opus 4（负责人）＋Sonnet 4（多个 subagent）的组合相较单个 Opus 4 智能体性能提高了 90.2%。但这种提升并非在所有领域都一样。同一篇文章明确指出，“需要智能体共享同一上下文，或智能体之间依赖关系很多的领域不适合多智能体”，并把编程工作列为典型例子。研究任务可以沿独立方向并行探索，代码则运行在依赖图上，一个函数的变化会影响其他函数。（如果你正在把多智能体用于编程，不妨重新确认工作流是否真的接近并行探索。单个智能体加一个隔离的探索 subagent，或许是编程领域更安全的默认方案。）</p>
<h3 id="compact-与-progressive-disclosure"><a class="anchor" href="#compact-与-progressive-disclosure">compact 与 progressive disclosure</a></h3>
<p>Claude Code 的 <code>/compact</code> 会把截至当前的整段对话压缩成摘要，再以新上下文重新开始。按 Anthropic 官方说明，压缩并非简单截断，而是按语义总结：保留正在进行的任务背景与近期变更，丢弃重复工具输出等不太需要再次引用的内容。<code>/clear</code> 会清空一切，而 <code>/compact</code> 会留下摘要；当上下文窗口接近占满 95% 时，auto-compact 会自动执行相同操作。在适当节点整理过长会话，就能切断输入 Token 的累积。</p>
<p>再深入一点，<code>/compact</code> 只是用户显式调用的最后阶段，在它之前还有四级自动上下文压缩流程。根据分析 Claude Code 内部机制的外部研究<a href="https://github.com/VILA-Lab/Dive-into-Claude-Code" target="_blank" rel="noopener noreferrer">《Dive into Claude Code》</a>，每次调用前，<code>query.ts</code> 都会依次检查以下五个阶段。</p>
<p><picture><source type="image/webp" srcset="/content/260611/8.720w.webp 720w, /content/260611/8.1440w.webp 1440w, /content/260611/8.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/8.png" alt="智能体循环示意图：从用户提示开始，反复进行工具请求与权限确认，上下文吃紧时经过压缩再进入下一轮" width="1680" height="1591" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<ul>
<li><strong>Budget Reduction</strong> 会截掉单个工具输出超过大小上限的部分。</li>
<li><strong>Snip</strong> 会按时间轴截去较旧的历史记录。</li>
<li><strong>Microcompact</strong> 在保留缓存感知能力的同时进行细粒度压缩。</li>
<li><strong>Context Collapse</strong> 在 read-time 重新投影超长历史记录，以降低维度。</li>
<li><strong>Auto-Compact</strong> 在达到 95% 时作为最后手段，按语义执行压缩。</li>
</ul>
<p>越靠上越轻量、越便宜，越靠下越重但效果也越强。五级结构的设计依据是：单一压缩策略无法解决所有上下文压力。用户显式调用 <code>/compact</code>，近似于提前触发自动流程的最后一级。</p>
<p>相似思路也体现在 Claude Code 的 Skills 结构中。<code>/compact</code> 是事后缩减已累积的上下文，而 <code>/skills</code> 的三级加载则从一开始就避免累积。</p>
<p>根据 Anthropic 文档，技能分三阶段加载。会话开始时，上下文中只有名称和一行说明（约 100 Token）；正文（<code>SKILL.md</code>，少于 5,000 Token）直到技能被触发才加载；打包脚本或资源通过 bash 执行时，只把输出返回上下文，代码本身不会进入。因此，即使安装几十个技能，初始上下文也几乎不会增加。</p>
<h3 id="model-routing答案相同就交给更便宜的模型"><a class="anchor" href="#model-routing答案相同就交给更便宜的模型">model routing：答案相同，就交给更便宜的模型</a></h3>
<p><picture><source type="image/webp" srcset="/content/260611/13.720w.webp 720w, /content/260611/13.1172w.webp 1172w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/13.png" alt="模型性能与对数成本的分布图，理想路由器落在低成本高性能的位置" width="1172" height="1039" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Opus 4.8 与 Haiku 4.5 的输入价格相差 5 倍。如果连简单搜索、探索、短摘要都一律交给最大的模型，成本损失会很大。按照任务难度沿 Haiku → Sonnet → Opus 逐级路由，只在真正需要重度推理的阶段调用 Opus，正在成为标准模式。<a href="https://lmsys.org/blog/2024-07-01-routellm/" target="_blank" rel="noopener noreferrer">LMSYS 的 RouteLLM 研究</a>展示了一种路由器：在保持 GPT-4 质量 95% 的同时，把强模型调用比例降至 14%（需要注意，这项基准针对一般推理，并非编程专项）。</p>
<p>工具版图也在迅速稳定。商业网关有 OpenRouter、Martian、NotDiamond，开源自托管阵营则有 LMSYS 的 RouteLLM，以及 LiteLLM、Bifrost 等成本可视化层。在 Anthropic 生态中，Agent SDK 的模型选择参数、Claude Code subagent 的 <code>model</code> 字段（Explore 默认使用 Haiku）、<code>/model</code> 斜杠命令，都是为路由准备的机制。不过需要指出，直接调用 API 时，并不会根据输入难度自动选择模型。Anthropic、OpenAI、Google API 默认都会严格使用用户指定的模型名；自动路由只在产品层（Cursor 的 Auto、ChatGPT 的自动模式、OpenRouter 的 <code>openrouter/auto</code>）作为可选项存在。想通过路由降低费用，无论接入网关还是部署分类器，都要自行构建。</p>
<p>但也不能盲目接入外部自动路由器。OpenRouter Auto Router 这类每次调用都动态换模型的路由器，与前文的 prompt caching 正面冲突。Anthropic 的 ephemeral cache 只有在模型相同、前缀相同时才能命中；每次调用改变模型键，缓存键就会错位。缓存读取原本能降低九成单价，这个效果会完全丢失。用路由省下近一半，又因缓存未命中全部吐回去的情况并不少见。OpenRouter 意识到了这一点，才会推荐基于 <code>session_id</code> 的粘性选项。</p>
<p>因此，业界最终形成的方案反而很简单：不要每次调用都动态分类，而应按任务类型静态分支。前文 subagent 的 <code>model</code> 字段就是这种模式。Explore subagent 始终走 Haiku 的代码探索通道，代码审查 subagent 始终走 Opus 的审查通道。每条通道内重复相同模型、系统提示词和工具定义，各模型便能分别积累并命中自己的缓存。所以“路由正在成为标准”更接近这样的含义：标准并非每次运行分类器的动态路由，而是“协调者＋执行者”形式、按任务类型划分的静态分支。路由与缓存能在同一设计中不矛盾地共存，原因就在这里。</p>
<h3 id="cursor-composer-25"><a class="anchor" href="#cursor-composer-25">Cursor Composer 2.5</a></h3>
<p><img src="/content/260611/10.webp" alt="Cursor 公布的 CursorBench 3.1 分数与单任务平均成本对比图，Composer 2.5 在最便宜的区间接近高分" width="1680" height="1120" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>这是一种稍有不同的节省方法：使用 Cursor 这样的智能体。<a href="https://cursor.com/blog/composer-2-5" target="_blank" rel="noopener noreferrer">Cursor 于 2026 年 5 月 18 日发布的自研模型 Composer 2.5</a>以 Moonshot AI 的开源检查点 Kimi K2.5 为基础，针对编程任务进行了微调。Cursor 团队称，在自有基准中，它以大约十分之一的价格实现了可与 Claude Opus 4.7 相当的编程性能。公开基础价格为输入 $0.50、输出 $2.50；与 Opus 4.8 的输入 $5.00、输出 $25.00 相比，正好低一个数量级。</p>
<p>专用模型的基准数值由 Cursor 自己测量，因此与其照单全收绝对值，不如把它看作一种趋势：“面向编程训练的小模型，可以在与通用前沿模型相同的任务上达到相近的实用性能，同时把成本降低一个数量级。”这是 2026 年最明显的节省趋势之一。</p>
<p>真正有趣的不只是单价，而是<strong>与 IDE 共同设计时，输入 Token 本身也会减少</strong>。Composer、Cascade 等模型经过训练，可以高效消化 IDE 发送的代码库上下文（当前文件、相邻文件、已建立索引的符号）。同一任务中，通用模型往往要求“再给我看看相关文件”，反复执行 grep、read，输入 Token 随之膨胀；专用模型可减少这部分开销。单价降低一个数量级，再叠加 Token 数略减，实际节省会超过单纯的价格差。</p>
<h3 id="把上下文移到上下文之外"><a class="anchor" href="#把上下文移到上下文之外">把上下文移到上下文之外</a></h3>
<p>2026 年最值得关注的趋势，是只在“需要的时候，放入需要的内容”。Anthropic 称之为 <strong>just in time（JIT）上下文</strong>。它不把全部资料预先塞进上下文，而是只保留文件路径、查询等轻量引用，真正需要时再通过工具取回。Claude Code 不会把整个代码库建立索引后全部装载，而是通过 glob、grep 随用随读，正是这一模式。</p>
<p>Anthropic 随 Sonnet 4.5 一同发布的 memory tool 与 context editing，也源于相同理念。这里要特别说明：memory tool 的“记忆”与常见理解不同。它既不是会话内累积的对话记录（存在于上下文窗口中，会话结束便消失），也不是 <code>CLAUDE.md</code> 这类由人预先编写、启动时自动加载的配置文件。memory tool 是<strong>由模型直接读写文件的工具</strong>。</p>
<p>深入来看，两种工具的设计接口出乎意料地简单。memory tool 让 Claude 对用户基础设施中的专用记忆目录执行创建、读取、更新、删除文件四类操作。模型不是把内容“写到某段文本里”，而是像使用普通文件系统一样管理记忆。保存位置放在用户基础设施中是关键：Claude 得以在上下文窗口外持久保留记忆，而数据如何存储和保管仍由用户控制。会话记忆在上下文内会消失，这类记忆却留在外部并延续到下一会话。context editing 从相反方向完成同一件事：接近 Token 上限时，自动清除陈旧且不再引用的工具调用结果，保留对话脉络，让智能体能运行更久。</p>
<p>Anthropic 自己的评估显示，两者结合后相较基线性能提升 39%，单用 context editing 提升 29%；在 100 轮网页搜索评估中，Token 消耗减少 84%。（应考虑这是供应商自有基准，但方向十分明确：上下文设计的重点正从填满转向腾空。）这一趋势与 Claude Code 的 <code>/compact</code> 五级流程一脉相承。区别在于，<code>/compact</code> 在上下文内部做减法，memory tool 则在上下文外设置独立存储。两者并不冲突，而是互相补充。</p>
<h2 id="结语"><a class="anchor" href="#结语">结语</a></h2>
<p><img src="/content/260611/14.webp" alt="提示工程与上下文工程对照图：单轮提问只带系统提示与用户消息，而智能体需要挑选文档、工具与记忆，并把工具结果送回" width="1680" height="946" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>梳理到这里，节省 Token 最终可归结为三条轴线：<strong>同一件事，发送更少；同一份输入，用更低价格发送；同一个答案，交给更便宜的模型</strong>。Prompt caching、Batch API、subagent 隔离、<code>/compact</code>、memory tool 与 context editing、model routing、专用模型等模式，区别只在于它们攻击三条轴线中的哪一条。若用一句话概括 2026 年的趋势，那就是上下文的重心正从“填入内容的技术”转向“腾空并筛选内容的技术”，也就是 context engineering。正如 context rot 所示，轻量上下文不仅省钱，也有利于正确率。</p>
<p>当然，不存在唯一答案。不同团队的工作模式不同，同一个人写文章和写代码时，成本结构也不同。但有一点可以确定：生态变化越快，“昨天有效的节省模式今天仍然有效”这个假设就越危险。持续追踪新模型、新工具和新价格表，并在自己的任务上用小型 POC 验证，或许才是最不起眼却最持久的节省方法。也建议读者试着翻开自己的 Token 账本看一看。（如果想了解这些策略为何有效，可以到“Token 的工作原理”中继续阅读 BPE 与 KV cache。）</p>
<p>最后还剩下一个问题：当我们已经学会腾空和筛选上下文，下一步会走向哪里？沿着公开资料看，重心正向上下文再外移一层，扩展到如何运行智能体整体系统（harness 设计）、如何衡量它是否运行良好（eval），以及出错时如何限制它（containment）。有趣的是，本文一直强调的“亲自用 POC 测量”，一旦扩展到系统层面，就恰好成为 eval。关于接下来的方向，我准备在 <a href="/260622" target="_blank" rel="noopener noreferrer">上下文之后</a>中另行详述。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://platform.claude.com/docs/en/build-with-claude/prompt-caching" target="_blank" rel="noopener noreferrer">Anthropic Prompt Caching</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://platform.claude.com/docs/en/build-with-claude/batch-processing" target="_blank" rel="noopener noreferrer">Anthropic Batch Processing</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://platform.claude.com/docs/en/about-claude/pricing" target="_blank" rel="noopener noreferrer">Anthropic Claude API Pricing</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://code.claude.com/docs/en/sub-agents" target="_blank" rel="noopener noreferrer">Anthropic Claude Code Subagents</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://docs.claude.com/en/docs/claude-code/mcp" target="_blank" rel="noopener noreferrer">Anthropic Claude Code MCP Tool Search</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://docs.claude.com/en/docs/agents-and-tools/agent-skills/overview" target="_blank" rel="noopener noreferrer">Anthropic Agent Skills</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://developers.openai.com/api/docs/guides/prompt-caching" target="_blank" rel="noopener noreferrer">OpenAI Prompt Caching</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://openai.com/api/pricing/" target="_blank" rel="noopener noreferrer">OpenAI API Pricing</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://ai.google.dev/gemini-api/docs/caching" target="_blank" rel="noopener noreferrer">Google Gemini Context Caching</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://ai.google.dev/pricing" target="_blank" rel="noopener noreferrer">Google Gemini API Pricing</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.anthropic.com/news/context-management" target="_blank" rel="noopener noreferrer">Anthropic, Managing context on the Claude Developer Platform</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2601.07206" target="_blank" rel="noopener noreferrer">LLMRouterBench (Findings of ACL 2026)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2604.14228" target="_blank" rel="noopener noreferrer">VILA-Lab, Dive into Claude Code</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>Token</category>
        </item>
        <item>
            <title><![CDATA[Token 的工作原理]]></title>
            <link>https://hooninedev.com/zh-CN/260610</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/260610</guid>
            <pubDate>Wed, 10 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊一聊：AI Token 究竟是什么，又按照怎样的原理工作。 过去，我主要关注如何用好 AI 工具、哪些工具正在流行，以及它们为什么会流行。但在整理 Token 节省方法时，我重新意识到一件事：要讲清怎样降低成本，终究得先说明“Token 是什么、费用又是怎样计算的”，可我此前从未认真补上这块基础。（写着写着，光是 Token 工作原理就已经足够单独成篇了。） 因此，本文是进入节省技巧前...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊一聊：AI Token 究竟是什么，又按照怎样的原理工作。</p>
<p>过去，我主要关注如何用好 AI 工具、哪些工具正在流行，以及它们为什么会流行。但在整理 <a href="/260611" target="_blank" rel="noopener noreferrer">Token 节省方法</a>时，我重新意识到一件事：要讲清怎样降低成本，终究得先说明“Token 是什么、费用又是怎样计算的”，可我此前从未认真补上这块基础。（写着写着，光是 Token 工作原理就已经足够单独成篇了。）</p>
<p>因此，本文是进入节省技巧前的基础篇。我们将依次梳理：如果 Token 既不是单词也不是字符，它到底是什么（BPE）；它以什么形式进入模型（embedding）；为什么输出比输入更贵（prefill/decode）；prompt caching 降低单价的原因来自 Transformer（神经网络架构）的哪个环节（KV cache）；以及上下文越长为什么越贵（注意力的平方成本）。如果更关心实践方法，可以读完本文后继续阅读 Token 节省方法。</p>
<h2 id="token-既不是单词也不是字符"><a class="anchor" href="#token-既不是单词也不是字符">Token 既不是单词，也不是字符</a></h2>
<p>先从最基本的事实说起：<strong>Token 既不是单词，也不是字符。</strong> 它是模型把训练数据中频繁出现的字符序列压缩后建立的一种词表单位。</p>
<p>人类凭直觉询问“这句话有几个词”的方式，与模型切分文本的方式并不相同。模型会把经常共同出现的字符组合归为一个单位，并在文本输入时按照这套词表将其切分。因此，同样是一个单词，有的只对应一个 Token，有的却会被拆成多个 Token。</p>
<p>构建这套词表的算法，就是几乎所有现代 LLM 都共同依赖的 BPE。</p>
<h3 id="bpe-算法"><a class="anchor" href="#bpe-算法">BPE 算法</a></h3>
<p>BPE（Byte Pair Encoding，字节对编码）会反复将最常共同出现的相邻符号对合并为一个新符号，从而逐步扩充词表。</p>
<p>有趣的是，它最初并非为自然语言处理而生。Philip Gage 在 1994 年首次将 BPE 作为一种<a href="/240706" target="_blank" rel="noopener noreferrer">数据压缩</a>技术提出：用数据中未使用的一个字节替换出现频率最高的字节对，并把替换规则单独存入一张表。</p>
<p>后来，爱丁堡大学 Sennrich 团队把它引入了神经机器翻译的词表问题。在 2015 年公开、并于 ACL 2016 发表的论文《Neural Machine Translation of Rare Words with Subword Units》中，他们用 BPE 解决了固定词表无法处理生僻词和陌生词的局限。其思路是：与其完整记住单词，不如用更小的子词片段组合来表示，这样即使词表中没有某个单词，也能用已有片段编码出来。</p>
<p>它的工作流程出乎意料地简单。</p>
<ol>
<li>一开始，每个字符都是一个 Token。</li>
<li>在语料库中找出最常相邻出现的 Token 对。</li>
<li>把这对 Token 合并成一个新 Token，并在整个语料库中用新 Token 替换它们。</li>
<li>重复第 2～3 步，直到词表达到目标大小。</li>
</ol>
<p>因此，起初每个字符都是一个 Token；随后，“th”“the”“tion”等频繁出现的组合会逐渐合并成单个 Token。换句话说，越常见的模式，越容易形成更大的块。</p>
<p><img src="/content/260610/2.webp" alt="BPE tokenizer 反复合并高频相邻字符对、逐步扩充词表的过程" style="max-width:min(500px, 100%);height:auto;" width="1024" height="1536" loading="eager" fetchpriority="high" decoding="async"></p>
<p>GPT 系列在此基础上更进一步，采用<strong>字节级 BPE</strong>。它先把文本转换为 UTF-8 字节流，再应用 BPE。这样既能让基础词表从 256 项（字节的种类数）开始，又能在没有未知 Token 的情况下编码任何可由 UTF-8 表示的文本。无论是表情符号、汉字还是特殊符号，至少在字节层面都一定能够表示。</p>
<h3 id="相同文本不同-token-数"><a class="anchor" href="#相同文本不同-token-数">相同文本，不同 Token 数</a></h3>
<p>由于这种结构，同一段文本在不同 tokenizer（把人类书写的文本切成模型可处理 Token 的工具）中可能得到截然不同的 Token 数。词表的训练方式不同，同一句话就可能被切得更细，也可能组成更大的块。</p>
<p><picture><source type="image/webp" srcset="/content/260610/3.720w.webp 720w, /content/260610/3.1440w.webp 1440w, /content/260610/3.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260610/3.png" alt="同一文本在不同 tokenizer 中产生不同 Token 数；对非英语文本，o200k_base 比 cl100k_base 使用更少 Token" width="1680" height="997" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>OpenAI 随 GPT-4o 一起发布的资料清楚展示了这种差异。GPT-4o 系列的新 tokenizer o200k_base，比 GPT-4 系列的 cl100k_base 能用更少 Token 表示同一文本。英语的效率只提高约 1.1 倍，但越到非英语语言，差距越明显。在官方公布的短例句中，中文和日文的 Token 数约减少 1.4 倍，韩文约 1.7 倍，印地语最多约 2.9 倍。（从韩语用户的角度看，tokenizer 代际差异比英语用户感受到的更明显，这一点很有意思。）</p>
<p>从更广的范围看，语言本身也存在差异。Yennie Jun 使用 cl100k_base 比较多种语言后发现，表达相同含义时，英语几乎总是需要最少的 Token，而采用独立文字体系的语言往往会被切成更多 Token。印地语和孟加拉语可能需要英语的 5 倍，缅甸语甚至超过 10 倍。韩语和中文也比英语使用更多 Token，但没有那些语言那么极端。</p>
<p>这带来一个重要的实践结论。Anthropic 从 Opus 4.7 起引入新 tokenizer，并在官方定价文档中明确指出：“相同文本相较旧模型，最多可能按多 35% 的 Token 计费。”<strong>即使每 Token 单价不变，Token 数增加也会让账单同比增加。</strong> 因此，诚实地比较模型不能只看单价，还必须看“单价 × 预期 Token 数”。</p>
<p>不过，这些被切分的 Token 进入模型时，并不是以字符或单词原样进入。那么，Token 究竟以什么形态作为模型输入？</p>
<h2 id="token-如何进入模型"><a class="anchor" href="#token-如何进入模型">Token 如何进入模型</a></h2>
<p>神经网络不能直接处理文本。模型最终能够计算的只有数字，更准确地说，是向量（由多个数字组成的数组）。所以 Token 在进入模型前，要经过若干步骤转换为数值。</p>
<p>按顺序来看，流程如下。</p>
<ol>
<li><strong>文本 → Token</strong>：BPE tokenizer 将文本切分为 Token 片段。</li>
<li><strong>Token → Token ID</strong>：每个 Token 都会映射到一个整数 ID，指向它在词表中的位置。例如，如果 <code>" the"</code> 是词表中的第 1,234 个 Token，它的 ID 就是 <code>1234</code>。</li>
<li><strong>Token ID → embedding 向量</strong>：用这个 ID 取出 embedding 矩阵中对应的一行。embedding 矩阵是一张尺寸为 <code>vocabulary size × model dimension (d_model)</code> 的巨大表，每个 Token ID 对应其中一行，也就是一个向量。可以把这个向量理解为承载 Token“含义”的一组坐标。</li>
<li><strong>加入位置信息</strong>：自注意力本身并不知道 Token 的顺序。也就是说，它会把“我喜欢你”和“你我喜欢”看成相同内容。因此，需要向每个 Token 向量注入位置信息，让模型知道排列顺序。</li>
</ol>
<p>仅靠文字会显得抽象，因此这里放了一个可以亲手操作这四个步骤的交互工具。改变输入句子或点击某个 Token，就能沿着它从 ID 变成 embedding 向量、再加入位置信息成为最终输入向量的路径查看。（展示时减少了维度数量，但实际 GPT-4 的向量有 12,288 维，可以带着这个概念观察。）</p>
<p>这里的模型维度（d_model）是表示一个 Token 的向量长度。最初的 Transformer 论文中，这个值是 512；如今的 LLM 则大得多。（仅 Llama 2 的 7B 模型就有 4,096 维。）模型维度越大，越能丰富地表达 Token 含义的细微差别，但计算量和内存占用也会随之增加。</p>
<p><picture><source type="image/webp" srcset="/content/260610/4.704w.webp 704w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260610/4.png" alt="Token ID 查询 embedding 矩阵中的一行，并转换为 d_model 维向量的结构" width="704" height="435" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>注入位置信息的方式也随着技术演进而变化。最初的 Transformer 直接把用正弦和余弦函数构造的位置向量加到 embedding 上；如今公开 LLM 事实上普遍采用 RoPE（Rotary Position Embedding，旋转位置 embedding），通过旋转 Query/Key 向量，在注意力计算阶段注入相对位置。（方式虽不同，目的都是“让 Transformer 获得顺序感”。）</p>
<p>简而言之，完整流程是：<strong>文本 → Token → Token ID → embedding 向量（+ 位置信息）→ Transformer 输入</strong>。账单上的“Token 数”，就是这个流程第二步的数量，也就是文本被切成了多少个 Token。</p>
<p>至此，我们已经知道 Token 如何生成，又以什么形式进入模型。那么模型处理这些 Token 时，输入和输出成本为何相差如此悬殊？重复发送相同输入的成本又如何降低？</p>
<h2 id="为什么输入便宜输出昂贵"><a class="anchor" href="#为什么输入便宜输出昂贵">为什么输入便宜、输出昂贵</a></h2>
<p>接触过 Token 的人多半都疑惑过：定价表上，<strong>输出 Token 往往比输入 Token 贵好几倍。</strong> Anthropic 的所有模型中，输出单价都恰好是输入单价的 5 倍。（Opus 输入 $5／输出 $25，Haiku 输入 $1／输出 $5。）明明是同一个 Token，为什么进入与输出时价格不同？</p>
<p>答案在于，模型处理输入和输出 Token 的方式完全不同。LLM 推理分为两个阶段。</p>
<ul>
<li><strong>prefill（输入处理）</strong>：把提示词中的全部 Token <strong>一次性并行</strong>处理。无论是 1,000 个还是 10,000 个 Token，GPU 都会一并扫描，计算每个 Token 的 K/V（Key/Value）。虽然总计算量很大，但并行处理让每 Token 的效率很高。</li>
<li><strong>decode（输出生成）</strong>：响应 Token <strong>一次一个、按顺序</strong>生成。每生成一个 Token，就把它追加到输入中，再生成下一个，直到响应结束。</li>
</ul>
<p>这种不对称造成了成本差异。Databricks 的推理性能分析指出，prefill 是受计算能力限制的阶段，而 decode 是受内存带宽限制的阶段。decode 每生成一个 Token，都必须从 GPU 内存重新读取整个模型的权重，可一次却只得到一个 Token。GPU 庞大的计算能力大部分都处于闲置状态。（借用 Databricks 的说法，就是花钱开着 GPU，却没能利用可用的计算能力。）</p>
<p>也就是说，<strong>输入 Token 能在并行 prefill 中一次高效处理，输出 Token 却必须逐个低效地挤出来。</strong> 需求、利润等商业因素当然也可能影响定价，但至少从技术上看，decode 阶段的这种低效构成了底层原因。因此，“缩短输出”不只是一条普通的省钱技巧，而是在直接削减最昂贵的阶段。</p>
<p>那么，有没有办法让低效的 decode 稍微高效一些？KV cache 正是在这里登场。</p>
<h2 id="缓存降低单价的原理"><a class="anchor" href="#缓存降低单价的原理">缓存降低单价的原理</a></h2>
<p>prompt caching 会先把静态输入存入缓存，让后续调用以低得多的价格再次读取。按照 Anthropic 官方文档，缓存读取的价格是基础输入单价的 0.1 倍，也就是 10%。缓存写入在 5 分钟 TTL（Time To Live，缓存有效期）下为 1.25 倍，在 1 小时 TTL 下为 2 倍。也就是第一次调用多付一点，从第二次起节省 90%。</p>
<p>但为什么价格能下降 90%？为什么“前缀必须完全一致”的条件如此严格？只要看一眼 Transformer 内部，两者的答案就会清晰起来。</p>
<h3 id="kv-cache"><a class="anchor" href="#kv-cache">KV cache</a></h3>
<p>模型处理输入 Token 时，会为每个 Token 创建对应的 Query/Key/Value（Q/K/V）向量。自注意力用 Query 与 Key 的点积计算注意力分数，再用这些分数对 Value 做加权和得到输出。可以把它理解为决定一个 Token 应该“参考”其他 Token 多少的计算。</p>
<p>前面提到的 decode 阶段正是在这里遇到问题。每生成一个新 Token，都需要前面所有 Token 的 K/V；如果每次都从头计算，同样的 K/V 就会无休止地重复计算。</p>
<p><img src="/content/260610/1.webp" alt="Transformer KV cache 的工作结构" width="1536" height="1024" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>KV cache 正是为了消除这种重复而引入。它会存储计算过的 K/V；生成新 Token 时，不再重新计算前面 Token 的 K/V，而是从缓存读取并复用。这样，在一个序列中多生成一个 Token 的成本，就从每一步都重算整个序列的 O(n²)，降为只需读取缓存的 O(n)。（严格说来，这是每一步的成本；核心思想很简单：“已经算过的就不要再算。”）decode 之所以受内存限制，也因为每一步都要从内存重新读取这份 KV cache。</p>
<h3 id="跨调用复用"><a class="anchor" href="#跨调用复用">跨调用复用</a></h3>
<p>如果说 KV cache 最初是为了加速单次响应内部的 decode，那么 prompt caching 的想法就是把这套内存结构<strong>不只用于一次调用内部，而是在调用与调用之间继续复用</strong>。</p>
<p>静态前缀（系统提示词、工具定义、代码片段等每次基本相同的开头部分）的 K/V，会在 5 分钟或 1 小时 TTL 内留在 GPU 内存中。下一次调用若从相同前缀开始，模型就会完全跳过这部分 K/V 计算，直接从缓存读取。准确地说，并不是输入 Token 单价凭空变低，而是<strong>计算这些 Token 所需的 GPU 工作本身消失了。</strong> 90% 的降价，是把节省的计算反映到价格上的结果。</p>
<p>理解这一机制后，也就能自然明白“前缀必须完全一致”为何如此严格。缓存命中通过比较前缀的累计哈希来判断；而自注意力具有因果性，只要前面的一个 Token 改变，后面所有 Token 的 K/V 都会改变。哪怕只在提示词最前面加入一行时间戳，它每次调用时变化，都会让前缀哈希不同，使后续整段缓存失效。</p>
<p>Anthropic 的缓存按 <code>tools</code> → <code>system</code> → <code>messages</code> 的层级顺序读取。因此，前部哪怕只改动一个工具定义，后面的全部缓存也会失效。结论很简单：<strong>静态内容放前面，每次变化的动态内容放后面，缓存才能存活。</strong></p>
<p>缓存还有一些不太为人熟知的限制。可缓存的最少 Token 数会因模型而异，同一系列的不同版本也可能不同。短于这一阈值的提示词即使开启缓存，也会悄无声息地不被保存。（这些最少 Token 数和不同服务商的缓存策略直接关系到成本设计，因此我在 Token 节省方法一文中结合定价表作了详细说明。）</p>
<h2 id="上下文窗口为什么形成-token-上限"><a class="anchor" href="#上下文窗口为什么形成-token-上限">上下文窗口为什么形成 Token 上限</a></h2>
<p>最后，谈 Token 绕不开的概念是上下文窗口：模型一次最多能够接收多少 Token。理解这项限制为何存在，也就能看清“上下文越长越贵”这句常见警告背后的原因。</p>
<p>关键在自注意力的计算结构。注意力会让每个 Token 各自参考所有其他 Token 一次。若有 n 个 Token，Token 对的数量就是 n × n，也就是说，<strong>自注意力的计算量与内存需求会随序列长度 n 的平方增长。</strong> Token 数翻倍，注意力成本就变为 4 倍。（需要说明的是，并非整个模型都是 O(n²)，而是注意力阶段如此。）</p>
<p><picture><source type="image/webp" srcset="/content/260610/5.330w.webp 330w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260610/5.png" alt="每个 Token 都参考所有其他 Token 的自注意力矩阵；n 个 Token 会产生 n×n 个 Token 对" width="330" height="304" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p><a href="https://huggingface.co/docs/transformers/en/llm_tutorial_optimization" target="_blank" rel="noopener noreferrer">Hugging Face 的估算</a>清楚展示了这种平方增长有多陡。在未经优化的标准注意力中，仅保存注意力分数矩阵，输入为 1,000 个 Token 时约需 50MB；16,000 个 Token 时约需 19GB；100,000 个 Token 时则接近 1TB。</p>
<p>此外，前面提到的 KV cache 也会按 Token 数量占用内存。上下文越长，必须保存的 K/V 就越多，并呈线性累积。因此，上下文窗口上限本质上是模型面对“再长下去，内存和计算就无法承受”这一物理限制所划出的边界。</p>
<p>所以，原样保留一段很长的对话，不只是增加输入 Token 数这么简单。注意力成本按平方增长，而且 Token 数越接近上限，模型准确处理信息的能力也会下降。（这种准确率下降与 Token 节省方法一文所谈的上下文退化直接相关；保持上下文精简之所以同时有利于成本和答案质量，原因就在这里。）</p>
<h2 id="总结"><a class="anchor" href="#总结">总结</a></h2>
<p>梳理到这里，Token 的工作原理可以浓缩为几个简单事实：<strong>Token 是 BPE 把高频模式合并后形成的词表单位</strong>，它经过 Token ID 映射，变为 embedding 向量后进入模型。<strong>输入可以通过并行 prefill 一次处理，而输出必须逐个 Token 挤出，因此更加昂贵</strong>；KV cache 则通过复用 K/V 来缓解这种低效。<strong>缓存能降低单价并不是魔法，而是 Transformer 不必重新计算已经算过的 K/V</strong>；前缀必须完全一致这一严格条件，也直接源自这种结构。最后，上下文越长越贵，是因为注意力成本按 Token 数的平方增长。</p>
<p>掌握这层基础后，成本问题就清楚得多。为什么输出贵、怎样安排提示词才不会破坏缓存、为什么应当保持上下文精简、在答案相同时为什么应该换用更便宜的模型——这些节省策略都建立在上述原理之上。如果你是为了理解工作机制读到这里，不妨继续阅读 Token 节省方法，看看这些原理如何真正降低费用。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://en.wikipedia.org/wiki/Byte-pair_encoding" target="_blank" rel="noopener noreferrer">Philip Gage, A New Algorithm for Data Compression (1994)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/1508.07909" target="_blank" rel="noopener noreferrer">Sennrich, Haddow, Birch, Neural Machine Translation of Rare Words with Subword Units (ACL 2016)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://jalammar.github.io/illustrated-transformer/" target="_blank" rel="noopener noreferrer">Jay Alammar, The Illustrated Transformer</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://huggingface.co/learn/nlp-course/en/chapter6/5" target="_blank" rel="noopener noreferrer">HuggingFace, Byte-Pair Encoding tokenization</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://huggingface.co/docs/transformers/en/kv_cache" target="_blank" rel="noopener noreferrer">HuggingFace, Transformers KV Cache strategies</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/openai/tiktoken" target="_blank" rel="noopener noreferrer">OpenAI, tiktoken</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://openai.com/index/hello-gpt-4o/" target="_blank" rel="noopener noreferrer">OpenAI, Hello GPT-4o (Language tokenization)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.artfish.ai/p/all-languages-are-not-created-tokenized" target="_blank" rel="noopener noreferrer">Yennie Jun, All languages are NOT created (tokenized) equal</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.databricks.com/blog/llm-inference-performance-engineering-best-practices" target="_blank" rel="noopener noreferrer">Databricks, LLM Inference Performance Engineering Best Practices</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.baseten.co/blog/llm-transformer-inference-guide/" target="_blank" rel="noopener noreferrer">Baseten, A guide to LLM inference and performance</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching" target="_blank" rel="noopener noreferrer">Anthropic, Prompt Caching</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>Token</category>
        </item>
        <item>
            <title><![CDATA[上下文文件]]></title>
            <link>https://hooninedev.com/zh-CN/260529</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/260529</guid>
            <pubDate>Fri, 29 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[本文想聊一聊AI 编程智能体读取的上下文文件。 本文写给这样的开发者：一个项目里同时堆着 CLAUDE.md、AGENTS.md、SKILL.md 和 .cursor/rules，却搞不清它们各自何时被读取、该写些什么。读完之后，你会了解这些文件的区别、智能体读取它们的方式，以及基于 ETH Zurich 研究的判断标准：只写智能体无法推断的信息。 作为一名前端开发者，我在日常工作中经常使用 Cl...]]></description>
            <content:encoded><![CDATA[<p>本文想聊一聊<strong>AI 编程智能体读取的上下文文件</strong>。</p>
<p>本文写给这样的开发者：一个项目里同时堆着 <code>CLAUDE.md</code>、<code>AGENTS.md</code>、<code>SKILL.md</code> 和 <code>.cursor/rules</code>，却搞不清它们各自何时被读取、该写些什么。读完之后，你会了解这些文件的区别、智能体读取它们的方式，以及基于 ETH Zurich 研究的判断标准：只写智能体无法推断的信息。</p>
<p>作为一名前端开发者，我在日常工作中经常使用 Claude。渐渐地，项目根目录里多出了 <code>CLAUDE.md</code>，旁边还有别人创建的 <code>AGENTS.md</code>，某个角落仍留着 <code>.cursorrules</code>，我也曾照着某篇文章建起 <code>.claude/skills/</code> 文件夹。（回过神来，记录着相似内容的文件已经有五个左右了。）</p>
<h2 id="没有持久记忆的智能体"><a class="anchor" href="#没有持久记忆的智能体">没有持久记忆的智能体</a></h2>
<p>AI 编程智能体存在一个根本局限：<strong>它没有持久记忆</strong>。每个 session 都从空白状态开始，昨天约定的 convention，或一小时前介绍过的文件夹结构，到了下一次对话就无法记住。上下文文件是解决这个问题最简单的装置：只要在项目中放置一个每次 session 开始时都会自动读取的文件，就不必反复说明同一件事。</p>
<p>问题在于，各种工具围绕同一种思路分别创建了自己的文件。Claude Code 读取 <code>CLAUDE.md</code>，Cursor 读取 <code>.cursorrules</code>（现已 deprecated，官方建议改用 <code>.cursor/rules</code>），GitHub Copilot 读取 <code>.github/copilot-instructions.md</code>，OpenAI Codex 则读取 <code>AGENTS.md</code>。当一个团队同时使用多种工具时，同样的内容就得复制到四个地方。</p>
<h3 id="claudemd"><a class="anchor" href="#claudemd">CLAUDE.md</a></h3>
<p><code>CLAUDE.md</code> 是 Claude Code 在 session 开始时自动读取的文件。根据 Anthropic 官方文档（<code>code.claude.com/docs/en/memory</code>），Claude Code 会在以下三个层级查找 <code>CLAUDE.md</code>。</p>
<ul>
<li><strong>用户记忆</strong>（<code>~/.claude/CLAUDE.md</code>）：适用于本机所有项目的全局默认值</li>
<li><strong>项目记忆</strong>（项目根目录下的 <code>CLAUDE.md</code>）：提交到 git，由整个团队共享</li>
<li><strong>本地记忆</strong>（子目录中的 <code>CLAUDE.md</code>）：仅在该目录内工作时额外加载</li>
</ul>
<p>三个层级同时存在时，Claude 会<strong>全部读取并串联（concatenate）</strong>。它并非按优先级只选一个，而是像 CSS cascade 一样，将更具体的内容继续叠加上去。（这是合并，不是覆盖。）因此，如果把同一主题的规则分散到多个层级，就可能产生冲突。（Anthropic 官方文档明确指出，指令冲突时的行为不受保证。）</p>
<p>这里有一点经常被忽略：Claude 会<strong>从当前工作目录一路向上走到仓库根目录，并读取沿途遇到的所有 <code>CLAUDE.md</code></strong>。所以在 monorepo 的 <code>packages/ui/</code> 中工作时，根目录的 <code>CLAUDE.md</code> 与 <code>packages/ui/CLAUDE.md</code> 都会被加载。（这很强大，但也意味着上下文可能在不知不觉间膨胀。）</p>
<h3 id="agentsmd"><a class="anchor" href="#agentsmd">AGENTS.md</a></h3>
<p><code>AGENTS.md</code> 是为解决上述各工具专属文件泛滥而创建的标准。2025 年 12 月，OpenAI 将这一标准捐赠给 Linux Foundation 旗下的 <strong>Agentic AI Foundation</strong>（AAIF）。据 <a href="https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation" target="_blank" rel="noopener noreferrer">Linux Foundation 的公告</a>，AAIF 由 Anthropic、Block、OpenAI 共同创立，创始项目有三个：Anthropic 的 MCP（把智能体连接到外部系统的协议）、Block 的 goose，以及 OpenAI 的 <code>AGENTS.md</code>。官方网站（<code>agents.md</code>）明确表示，<strong>已有超过 6 万个开源仓库采用该文件</strong>。</p>
<p>看看支持工具的名单，这一点就更清楚了。OpenAI Codex、Google Jules、VS Code、GitHub Copilot、Cursor、JetBrains Junie、Aider、Devin、Zed、Factory、Warp、goose、opencode、Amp、RooCode、Gemini CLI、Kilo Code、Phoenix、Semgrep、Ona、Windsurf、Augment Code 等众多工具都提供支持。GitHub Copilot 从 2025 年 8 月起原生支持 <code>AGENTS.md</code>。有趣的是，<strong>Claude Code 对 <code>AGENTS.md</code> 的原生支持目前仍处于 active feature request 状态</strong>。Claude Code 依然将 <code>CLAUDE.md</code> 视为主要文件。</p>
<p>它虽被称为标准，但人们可能仍会怀疑是否真的有人采用。最有力的证据就是 <strong>dogfooding</strong>，也就是亲自使用自己制定的标准。</p>
<ul>
<li><strong>Vercel/Next.js</strong> 的 canary 分支根目录中有一个 <code>AGENTS.md</code>。它实际上是指向 <code>CLAUDE.md</code> 的符号链接，其中包含 monorepo 结构、通过 <code>pnpm --filter=next dev</code> 进行 1～2 秒级迭代、Turbopack 与 Webpack 双端测试指南、<code>pr-status</code> 脚本，以及环境变量和 secret 的处理规则。<code>create-next-app</code> 后来改为在新项目中同时生成 <code>AGENTS.md</code> 和 <code>CLAUDE.md</code>，也是同一趋势的体现。</li>
<li><strong>OpenAI/codex</strong> 仓库本身也维护着自己的 <code>AGENTS.md</code>。</li>
</ul>
<p>一种标准策略正在逐渐成形：将 <strong><code>AGENTS.md</code> 作为单一信息源（single source of truth）</strong>，尽可能精简 <code>CLAUDE.md</code>，只保留一行对 <code>AGENTS.md</code> 的引用，以及 Claude Code 专用指令。这样既消除了重复，又因为 Claude Code 会读取两个文件而不会丢失任何信息。</p>
<h3 id="skillmd"><a class="anchor" href="#skillmd">SKILL.md</a></h3>
<p><code>SKILL.md</code> 与前两个文件性质不同。<code>CLAUDE.md</code> 和 <code>AGENTS.md</code> 是<strong>始终存在于上下文中的持久指令</strong>，而 Skill 是<strong>只在需要时调用的按需能力</strong>。</p>
<p>Skill 以文件夹为单位组织。文件夹中包含一个 <code>SKILL.md</code>、该 Skill 会执行的脚本，以及额外的 Markdown 文档。只有当前任务与 Skill 的 <code>description</code> 匹配时，Claude 才会加载该文件夹。这称为 <strong>progressive disclosure（渐进式披露）</strong>。这一概念由 Jakob Nielsen 于 1995 年在 UX 领域确立：把高级或较少使用的功能放到辅助界面，让用户一次只专注于一项任务，从而降低认知负荷和错误。在 Claude Skills 的语境中，它指“只在需要时才把 Skill 正文载入上下文”的机制。这样可以大幅节省上下文窗口成本。</p>
<p><code>SKILL.md</code> 的 frontmatter 中有几个独有字段。</p>
<ul>
<li><strong><code>description</code></strong>：说明何种情况下需要该 Skill，是模型判断是否调用它的触发条件</li>
<li><strong><code>allowed-tools</code></strong>：限制 Skill 内可使用的工具（例如 <code>"Read, Glob, Grep, Bash(python:*)"</code>）</li>
<li><strong><code>disable-model-invocation: true</code></strong>：禁止模型调用，只允许用户通过斜杠命令触发。用于部署、提交等带有副作用的操作</li>
<li><strong><code>user-invocable: false</code></strong>：不在用户的斜杠菜单中显示，仅由 Claude 自主调用，适合作为背景知识</li>
</ul>
<p>Claude Skills 于 2025 年 10 月 16 日在 Claude.ai、Claude Code、API 与 Agent SDK 中同时推出。随后在 2025 年 12 月 18 日，Anthropic 又将 Skills 规范本身发布为开放标准（<code>agentskills.io</code>）。Simon Willison 甚至评价道：“<strong>Skills are awesome, maybe a bigger deal than MCP</strong>。”原因在于，它的形式比 MCP 简洁得多，同时借助 progressive disclosure 解决了上下文窗口成本问题。</p>
<p>这里与 Skills 相比较的 MCP（Model Context Protocol），是把智能体与 Slack、GitHub、DB 等外部系统连接起来、使其能够调用这些系统的标准协议。如果说上下文文件关乎该告诉智能体什么，MCP 关乎的就是该让智能体能做什么。MCP 与 function calling 有何不同，另外整理在<a href="/260524" target="_blank" rel="noopener noreferrer">MCP 与 function calling</a>中。</p>
<p>帮助智能体降低查找相关代码成本的工具（Repomix、Aider、CodeGraph、Serena），因理解代码的深度不同，所降低的成本也不同。相关比较另外整理在<a href="/260526" target="_blank" rel="noopener noreferrer">代码智能的四个层级</a>中。</p>
<h3 id="其他工具使用的文件"><a class="anchor" href="#其他工具使用的文件">其他工具使用的文件</a></h3>
<p>Cursor 的 <code>.cursorrules</code> 从 <strong>0.43 版本起已 deprecated</strong>。目前官方建议在 <code>.cursor/rules/</code> 目录中放置多个 <code>.mdc</code> 文件。每个 <code>.mdc</code> 文件都带有 YAML frontmatter。</p>
<ul>
<li><strong><code>description</code></strong>：供智能体判断该规则是否相关</li>
<li><strong><code>globs</code></strong>：当匹配的文件进入对话时自动附加（auto-attach）</li>
<li><strong><code>alwaysApply</code></strong>：设为 <code>true</code> 时无条件加入每次对话（此时忽略 <code>globs</code>）</li>
</ul>
<p>GitHub Copilot 也朝着类似方向演进。仓库全局指令放在 <code>.github/copilot-instructions.md</code> 中；需要按路径限定作用域的指令，则放进 <code>.github/instructions/*.instructions.md</code> 文件，并通过 frontmatter 的 <code>applyTo:</code> 键指定 glob。（Copilot code review 从 2025 年 9 月起正式支持 path-scoped instructions。）</p>
<p>Cursor、Copilot 以外的工具也都在向相似模式靠拢。汇总如下。</p>
<table>
<thead>
<tr>
<th>工具</th>
<th>文件/目录</th>
<th>特点</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Claude Code</strong></td>
<td><code>CLAUDE.md</code>（3 个层级）</td>
<td>沿目录树合并</td>
</tr>
<tr>
<td><strong>Cursor</strong></td>
<td><code>.cursor/rules/*.mdc</code></td>
<td>通过 <code>globs</code> 限定文件模式作用域</td>
</tr>
<tr>
<td><strong>GitHub Copilot</strong></td>
<td><code>.github/copilot-instructions.md</code> + <code>.github/instructions/*.instructions.md</code></td>
<td>支持 <code>applyTo</code> glob</td>
</tr>
<tr>
<td><strong>Cline</strong></td>
<td><code>.clinerules/</code> 目录</td>
<td>合并所有 <code>.md</code>/<code>.txt</code>，通过 <code>paths</code> glob 条件激活</td>
</tr>
<tr>
<td><strong>Continue.dev</strong></td>
<td><code>.continue/rules/*.md</code></td>
<td><code>name</code>/<code>globs</code>/<code>alwaysApply</code> frontmatter</td>
</tr>
<tr>
<td><strong>Aider</strong></td>
<td><code>CONVENTIONS.md</code> + <code>.aider.conf.yml</code></td>
<td>每次请求都包含，<strong>建议控制在 200 行以内</strong></td>
</tr>
<tr>
<td><strong>Windsurf</strong></td>
<td><code>.windsurfrules</code> + <code>global_rules.md</code></td>
<td>全局与项目两级</td>
</tr>
<tr>
<td><strong>标准</strong></td>
<td><code>AGENTS.md</code>（AAIF）</td>
<td>已被 60,000+ 个仓库采用</td>
</tr>
</tbody>
</table>
<p>尤其值得注意的是 <strong>Aider 的 <code>CONVENTIONS.md</code></strong>。官方文档明确指出，由于每次请求都会将该文件完整加入上下文，因此应“<strong>保持在 200 行以内</strong>”。（可以说，Aider 很早就意识到这一限制，并明确提醒了用户。）</p>
<h3 id="memorymd"><a class="anchor" href="#memorymd">MEMORY.md</a></h3>
<p>除了上述文件，还有一种模式正越来越常见：<code>MEMORY.md</code>。它不是官方标准，而是社区自发形成的 convention，用于<strong>记录随时间累积的决策与失误</strong>。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="markdown" data-theme="github-dark github-light"><code data-language="markdown" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-dark-font-weight:bold;--shiki-light:#005CC5;--shiki-light-font-weight:bold">## 2026-04-10</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Pages Router에서 App Router로 이전. 신규 라우트는 App Router 컨벤션 사용.</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-dark-font-weight:bold;--shiki-light:#005CC5;--shiki-light-font-weight:bold">## 2026-04-22</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Prisma 쿼리 결과에 optional chaining 쓰지 말 것 — null은 if-check로 명시적 처리.</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(이전에 옵셔널 체이닝으로 null을 흘려보내 프로덕션 이슈 발생.)</span></span></code></pre></figure>
<p>如果说 <code>CLAUDE.md</code> 或 <code>AGENTS.md</code> 记录的是<strong>当前规则</strong>，那么 <code>MEMORY.md</code> 记录的就是<strong>这些规则为何形成的历史</strong>。（两者是互补关系，而非替代关系。）</p>
<h3 id="智能体如何读取这些文件"><a class="anchor" href="#智能体如何读取这些文件">智能体如何读取这些文件</a></h3>
<p>到目前为止，我们梳理了有哪些文件。但还有一个经常被遗漏的问题：<strong>智能体究竟把这些文件读到哪里，又是怎样读取的？</strong> 理解这一机制，就更容易明白后文 ETH Zurich 的研究结果——上下文文件中的指令并不会被可靠遵循——为何会出现。</p>
<p>首先要明确一个事实：<strong><code>CLAUDE.md</code> 不是 system prompt，而是以 user message 的形式注入。</strong> Anthropic 官方文档明确写道：</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>CLAUDE.md 的内容会在 system prompt 之后以 user message 的形式传递，而不是作为 system prompt 的一部分。Claude 会读取并尝试遵循它，但不保证严格执行。</p></div><div class="quote-original" lang="en"><p>CLAUDE.md content is delivered as a user message after the system prompt, not as part of the system prompt itself. Claude reads it and tries to follow it, but there's no guarantee of strict compliance.</p></div></blockquote>
<p>也就是说，它不是强制规则，而是“参考上下文”。如果确实要强制某种行为，官方指南也建议使用 <code>PreToolUse</code> hook 等额外机制。</p>
<p>加载顺序按 broad → specific 逐层叠加。具体来说，是 managed policy（组织级设置）→ 用户全局文件（<code>~/.claude/CLAUDE.md</code>）→ 项目文件（<code>./CLAUDE.md</code>）→ 本地文件（<code>./CLAUDE.local.md</code>）。在同一目录中，先读 <code>CLAUDE.md</code>，再读 <code>CLAUDE.local.md</code>。利用<strong>距离最近的指令最后读取</strong>这一点，可以借助 LLM 的 recency bias，让更具体的规则产生更强影响。</p>
<p>这里很有意思的是 <code>@import</code> 语法。在 CLAUDE.md 正文的任意位置写入 <code>@path/to/file</code>，对应文件就会在原地展开并一同加载。<strong>最大递归深度为 4 hops</strong>，相对路径以写有 import 语句的文件为基准解析。因此，官方建议用 <code>@AGENTS.md</code> 建立 bridge。让 <code>CLAUDE.md</code> 几乎保持为空，只写一行 <code>@AGENTS.md</code>，Claude Code 就能自然读取 AGENTS.md。（在 CLAUDE.md 尚未原生支持 AGENTS.md 的现状下，这是最简洁的变通方案。）</p>
<p>token 方面也值得一提。CLAUDE.md 本身没有明确的 token 上限，因此<strong>只要存在就会完整加载</strong>。不过官方建议<strong>每个文件不超过 200 行</strong>。超过 200 行后，会“consume more context and may reduce adherence”。有趣的是，在 Claude 4.x 中，<strong>仅仅启用 tool use，就会自动通过 special system prompt 增加 346 个 token</strong>（以 <code>tool_choice: auto</code> 为准）。上下文就在这些不易察觉的地方不断流失。</p>
<p>Cursor 则采用另一种方式。<code>.cursor/rules/*.mdc</code> 中的规则有四种工作模式。</p>
<ul>
<li><strong>Always Apply</strong>：无条件加入每次 chat；忽略 globs/description</li>
<li><strong>Apply Intelligently</strong>（Agent Requested）：智能体读取 <code>description</code>，判断相关性后自行调用规则</li>
<li><strong>Apply to Specific Files</strong>（Auto Attached）：与 glob 模式匹配的文件进入上下文时激活</li>
<li><strong>Apply Manually</strong>：用户通过 <code>@rule-name</code> 明确调用</li>
</ul>
<p>其他工具又有所不同。OpenAI Codex 会从 git 仓库根目录朝 cwd 方向遍历，收集所有 <code>AGENTS.md</code>，并在<strong>用户 prompt 之前紧邻的位置</strong>注入。GitHub Copilot 则将 <code>.github/copilot-instructions.md</code> 放在上下文窗口的中等优先级：“位于 edit context 与 explicit references 之后，但在 loosely related open files 之前。”即使使用同一个 <code>AGENTS.md</code> 文件，各工具的加载时机、优先级与合并规则仍不相同，因此<strong>不能保证三个工具以完全相同的方式理解该文件。</strong></p>
<p>但这里还剩下一个根本问题：<strong>为什么模型只遵循上下文中的一部分指令？</strong> 单纯用“指令太长”来解释并不充分。这种现象背后是 LLM 的结构性局限。</p>
<h3 id="幻觉与上下文遗忘"><a class="anchor" href="#幻觉与上下文遗忘">幻觉与上下文遗忘</a></h3>
<p>如果你见过 AI 智能体混淆对话语境，或忘掉前面明明说过的内容，那么这正是<strong>幻觉（Hallucination）<strong>的一种形式。通常提到幻觉，人们首先想到的是“捏造不存在的事实”，但学术上会将其分为三类。Yue Zhang 等研究者在 2023 年的综述《Siren's Song in the AI Ocean》中将它们归为：<strong>输入冲突型</strong>（生成内容与用户明确提供的内容不一致）、<strong>上下文冲突型</strong>（与自己先前生成的内容矛盾）、<strong>事实冲突型</strong>（与世界知识不符）。忽略上下文文件指令属于</strong>第一类</strong>，而不是第三类。模型在处理输入时，把其中一部分信息当成了“不存在”。</p>
<p>更根本的问题在于，这种幻觉<strong>原则上无法彻底消除</strong>。新加坡国立大学的研究团队利用学习理论对此作出了数学证明：任何 LLM 都无法学习所有可计算函数，因此只要把它当作通用问题求解器，就必然会在某处产生幻觉。</p>
<p>位置效应也很重要。Stanford 的研究团队通过实验证明，当相关信息位于<strong>上下文窗口的开头或结尾</strong>时，模型最容易引用；而当信息<strong>埋在中间</strong>时，性能会显著下降。这与上下文文件直接相关。按照加载顺序，<code>CLAUDE.md</code> 会被插入某个中间位置；随着对话变长，其中的指令也会越来越深地被推向上下文的“中部”。这也对应了前文提到的 recency bias 的另一面：在 primacy-recency 效应中，<strong>中间区域最为薄弱</strong>。</p>
<p>把这些现象放在一起，就会得到一幅完整图景。上下文文件不过是<strong>在 LLM 第一个 user turn 之前，从 system prompt 外部额外插入的文本</strong>。它不是强制模型作出某种决策的机制，而只是丢进上下文窗口的另一块 token。文件越长、对话越长，其中的指令就越容易被推向“中间”，引用率也随之下降。ETH Zurich 的结果可以说是对这一结构性局限的量化验证。</p>
<h3 id="eth-zurich-的研究"><a class="anchor" href="#eth-zurich-的研究">ETH Zurich 的研究</a></h3>
<p>很多人可能会想：“那就尽可能多地往这些文件里写内容吧？”近期一项研究正面反驳了这种直觉，也就是前文不断提到的 ETH Zurich 研究。</p>
<p>ETH Zurich 研究团队于 2026 年 2 月发表论文《Evaluating AGENTS.md: Are Repository-Level Context Files Helpful for Coding Agents?》。研究者在由 138 个真实 Python 软件工程任务构成的 benchmark（AGENTBENCH）以及 SWE-bench Lite 上，测试了 Claude Code（Sonnet-4.5）、Codex（GPT-5.2 / GPT-5.1 mini）、Qwen Code 共四种智能体，结果出人意料。</p>
<ul>
<li><strong>由 LLM 自动生成的上下文文件</strong>在 SWE-bench Lite 上反而使<strong>任务成功率降低</strong>约 0.5%，在 AGENTBENCH 上降低约 2%</li>
<li>即使是<strong>人工编写的文件</strong>，平均也只带来约 4% 的小幅改善</li>
<li>添加上下文文件后，<strong>每个 instance 的推理成本增加了 20% 以上</strong></li>
<li>对更强的模型（GPT-5.2）而言，上下文文件效果更加有限（模型越强，parametric knowledge 越充足，额外上下文就越可能成为噪声）</li>
</ul>
<p>不过有一个例外：<strong>明确指定非标准工具时</strong>。例如在上下文中指定 Python 包管理器 <code>uv</code> 后，智能体使用 <code>uv</code> 的频率从每个 instance 0.01 次上升到 1.6 次，<strong>约为原来的 160 倍</strong>。</p>
<p>Aider 的“200 行建议”是实用层面的提醒——文件每次都会进入上下文，所以要保持简短；而 ETH Zurich 的研究则量化证明了“冗长上下文文件会在统计意义上降低性能”。我认为这项研究有以下实践启示。</p>
<ul>
<li><strong>自动生成的巨型上下文文件可能弊大于利</strong>。如果把编码规范、架构、workflow 全塞进 300 行的 <code>CLAUDE.md</code>，智能体会遵循其中一部分、忽略其余部分。这种不一致甚至可能比没有上下文更糟。</li>
<li><strong>真正必须写下的是“无法推断的信息”</strong>。非标准工具、项目专属 convention、过去的失败案例都属于这一类。一般性的编码最佳实践，模型本来就知道。</li>
<li>将 AGENTS.md 作为单一来源，CLAUDE.md 只保留简短的工具专用指令，把细致的 workflow 拆分为 Skill。</li>
</ul>
<h2 id="总结"><a class="anchor" href="#总结">总结</a></h2>
<p>上下文文件因工具而异，名称、存放位置和被读取的时机都不一样。仅本文提到的就有 CLAUDE.md、AGENTS.md、SKILL.md、Cursor rules、copilot-instructions.md 和 MEMORY.md，哪个工具支持哪个文件也在不断变化。只记住文件清单，很快就会过时。</p>
<p>所以，我想做的不是推荐某种特定的文件格式，而是培养一种<strong>看清文件如何被智能体读取的眼光</strong>。理解 CLAUDE.md 为何以 user message 注入、不同工具为何以不同方式读取同一个 AGENTS.md、指令为何会在上下文中间变弱之后，新的上下文文件格式出现时，就能快速看懂：“它何时被加载，作用有多强。”</p>
<p>最终留下的，是 ETH Zurich 研究带来的一个直觉：<strong>模型本来就知道很多东西。</strong> 把所有内容都塞进上下文文件，并不会让智能体更认真地遵循。更好的做法是，只保留模型很可能不知道的信息——项目专属 convention、非标准工具、过去的错误——删除其余内容。把上下文文件写长，与把它写好，是两回事。</p>
<p>我也建议读者不要急着把 CLAUDE.md 扩展到几百行，不妨先深入了解一下，自己当前使用的工具会在何时、放在哪个位置、以多大的力度读取这个文件。无论文件格式如何变化，这种理解都会成为不易动摇的基础。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://code.claude.com/docs/en/memory" target="_blank" rel="noopener noreferrer">Claude Code Memory, Anthropic</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview" target="_blank" rel="noopener noreferrer">Anthropic Tool Use Overview</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://cursor.com/docs/context/rules" target="_blank" rel="noopener noreferrer">Cursor Rules Documentation</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2602.11988" target="_blank" rel="noopener noreferrer">ETH Zurich, "Evaluating AGENTS.md" (2602.11988)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2309.01219" target="_blank" rel="noopener noreferrer">Yue Zhang et al., "Siren's Song" (2309.01219)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2401.11817" target="_blank" rel="noopener noreferrer">Ziwei Xu et al., "Hallucination is Inevitable" (2401.11817)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2307.03172" target="_blank" rel="noopener noreferrer">Nelson F. Liu et al., "Lost in the Middle" (2307.03172)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://simonwillison.net/2025/Oct/16/claude-skills/" target="_blank" rel="noopener noreferrer">Simon Willison, "Claude Skills are awesome"</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>开发工具</category>
            <category>Claude</category>
            <category>MCP</category>
            <category>CodeGraph</category>
        </item>
        <item>
            <title><![CDATA[代码智能的四个层级]]></title>
            <link>https://hooninedev.com/zh-CN/260526</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/260526</guid>
            <pubDate>Tue, 26 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊聊：降低 AI 编程智能体查找相关代码成本的这些工具，彼此到底有什么不同。 本文写给这样的开发者：看到智能体在大型代码库中反复 grep 和读取文件、不断消耗 token，正在犹豫该接入 Repomix、CodeGraph、Serena 中的哪一种。这些工具按对代码理解的深度，分为 context packing、tree-sitter repo map、知识图谱、LSP 四个层级，每...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊：<strong>降低 AI 编程智能体查找相关代码成本的这些工具，彼此到底有什么不同</strong>。</p>
<p>本文写给这样的开发者：看到智能体在大型代码库中反复 grep 和读取文件、不断消耗 token，正在犹豫该接入 Repomix、CodeGraph、Serena 中的哪一种。这些工具按对代码理解的深度，分为 context packing、tree-sitter repo map、知识图谱、LSP 四个层级，每个层级降低检索成本的位置各不相同。知识图谱附上开发方的基准测试，其余三个层级附上在这个博客仓库上测得的结果。</p>
<p>自从看到 <code>codegraph</code> 登上 GitHub Trending 并跟着安装之后，笔者每次看到新工具，都会好奇它究竟靠什么原理节省 token。本文出现的工具，很多也是笔者在 GitHub Trending 上第一次看到的；笔者平时按 weekly 浏览，并筛选 TypeScript 和 Python。</p>
<h2 id="代码智能工具"><a class="anchor" href="#代码智能工具">代码智能工具</a></h2>
<p>智能体在修改代码之前，会先去找相关代码在哪里。用 grep 查找、读取文件、筛选、再 grep，如此循环。在后文要看的 CodeGraph 基准测试中，不用该工具作答的一方，回答一个问题最多用了 43 次 tool call。代码智能工具就是为了降低这种检索成本而出现的各种尝试。</p>
<p>不过，“成本”指的并不是一样东西。模型处理的 token、tool call 的次数，以及工作结束后仍留在 context window 里的 token，三者各自变化。本文逐个层级来看它减少的是这三者中的哪一个。</p>
<p>笔者按照工具对代码理解的深度，把这些尝试分为以下四个层级（tier）。业界并没有既定的分类，这一划分是笔者自己整理的。本文的实测数值，都是 2026-10-08 在本仓库的提交 <code>36e5cfa</code> 上得到的，token 用 tiktoken 的 <code>o200k_base</code> 计数。它与 Claude 的 tokenizer 数值不同，因此用于比较，而不是当作绝对值。</p>
<h3 id="上下文打包"><a class="anchor" href="#上下文打包">上下文打包</a></h3>
<p>最简单的方案是：“<strong>把所有内容都塞进一个 context window。</strong>”既不建图，也不做索引，只是把整个仓库序列化为一大块文本，再整体交给模型。</p>
<p>代表性工具是 <strong>Repomix</strong>。它的默认输出格式是 XML，README 附上了 Anthropic 关于 XML 标签的文档链接。它同时提供 CLI、Web、浏览器扩展与 MCP server。</p>
<p><strong>GitIngest</strong> 的优势是无需安装即可使用。把 GitHub URL 中的 <code>github.com</code> 换成 <code>gitingest.com</code>，整个仓库就变成一个文本页面。Rust CLI <strong>code2prompt</strong>（由 Mufeed VH 开发）可以用模板改变输出格式。</p>
<p><strong>rtk</strong>（<code>rtk-ai/rtk</code>）的方向略有不同。上述工具是一次打包整个仓库，而 rtk 压缩的是智能体所执行 CLI 命令的输出。它是用 Rust 编写的单一 binary，注册在 Claude Code、Cursor、Copilot、Gemini CLI、Codex 等多种智能体的 hook 中，当智能体调用 <code>git status</code> 时，会改为执行 <code>rtk git status</code>。它对 100 多种命令应用 filtering、grouping、truncation、deduplication。rtk 的 hook 只以 Bash 的 tool call 为对象。Claude Code 的 <code>Read</code>、<code>Grep</code>、<code>Glob</code> 会直接绕过。</p>
<p>减少的幅度需要小心解读。<a href="https://github.com/rtk-ai/rtk/blob/8533612180c60efbcb5827c7db4e910aba096705/README.md#L66-L70" target="_blank" rel="noopener noreferrer">rtk README</a> 写着能把 Bash 输出最多减少 90%，随即补充说这并不等于账单减少 90%。从模型的角度看，命令输出只是输入 token 的一部分，而输入 token 又只是账单的一部分，所以每经过一步都会被稀释。<a href="https://www.rtk-ai.app/" target="_blank" rel="noopener noreferrer">官方网站</a> 主打的数字是：在 rtk 改写执行的命令上平均减少 56%。如果说前面的工具减少的是输入的文本，rtk 减少的则是作为 tool call 结果返回的文本。</p>
<p>这一层级的局限是：<strong>大型仓库会触及 token 上限</strong>。仅打包这个博客 <code>src/</code> 中的 109 个文件，就有 94,596 tokens。这个量还能放进窗口，但如果是有数千个文件的仓库，很快就会触及上限。Repomix 用 <code>--compress</code> 来应对这个问题。根据 <a href="https://github.com/yamadashy/repomix/blob/8d6429121e98ed178e4d3a975c2bdbbecc958c4a/README.md#L797-L831" target="_blank" rel="noopener noreferrer">README</a>，它用 Tree-sitter 保留函数与类的签名，丢弃实现主体。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># repomix 1.18.1, 이 리포 커밋 36e5cfa, 2026-10-08</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npx</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -y</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> repomix@1.18.1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> src</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -o</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> out.xml</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">              # Total Tokens: 94,596 tokens</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npx</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -y</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> repomix@1.18.1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> src</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --compress</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -o</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> out.xml</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">   # Total Tokens: 30,320 tokens</span></span></code></pre></figure>
<p>减少了 68%。注释会原样保留，因此 JSDoc 较长的 <code>visit-counter.ts</code> 只从 1,642 降到约 1,220 tokens，仅减少 26%。压缩结果中保留了 import 语句和签名，但函数体内的调用会消失。哪个文件引入了什么，从文本中看得出来，但无法询问谁调用谁。因此，这一层级与下一层级的边界，与其说在于是否看得到语法，不如说在于能否就关系发问。</p>
<h3 id="tree-sitter-仓库地图"><a class="anchor" href="#tree-sitter-仓库地图">tree-sitter 仓库地图</a></h3>
<p>下一个层级利用 <strong>tree-sitter</strong> 分析代码结构，但不会另外启动 index server。</p>
<p>这一层级的工具所使用的 tree-sitter，构建的是用树形结构表示源代码结构的 <strong>CST</strong>（Concrete Syntax Tree）。<a href="https://tree-sitter.github.io/tree-sitter/" target="_blank" rel="noopener noreferrer">tree-sitter 官方文档</a> 也写着它构建的是 concrete syntax tree。这是一棵连括号和标点都保留为节点的树，下面这些工具处理的也正是这棵树。</p>
<p><strong>tree-sitter</strong> 是一个开源 parser generator 与增量（incremental）解析库。<a href="https://docs.github.com/en/repositories/working-with-files/using-files/navigating-code-on-github" target="_blank" rel="noopener noreferrer">GitHub 的 code navigation</a> 使用了 tree-sitter。由于它只重新解析被编辑的部分，在编辑器中改一行也不会重新解析整个文件，而只修补发生变化的那部分树。这个优势属于编辑不断发生的编辑器。下文的 Aider 则按文件修改时间做缓存，不会重新解析没有变化的文件。</p>
<p>在终端中使用的 AI 结对编程工具 <strong>Aider</strong> 是这种方法的代表。Aider 用 tree-sitter 从每个文件中提取函数、类、方法的定义与引用。然后构建以文件为节点的图。当文件 A 引用了文件 B 中定义的标识符时，就会产生一条从 A 指向 B 的边。</p>
<p>为了在这张图中挑出重要的文件，Aider 使用 PageRank。PageRank 是一种算法，节点收到的链接越多、越重，得分就越高。Aider 用的是它的变体 personalized PageRank，会让分数向指定节点倾斜。然后按 token 预算，从排名靠前的文件开始放入定义与签名。</p>
<p>预算里放进什么，会随当前对话而变化。在 Aider 的 <a href="https://github.com/Aider-AI/aider/blob/5dc9490bb35f9729ef2c95d00a19ccd30c26339c/aider/repomap.py#L487-L525" target="_blank" rel="noopener noreferrer"><code>repomap.py</code></a> 中，边的权重是引用次数的平方根乘以倍数。对话中出现的标识符乘 10，8 个字符以上的 camelCase、snake_case 或 kebab-case 标识符也乘 10。以 <code>_</code> 开头的标识符和在超过 5 个文件中被定义的标识符各乘 0.1，从当前加入聊天的文件出发的边乘 50。加入聊天的文件、对话中提到的文件，以及路径或文件名与对话中标识符相同的文件，还会得到 PageRank 的 personalization 分数。</p>
<p>预算也不是固定值。<a href="https://aider.chat/docs/repomap.html" target="_blank" rel="noopener noreferrer">Aider 文档</a> 写的 <code>--map-tokens</code> 默认值是 1k，但 <a href="https://github.com/Aider-AI/aider/blob/5dc9490bb35f9729ef2c95d00a19ccd30c26339c/aider/models.py#L782-L789" target="_blank" rel="noopener noreferrer">代码</a> 会把模型输入上限的 1/8 截取到 1,024 到 4,096 之间。而且聊天中没有文件时，会放大到 <code>--map-multiplier-no-files</code>（默认 2）倍。笔者用本仓库的 <code>src/</code> 验证了这一点。实际运行的 0.86.1 与上面链接的提交，排序代码相同。输出的 map 连同 Aider 附加的三行提示文字一起，用 o200k_base 计数。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># aider-chat 0.86.1, 리포 루트에서 시작한다, 2026-10-08</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">mkdir</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> /tmp/aidermap</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">cp</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -R</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> src</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> /tmp/aidermap/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">cd</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> /tmp/aidermap</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> init</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -q</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> add</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -A</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> commit</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -qm</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> init</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># 키는 더미 값이다. --show-repo-map 은 map 만 출력하고 LLM 을 부르지 않는다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> OPENAI_API_KEY</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">dummy</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">aider_map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() { </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">uvx</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --python</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 3.12</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> aider-chat==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.86.1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> aider</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --no-check-update</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --analytics-disable</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --no-gitignore</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --model</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> gpt-4o</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">$@</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; }</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">aider_map</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --show-repo-map</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">                                              # Repo-map: using 4096 tokens</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">aider_map</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --map-tokens</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1024</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --show-repo-map</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">                            # Repo-map: using 1024 tokens</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">aider_map</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --map-tokens</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1024</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --show-repo-map</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> src/lib/filter-posts.ts</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    # 이 파일을 채팅에 올린 상태</span></span></code></pre></figure>
<table>
<thead>
<tr>
<th>条件</th>
<th>Aider 采用的预算</th>
<th>输出的 map</th>
<th>包含的文件</th>
</tr>
</thead>
<tbody>
<tr>
<td>默认值，聊天中无文件</td>
<td>4,096</td>
<td>7,370 tokens</td>
<td>61 个</td>
</tr>
<tr>
<td><code>--map-tokens 1024</code>，聊天中无文件</td>
<td>1,024</td>
<td>2,019 tokens</td>
<td>27 个</td>
</tr>
<tr>
<td><code>--map-tokens 1024</code>，把 <code>filter-posts.ts</code> 加入聊天</td>
<td>1,024</td>
<td>983 tokens</td>
<td>12 个</td>
</tr>
</tbody>
</table>
<p>gpt-4o 的输入上限是 128k，所以 1/8 被截到 4,096；聊天为空，map 就在其两倍以内输出。把一个文件加入聊天后，map 缩到预算以内，原来 27 个文件中只留下 10 个，<code>PostList.tsx</code> 和 <code>SearchModal.tsx</code> 则是新加入的。repo map 不是仓库的固定摘要，而是按那一刻的对话裁出的摘录。</p>
<p>同一层级里还有一个值得一提的工具：<strong>ast-grep</strong>（<code>ast-grep/ast-grep</code>）。它是基于 tree-sitter 的结构化搜索与 rewrite CLI，匹配的是语法树节点而不是文本。例如，<code>console.log($A)</code> 这个模式会抓住所有以单个参数调用 <code>console.log</code> 的地方，不管换行和空白长什么样。它抓住的是相同的语法结构。它不知道 <code>$A</code> 的类型，也不知道 <code>console</code> 这个名字从哪里来。另外还有 <code>ast-grep-mcp</code> server，可以让智能体用结构化搜索代替文本 grep。</p>
<h3 id="知识图谱"><a class="anchor" href="#知识图谱">知识图谱</a></h3>
<p>第三个层级更进一步：<strong>预先解析整个代码库，构建知识图谱并保存到磁盘</strong>，智能体再向保存好的图谱发出查询。最受关注的例子是名为 <strong>CodeGraph</strong> 的工具。</p>
<p><a href="https://github.com/colbymchenry/codegraph/blob/b635dd467f0578926a9c01a37b9d28d2b26689f1/README.md" target="_blank" rel="noopener noreferrer">CodeGraph README</a> 描述的结构很简单。用 tree-sitter 解析代码，提取 symbol、边与文件信息，并把它们保存到本地 SQLite 数据库中。名称搜索通过 SQLite 的 FTS5 索引完成。智能体通过 MCP 向这张图发问。README 描述的步骤中没有出现 LLM。因此笔者认为，这种提取是确定性的（deterministic），也就是同样的代码总会得到同样的结果。</p>
<p>如果由 LLM 摘要代码来构建图谱，即便是相同的代码，结果也可能不同，还可能混入幻觉。反之，直接解析语法树，就只会按语言文法规定的规则提取 symbol 关系，这类解释没有插足的余地。</p>
<p>这里出现的 <strong>FTS5</strong>（SQLite Full-Text Search 5）是 SQLite 以虚拟表形式提供的全文搜索扩展。根据 <a href="https://www.sqlite.org/fts5.html" target="_blank" rel="noopener noreferrer">SQLite 文档</a>，它自 3.9.0（2015-10-14）起被纳入 amalgamation，可用 <code>CREATE VIRTUAL TABLE ... USING fts5(...)</code> 建表，再用 <code>MATCH</code> 运算符查询。无需另外启动 Elasticsearch 之类的搜索引擎，用一个 SQLite 文件就能持有全文索引。</p>
<p>不过，确定性不等于没有遗漏。同一份 README 把依赖约定和反射的框架的路由识别率记为从 Django 的 74.1% 到 ASP.NET 的 83.9%，并称之为静态分析的上限（honest static-analysis ceiling）。相同的代码会得到相同的结果，但这个结果里可能缺了一些边。</p>
<p>基准测试是 CodeGraph 自己测的。同一份 README 中 2026-08-05 的重新测量，以 headless 方式运行 Claude Opus 4.8，向 7 个开源仓库各提一个架构问题。启用 CodeGraph MCP 的一方，平均成本降低 44%，处理的 token 减少 62%，tool call 减少 88%。这次重新测量在双方都禁止通过 Bash 调用 <code>codegraph</code> CLI。在没有这一限制的测量环境中，不用该工具的一方在 28 次运行中有 26 次找到并用上了 CLI，README 也说明此前公布的数字是在没有这一限制的情况下得出的。</p>
<p>节省幅度并没有跟随仓库规模。在不用该工具的一方用了 28～43 次 tool call 的问题上，成本降低了 57～78%；而在 7 次就结束的 Gin 上，几乎持平。约 11k 个文件的 VS Code 是 71%，约 640 个文件的 Excalidraw 是 78%。README 用问题所需的探索量来解释这一点。每个仓库只有一个问题，所以笔者只读出方向。</p>
<p>README 也记录了方向相反的数字。处理的 token 减少了，但在多轮会话结束时，仍留在 context window 中的 retrieval 结果，在 7 个仓库整体上 CodeGraph 一方多出约 80%。各仓库之间差距很大，仅 VS Code 一个仓库就是 67k 对 18k tokens，约 3.7 倍。原因是它一次返回密集的原文，而这些原文会原样留在窗口里。CodeGraph 以窗口中留存更多为代价，减少了 tool call 和处理的 token。</p>
<p><strong>Cursor</strong> 走了另一条路，后来又改变了方向。<a href="https://cursor.com/blog/secure-codebase-indexing" target="_blank" rel="noopener noreferrer">2026 年 1 月 Cursor 博客</a> 介绍的索引不是语法图，而是<strong>基于向量嵌入的语义搜索</strong>。它在本地把文件切成 chunk，用 Merkle tree 哈希与服务器同步，并把 chunk 转成嵌入，用于语义搜索。2026 年 7 月，Cursor 的 Community Support Engineer 在<a href="https://forum.cursor.com/t/what-do-you-think-about-cursor-removing-the-codebase-indexing-settings/165899" target="_blank" rel="noopener noreferrer">论坛</a>上回答：“Semantic/embeddings indexing is being turned down in favor of grep-based retrieval”。同一帖子里，另一位员工写道，随着模型越来越会用 grep，以前的语义搜索路径已不再有实质帮助。现在的 <a href="https://cursor.com/docs/context/codebase-indexing" target="_blank" rel="noopener noreferrer">Cursor 文档</a> 写着：Instant Grep 在本机构建并查询索引，不为搜索保存代码库的嵌入。</p>
<h3 id="lsp"><a class="anchor" href="#lsp">LSP</a></h3>
<p>最后一个层级<strong>直接依赖语言服务器</strong>。如果说 tree-sitter 知道“symbol 存在”，那么 LSP 知道“这个 symbol 是什么”。</p>
<p><strong><a href="https://microsoft.github.io/language-server-protocol/" target="_blank" rel="noopener noreferrer">LSP</a></strong>（Language Server Protocol）是一种基于 JSON-RPC 的开放协议，用来标准化编辑器与语言分析工具（代码补全、跳转到定义、查找引用、重构等）之间的通信。2016 年，<a href="https://www.redhat.com/en/about/press-releases/red-hat-codenvy-and-microsoft-collaborate-language-server-protocol" target="_blank" rel="noopener noreferrer">Microsoft、Red Hat、Codenvy 宣布了合作</a>。核心思路是：“不要为每个编辑器重新实现语言分析器，而是每种语言放一个服务器，让所有编辑器都去问它。”rust-analyzer 和 Python 的 pyright 是 LSP 服务器，而 TypeScript 使用的是 typescript-language-server，它用 LSP 包装了使用自有协议的 <code>tsserver</code>。</p>
<p><strong>Serena</strong>（<code>oraios/serena</code>）是属于这一层级的 MCP server。截至 2026-10-08 有 30,093 stars，仓库创建于 2025 年 3 月。Serena 的核心思路可以用一句话概括：<strong>以 symbol 为单位给智能体看代码。</strong> 主要工具有 <code>find_symbol</code>、<code>find_referencing_symbols</code>、<code>get_symbols_overview</code> 等。后端可以二选一：默认是实现了 LSP 的语言服务器（免费/开源），另一个选项是利用 JetBrains IDE 代码分析的付费插件（提供免费试用）。</p>
<p>在本仓库里测一下，就能看出差异从哪里来。笔者用两种方式查找了过滤非公开文章的 <code>isHiddenPost</code>（<code>src/lib/filter-posts.ts:12</code>）的使用位置。文本这一侧是 grep。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># 커밋 36e5cfa, 2026-10-08. 이 글도 같은 이름을 담고 있어 content/ 는 뺐다</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> grep</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -n</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> isHiddenPost</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ':!content'</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">   # 16줄, 파일 9개</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> grep</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -n</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -C1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> isHiddenPost</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ':!content'</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">   # 같은 16곳을 앞뒤 1줄과 함께</span></span></code></pre></figure>
<p>LSP 这一侧调用的是 Serena 的 <a href="https://github.com/oraios/serena/blob/3b99f8b024dafd58c962ea6e74f37c8a730ef532/src/serena/tools/symbol_tools.py#L169-L172" target="_blank" rel="noopener noreferrer"><code>find_referencing_symbols</code></a>。LSP 的查找引用不是按名称，而是按位置（文件、行、列）来问，所以这个工具也要同时接收作为 symbol 名称的 <code>name_path</code> 和定义所在文件的 <code>relative_path</code>。这里没有把 Serena 作为 MCP 服务器启动，而是在同一个提交上从 Python 直接调用了这个工具。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="python" data-theme="github-dark github-light"><code data-language="python" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># serena 3b99f8b, 커밋 36e5cfa, 2026-10-08. 리포 루트에서 실행한다</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># uvx --python 3.12 --from git+https://github.com/oraios/serena@3b99f8b024dafd58c962ea6e74f37c8a730ef532 python refs.py</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> serena.agent </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SerenaAgent</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> serena.config.serena_config </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SerenaConfig</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> serena.tools.symbol_tools </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> FindReferencingSymbolsTool</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">agent </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SerenaAgent(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">project</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"."</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">serena_config</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">SerenaConfig().with_headless_mode_overrides())</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">agent.execute_task(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">lambda</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">None</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)  </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># 언어 서버가 뜰 때까지 기다린다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">tool </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> agent.get_tool(FindReferencingSymbolsTool)</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">print</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(agent.execute_task(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">lambda</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: tool.apply(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name_path</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"isHiddenPost"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">relative_path</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"src/lib/filter-posts.ts"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)))</span></span></code></pre></figure>
<p>输出是一行 JSON。只摘出第一项是这样的。行号从 0 开始计。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>{"src/lib/filter-posts.ts": {"Function": [{"name_path": "filterPublishedPosts", "body_location": {"start_line": 22, "end_line": 24}, "content_around_reference": "...  22:export function filterPublishedPosts(posts: Post[]): Post[] {\n  >  23:  return posts.filter(post => !isHiddenPost(post))\n...  24:}"}]}, ...</span></span></code></pre></figure>
<p>Serena 返回了 10 处引用。它不包括定义所在的 <code>filter-posts.ts:12</code>，对三个文件中的 import 行，则把该文件本身作为 symbol（<code>File</code>）返回。grep 抓到的 11 处代码位置，等于这 10 处再加上定义那一行。这个名字在仓库中只有一个，所以 grep 既没有漏掉也没有多抓代码位置。grep 多抓的 5 处，是文档中解释这个函数的句子：根目录的 <code>CLAUDE.md</code>，<code>.claude/commands/</code> 下的 <code>audit.md</code> 和 <code>write-post.md</code>，以及 <code>docs/research/snapshot-20260816/</code> 中留下的 <code>CLAUDE.md</code> 和 <code>write-post.md</code> 副本。</p>
<p>Serena 会给每个引用附上<a href="https://github.com/oraios/serena/blob/3b99f8b024dafd58c962ea6e74f37c8a730ef532/src/serena/repl/api/lsp_api.py#L367-L369" target="_blank" rel="noopener noreferrer">前后各 1 行</a>再返回。因此 grep 分两种方式运行：只返回行号的 <code>git grep -n</code>，和连前后各 1 行一起返回的 <code>git grep -n -C1</code>，再用 <code>o200k_base</code> 统计三种输出的全部 token。</p>
<table>
<thead>
<tr>
<th>输出</th>
<th>位置</th>
<th>tokens</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>git grep -n</code></td>
<td>16 处（代码 11，文档 5）</td>
<td>703</td>
</tr>
<tr>
<td><code>git grep -n -C1</code></td>
<td>16 处（代码 11，文档 5）</td>
<td>1,575（代码 733，文档 841）</td>
</tr>
<tr>
<td>Serena <code>find_referencing_symbols</code></td>
<td>10 处</td>
<td>892</td>
</tr>
</tbody>
</table>
<p>与连前后 1 行一起返回的 grep 相比，Serena 少 683 tokens。差距来自它不返回 5 处文档。不过 Serena 会给每个引用附上 JSON 形式的 symbol 名称和位置信息，所以只看代码一侧，它反而多 159 tokens。与只返回行号的 <code>git grep -n</code> 的 703 tokens 相比，Serena 的 892 tokens 更多。在这个仓库里，LSP 层级节省的不是搜索结果，而是看到 grep 结果后再去读周边行或文件的下一步。</p>
<p>在这下一步中，如果智能体把 grep 命中的 9 个文件整个读一遍，要读的量是 47,034 tokens，其中 4 个代码文件 3,716，5 个文档 43,318。这是上限。在实际会话中，<code>CLAUDE.md</code> 很可能已经在 context 中，不会再读一次。如果是 <code>isLocale</code> 这样常见的名字，连代码位置的结果都可能出现分歧。</p>
<h2 id="总结"><a class="anchor" href="#总结">总结</a></h2>
<p>总之，四个层级按对代码理解的深度区分，减少的成本也各不相同。context packing 把代码作为文本交出去，即使加上 <code>--compress</code> 也只保留到语法，减少的是输入的 token。tree-sitter repo map 知道 symbol 存在，用预算给输入的 token 设上限，至于用什么填满这个上限，则由那一刻的对话决定。知识图谱预先保存关系，减少 tool call 和处理的 token，但按厂商自己的测量，留在窗口里的量反而增加了。LSP 连 symbol 是什么都知道，在这个仓库里，它不返回文档中的同名内容，但给每个结果都附上了元数据。节省之处不在搜索结果，而在其后的阅读。</p>
<p>所以，笔者在挑选工具时，比起层级有多深，会先看现在出问题的是哪一种成本。窗口小而会话长，就看留存的量；往返慢，就看 tool call 次数；如果仓库里文档和代码共用同样的名字，就看看到 grep 结果后还要多读多少文件。笔者把 Cursor 拿掉基于嵌入的语义搜索、改用本地文本索引（Instant Grep）这件事，也读作“昂贵的索引不一定总胜过模型自身的检索能力”的信号。</p>
<p>如果这些工具降低的是智能体查找代码的成本，那么智能体从一开始就该知道的项目规则要写在哪个文件、写多少，就是另一个问题了。这部分内容在<a href="/260529" target="_blank" rel="noopener noreferrer">上下文文件</a>中讨论。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/ast-grep/ast-grep" target="_blank" rel="noopener noreferrer">ast-grep/ast-grep</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/ast-grep/ast-grep-mcp" target="_blank" rel="noopener noreferrer">ast-grep/ast-grep-mcp</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>开发工具</category>
            <category>Claude</category>
            <category>MCP</category>
            <category>CodeGraph</category>
        </item>
        <item>
            <title><![CDATA[MCP 与 function calling]]></title>
            <link>https://hooninedev.com/zh-CN/260524</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/260524</guid>
            <pubDate>Sun, 24 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[本文想聊一聊 MCP（Model Context Protocol）与 function calling 有什么不同。 本文写给这样的开发者：在 Claude Code 或 Cursor 中接入了 MCP server，却很难说清它与 LLM API 的 function calling 在哪里分道扬镳。先说答案：MCP 并不取代 function calling。host 应用把从 MCP se...]]></description>
            <content:encoded><![CDATA[<p>本文想聊一聊 <strong>MCP（Model Context Protocol）与 function calling 有什么不同</strong>。</p>
<p>本文写给这样的开发者：在 Claude Code 或 Cursor 中接入了 MCP server，却很难说清它与 LLM API 的 function calling 在哪里分道扬镳。先说答案：MCP 并不取代 function calling。host 应用把从 MCP server 拿到的工具列表转换成 function calling 的 <code>tools</code> 参数传入，再把模型选中的调用转交回 MCP server。读完之后，你会知道 MCP 在此之上增加的四点差异分别来自协议的哪个部分，哪些差异在 2026-07-28 修订版之后依然存在，以及这种结构会打开怎样的攻击面。</p>
<p>笔者作为前端开发者，日常都会使用 Claude，但每次新增一个 MCP server 时，对于这些工具究竟如何进入模型的视野，总是有些模糊。</p>
<h2 id="mcpmodel-context-protocol"><a class="anchor" href="#mcpmodel-context-protocol">MCP（Model Context Protocol）</a></h2>
<p>MCP（Model Context Protocol）解决的是“<strong>该让智能体能做什么</strong>”的问题。</p>
<p>展开来说，AI 智能体若要向 Slack 发送消息，就必须能调用 Slack API；要创建 GitHub issue，就必须能调用 GitHub API；要查询 Postgres，就必须能处理 DB 连接。MCP 就是把所有这些外部系统集成<strong>统一到一个标准协议之下</strong>。（意思是 client 与 server 按同一套规格连接。）</p>
<p>MCP 是 Anthropic 于 <strong>2024 年 11 月 25 日</strong>首次发布的开放标准。到 <strong>2025 年 12 月 9 日</strong>，Anthropic 将 MCP 捐赠给 Linux Foundation 旗下的 <a href="https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation" target="_blank" rel="noopener noreferrer">Agentic AI Foundation（AAIF）</a>。AAIF 由 Anthropic、Block、OpenAI 共同创立。</p>
<p>MCP 是建立在 JSON-RPC 之上的协议。<a href="https://www.jsonrpc.org/specification" target="_blank" rel="noopener noreferrer">JSON-RPC 2.0</a> 是一种以 JSON 作为 wire format 的 stateless 轻量级 RPC（Remote Procedure Call）协议。它与传输层无关，可以运行在 HTTP、TCP 或标准输入输出之上。本文以 2025-11-25 修订版为准，在这一版中，MCP 是为每个连接建立 session 的 stateful 协议。之后的修订版改了什么，等看完调用流程再讨论。</p>
<h3 id="六种-primitive"><a class="anchor" href="#六种-primitive">六种 primitive</a></h3>
<p>2025-11-25 规范的概览列出了 server 提供的三项功能和 client 提供的三项功能。本文把这六项称为 primitive。这里的 primitive 与 JavaScript 的原始类型（如 string、number）无关，指的是协议所定义的基本交互类型。</p>
<h4 id="server-侧-primitive"><a class="anchor" href="#server-侧-primitive">Server 侧 primitive</a></h4>
<ul>
<li><strong>Tool</strong>（model-controlled）：模型自行判断是否调用并执行的操作，可能产生副作用（side effect）</li>
<li><strong>Resource</strong>（application-controlled）：由 URI 标识的数据。规范中只有读取内容的 <code>resources/read</code>，没有写入方法。如何把这些 resource 放入 context，由 host application 决定</li>
<li><strong>Prompt</strong>（user-controlled）：由用户通过斜杠命令等方式明确触发的可复用模板</li>
</ul>
<h4 id="client-侧-primitive"><a class="anchor" href="#client-侧-primitive">Client 侧 primitive</a></h4>
<ul>
<li><strong>Sampling</strong>：让 server 反向请求 client 的 LLM 生成 completion，从而在 client 与 server 之间建立双向结构。它的用途是让 server 在执行工具时需要生成文本，可以不用自己的 API key，借用 client 所用的模型。在 2026-07-28 修订版中，它成为计划移除的 deprecated 状态</li>
<li><strong>Roots</strong>：client 向 server 说明“可操作范围到这里为止”的 workspace 边界信息</li>
<li><strong>Elicitation</strong>：server 在执行工具的过程中，以结构化形式向用户请求补充输入</li>
</ul>
<p>这种区分之所以重要，是因为<strong>由谁决定调用或提供</strong>各不相同。Tool 由模型判断并执行，因此存在误调用风险；Prompt 由用户明确选择。Resource 默认由应用选择，但规范也允许依据启发式规则或模型的选择自动纳入的实现。client 侧的三种方向正好相反：由 server 发出请求，是否响应由 client 决定。</p>
<h3 id="两种传输方式"><a class="anchor" href="#两种传输方式">两种传输方式</a></h3>
<p><a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/transports" target="_blank" rel="noopener noreferrer">标准传输方式</a>有两种，规范也允许其他自定义传输（MAY）。一种是 <strong>stdio</strong>：将 MCP server 作为本地子进程运行，通过标准输入输出通信，适合文件系统、git 等在本地工作的工具。另一种是 <strong>Streamable HTTP</strong>：在 HTTP POST 和 GET 上叠加 SSE（Server-Sent Events）streaming，形成近似双向的通信方式。SSE 是 server 在 HTTP 连接上向 client 单向推送数据的方式。它适合远程 server、OAuth 认证、多 client 连接、云端部署等跨网络场景。</p>
<h3 id="llm-调用-mcp-工具的流程"><a class="anchor" href="#llm-调用-mcp-工具的流程">LLM 调用 MCP 工具的流程</a></h3>
<p>了解 primitive 与传输方式后，接下来看看 <strong>LLM 实际如何发现并调用 MCP 工具</strong>。</p>
<p>在 2025-11-25 修订版中，连接开始时会按以下顺序进行 handshake。截至 2026-10-08，TypeScript SDK 的 1.32.1 和 v2 系列的 2.3.1 在默认配置下也按这个顺序运行。</p>
<ul>
<li><strong>Client → Server</strong>：发送 <code>initialize</code> 请求（传递支持的协议版本和 client capabilities）</li>
<li><strong>Server → Client</strong>：返回 <code>initialize</code> 响应（server capabilities + 可选的 <code>instructions</code> 字段）</li>
<li><strong>Client → Server</strong>：发送 <code>notifications/initialized</code> 通知</li>
<li><strong>Client → Server</strong>：发送 <code>tools/list</code> 请求 → 获取可用工具列表</li>
<li>（之后）LLM 决定调用工具 → client 发送 <code>tools/call</code> → 接收结果</li>
</ul>
<p><code>initialize</code> 响应中的 <code>instructions</code> 字段，是 server 用文字说明工具该如何使用的位置。下面演示输出中的 instructions 一行就是这个值。</p>
<p>那么，tool 定义本身又是如何进入 LLM 视野的？MCP 的 tool 定义采用如下 JSON Schema 形式。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark github-light"><code data-language="json" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "name"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"get_weather"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "description"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Get current weather information for a location"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "inputSchema"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: {</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "type"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"object"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "properties"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">"location"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">"type"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"string"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } },</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "required"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"location"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>host 会把通过 <code>tools/list</code> 获取的列表转换成 <strong>Anthropic Messages API 的 <code>tools</code> 参数</strong>，或 <strong>OpenAI function calling 的 <code>tools</code> 参数</strong>，再随 LLM API 请求一同发送。以 Anthropic 为例，传入 tool 参数后，<a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview" target="_blank" rel="noopener noreferrer">系统会自动添加 special system prompt</a>，使模型理解工具调用方式。它的长度因模型而异。在 2026-10-08 查看的文档表格中，以 <code>tool_choice: auto</code> 为准，现行模型为 286～675 个 token。</p>
<p>当 LLM 判断应该调用工具时，响应中会包含 <code>tool_use</code> block（<code>{"type": "tool_use", "name": ..., "input": ...}</code>），并以 <code>stop_reason</code> 为 <code>tool_use</code> 结束。host 收到后，会向实际的 MCP server 发送 <code>tools/call</code>，再把返回结果装进下一条 user message 的 <code>tool_result</code> block 中发回 LLM。<strong>这个循环会持续进行，直到 <code>stop_reason</code> 从 <code>tool_use</code> 变成其他值（如 <code>end_turn</code>、<code>max_tokens</code>）。</strong> 我们通常所说的“智能体在工作”，实际上很接近这种调用、结果、调用的循环的连续运行。</p>
<p>画成图的话，模型与 MCP server 之间没有连线。模型只看到通过 <code>tools</code> 收到的定义，MCP server 接收的是 host 的请求，而不是模型的请求。连接两个协议的是中间的 host。</p>
<p><picture><source type="image/webp" srcset="/content/260524/1.720w.webp 720w, /content/260524/1.1440w.webp 1440w, /content/260524/1.2000w.webp 2000w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260524/1.png" alt="左侧 LLM API 与中间 host 之间有 tools、tool_use、tool_result 箭头往来，host 与右侧 MCP server 之间有 tools/list、tools/call 箭头往来。LLM API 与 MCP server 之间没有连线" style="max-width:min(720px, 100%);height:auto;" width="2000" height="640" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>为了看看这个转换实际有多短，笔者把它跑了一遍。2026-10-08 使用 Node v24.16.0、<code>@modelcontextprotocol/sdk</code> 1.32.1、<code>zod</code> 4.6.5 运行。传输用的不是 stdio，而是在同一进程内连接 server 与 client 的 <code>InMemoryTransport</code>，也没有调用 LLM。<code>tool_use</code> block 是按照 Anthropic 文档中的形状手工构造的。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { McpServer } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "@modelcontextprotocol/sdk/server/mcp.js"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { Client } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "@modelcontextprotocol/sdk/client/index.js"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { InMemoryTransport } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "@modelcontextprotocol/sdk/inMemory.js"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { z } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "zod"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// MCP 서버: 도구 하나와 instructions 를 둔다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> server</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> McpServer</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"weather"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, version: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"1.0.0"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { instructions: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Use get_weather for current conditions only."</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">server.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">registerTool</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">  "get_weather"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Get current weather information for a location"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, inputSchema: { location: z.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() } },</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">location</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ content: [{ type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"text"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, text: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">location</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}: 15C, partly cloudy`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }] })</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 호스트: 같은 프로세스 안에서 서버와 잇고, 클라이언트가 보내는 메서드 이름을 찍는다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">clientT</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">serverT</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> InMemoryTransport.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">createLinkedPair</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> send</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> clientT.send.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">bind</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(clientT);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">clientT.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">send</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"C->S"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, m.method, m.params?.protocolVersion </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ""</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> send</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(m); };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> server.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">connect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(serverT);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> client</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Client</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"demo-host"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, version: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"1.0.0"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">connect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(clientT);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"instructions:"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getInstructions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">());</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 1. tools/list 결과를 LLM API 의 tools 파라미터 모양으로 바꾼다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">tools</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">listTools</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> anthropicTools</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> tools.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">t</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ name: t.name, description: t.description, input_schema: t.inputSchema }));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> openaiTools</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> tools.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">t</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"function"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, name: t.name, description: t.description, parameters: t.inputSchema }));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"anthropic:"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(anthropicTools[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"openai:"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(openaiTools[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 2. 모델이 이런 tool_use 블록을 돌려줬다고 가정한다. input 이 그대로 tools/call 의 arguments 가 된다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> toolUse</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"tool_use"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, id: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"toolu_demo"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"get_weather"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, input: { location: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Seoul"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> result</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">callTool</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name: toolUse.name, arguments: toolUse.input });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"tool_result:"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"tool_result"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, tool_use_id: toolUse.id, content: result.content }));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p><code>node post-demo.mjs</code> 的输出如下。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>C->S initialize 2025-11-25</span></span>
<span data-line=""><span>C->S notifications/initialized </span></span>
<span data-line=""><span>instructions: Use get_weather for current conditions only.</span></span>
<span data-line=""><span>C->S tools/list </span></span>
<span data-line=""><span>anthropic: {"name":"get_weather","description":"Get current weather information for a location","input_schema":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"],"$schema":"http://json-schema.org/draft-07/schema#"}}</span></span>
<span data-line=""><span>openai: {"type":"function","name":"get_weather","description":"Get current weather information for a location","parameters":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"],"$schema":"http://json-schema.org/draft-07/schema#"}}</span></span>
<span data-line=""><span>C->S tools/call </span></span>
<span data-line=""><span>tool_result: {"type":"tool_result","tool_use_id":"toolu_demo","content":[{"type":"text","text":"Seoul: 15C, partly cloudy"}]}</span></span></code></pre></figure>
<p>工具定义的转换只不过是改字段名。MCP 的 <code>inputSchema</code> 在 Anthropic 中变成 <code>input_schema</code>，在 OpenAI 中变成 <code>parameters</code>。从输出中还能看到 SDK 会在 schema 里加上 <code>$schema</code>。反方向同样简短。Anthropic 的 <code>tool_use.input</code> 是对象，所以直接成为 <code>tools/call</code> 的 <code>arguments</code>。结果一侧也一样，text block 形状相同，可以直接传过去；但图片或错误结果的形状不同，由 Anthropic SDK 的 MCP helper 另行转换。上面 OpenAI 一侧的形状是 Responses API 的格式。OpenAI 返回的调用中的 <code>arguments</code> 是 <a href="https://developers.openai.com/api/docs/guides/function-calling" target="_blank" rel="noopener noreferrer">JSON 字符串</a>，因此在转交之前需要先经过一次 <code>JSON.parse</code>。上面的代码只构造了 Anthropic 形状的 <code>tool_use</code> 块，所以这个 parse 步骤不会出现在输出中。</p>
<h3 id="mcp-增加的四点"><a class="anchor" href="#mcp-增加的四点">MCP 增加的四点</a></h3>
<p>那么，MCP 给 function calling 增加了什么？以 2025-11-25 修订版为准，有四点。</p>
<ul>
<li><strong>动态发现</strong>：构建时并不知道工具列表，而是在运行时通过 <code>tools/list</code> 获取。server 可以通过 <code>notifications/tools/list_changed</code> 通知连接期间列表发生了变化</li>
<li><strong>Stateful session</strong>：用 <code>initialize</code> 建立连接，请求在其中往来。没有用于结束的 JSON-RPC 消息，关闭传输即告结束</li>
<li><strong>Tool 以外的 primitive</strong>：通过 capability negotiation 暴露 Resource、Prompt、Sampling、Roots、Elicitation。capability negotiation 是在 <code>initialize</code> 中双方互相告知所支持功能的阶段</li>
<li><strong>双向性</strong>：server 可以通过 Sampling 反向请求 client 的 LLM 生成 completion（2026-07-28 修订版中已 deprecated）</li>
</ul>
<h3 id="2026-07-28-修订版之后"><a class="anchor" href="#2026-07-28-修订版之后">2026-07-28 修订版之后</a></h3>
<p>截至 2026-10-08，官方网站作为 latest 打开的是 <a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog" target="_blank" rel="noopener noreferrer">2026-07-28 修订版</a>，这份列表在其中变了一半。首先，由 <code>initialize</code> 与 <code>notifications/initialized</code> 构成的 handshake 以及协议层面的 session 消失了。取而代之的是，每个请求都在 <code>_meta</code> 中携带协议版本和 client capabilities。这个字段是 MCP 预留的位置，用于在消息本身的参数之外附加元数据。server 必须（MUST）实现 <code>server/discover</code>。这个 RPC 返回 server 支持的协议版本、capabilities 和 server 信息，client 可以在其他请求之前调用它，预先确认支持的版本和 capabilities。</p>
<p>原先由 server 先发出的请求，被一种名为 Multi Round-Trip Requests（MRTR）的模式取代。server 不再单独发出请求，而是返回一个表示还需要额外输入的中间结果（<code>input_required</code>），client 补上这些输入后重新发送原来的请求。</p>
<p>Sampling 和 Roots 与 Logging 一起被标为 deprecated。它们仍留在规范中并且可以工作，但新的实现不应采用，规范建议不要用 Sampling，而是直接对接 LLM provider 的 API。</p>
<p>不过，SDK 的默认行为仍是旧方式。TypeScript SDK 1.32.1 的最新版本常量是 <code>2025-11-25</code>，因此并不知道 2026-07-28 修订版；2.3.1 支持这一修订版，但版本协商的默认值是 <code>legacy</code>。上面输出的第一行 <code>initialize 2025-11-25</code> 就是其结果。</p>
<p>这样一来，四点中留下的是动态发现和 Tool 以外的 primitive（Resource、Prompt、Elicitation）。动态发现的形态也略有变化：列表变更通知只发给 opt-in 了 <code>subscriptions/listen</code> 流的 client。归根结底，MCP 与 function calling 分道扬镳之处，与其说在 session 或双向性，不如说在于<strong>在运行时交换工具列表与 context 的约定</strong>。</p>
<h3 id="当-api-成为-mcp-client"><a class="anchor" href="#当-api-成为-mcp-client">当 API 成为 MCP client</a></h3>
<p>这份约定中有多少能到达模型，Anthropic 的 <a href="https://platform.claude.com/docs/en/agents-and-tools/mcp-connector" target="_blank" rel="noopener noreferrer">MCP connector</a>（beta）给出了答案。这是 Messages API 直接连接远程 MCP server 的功能，而文档的 Limitations 写道，在 MCP 规范的功能中 "only tool calls are currently supported"，并写道 "Local STDIO servers cannot be connected directly"。同一份文档还说明，如果需要本地 server 或 MCP 的 prompt、resource，就用 MCP SDK 自行管理连接，同时使用 Anthropic SDK 的转换 helper。</p>
<p>也就是说，在 function calling 层消费 MCP 时，只剩下 Tool。Resource 和 Prompt 要有 host 把它们带到界面或 context 中才有意义。OpenAI 也在 function calling 指南中介绍了把 MCP server 的功能当作 built-in tool 使用的方式。OpenAI 的 <a href="https://developers.openai.com/api/docs/guides/tools-connectors-mcp" target="_blank" rel="noopener noreferrer">MCP servers 指南</a>只说明了如何列出和调用工具，没有写明是否支持 Resource 或 Prompt。</p>
<h3 id="动态发现打开的攻击面"><a class="anchor" href="#动态发现打开的攻击面">动态发现打开的攻击面</a></h3>
<p>工具的 description 和工具调用的结果，会经由 host 进入模型的 context。所以无论 server 在那里写了什么，模型都会读到。两种代表性攻击都源于这一点。</p>
<ul>
<li>
<p><strong>Tool Poisoning Attack（TPA）</strong>：<a href="https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks" target="_blank" rel="noopener noreferrer">Invariant Labs</a> 于 2025 年 4 月命名并公开 PoC 的攻击。若把恶意指令隐藏在 MCP server 的工具 description 中，模型会读到这段用户看不见的文本，并可能在用户不知情的情况下照做。</p>
</li>
<li>
<p><strong>Rug Pull</strong>：Invariant Labs 在同一篇文章中描述的攻击，即在用户批准之后，server 修改工具定义。就像 <a href="https://simonwillison.net/2025/Apr/9/mcp-prompt-injection/" target="_blank" rel="noopener noreferrer">Simon Willison 引用的</a> Elena Cross 的例子：第 1 天批准了一个看起来安全的工具，到第 7 天它已经被改成把 API key 发给攻击者；这是因为工具定义不是在安装时，而是在运行时从 server 获取。<a href="https://modelcontextprotocol.io/specification/2025-11-25/server/tools" target="_blank" rel="noopener noreferrer">tool 规范</a>建议（SHOULD）提供显示哪些工具暴露给模型的 UI，但并不要求对变化后的定义重新批准，因此重新批准由 host 负责。</p>
</li>
</ul>
<h2 id="总结"><a class="anchor" href="#总结">总结</a></h2>
<p>总而言之，MCP 并不是要取代 function calling，而是建立在它之上的标准。模型调用工具的方式仍然是 <code>tools</code> 参数和 <code>tool_use</code> 循环，两个协议之间由 host 负责翻译。在 2025-11-25 修订版中，MCP 增加的是动态发现、stateful session、Tool 以外的 primitive，以及从 server 发往 client 的调用。在 2026-07-28 修订版中 session 消失、Sampling 被标为 deprecated 之后，留下的是在运行时交换工具列表与 context 的约定。正因为有这份约定，污染工具定义或悄悄修改它们的攻击也从同一个地方产生。再接入一个 MCP server 时，建议不仅确认这个 server 能做什么，也确认定义变化时 host 是否会提醒你。</p>
<p>如果说 MCP 关乎该让智能体能做什么，那么该告诉它什么，就是 <code>CLAUDE.md</code>、<code>AGENTS.md</code> 这类上下文文件的问题。这些文件如何被智能体读取、其中的指令能被遵守到什么程度，在<a href="/260529" target="_blank" rel="noopener noreferrer">上下文文件</a>中讨论。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle" target="_blank" rel="noopener noreferrer">MCP Specification 2025-11-25, Lifecycle</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning" target="_blank" rel="noopener noreferrer">MCP Specification 2026-07-28, Versioning</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>开发工具</category>
            <category>Claude</category>
            <category>MCP</category>
        </item>
        <item>
            <title><![CDATA[状态管理]]></title>
            <link>https://hooninedev.com/zh-CN/260518</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/260518</guid>
            <pubDate>Mon, 18 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊一聊状态管理（State Management）。它不是一篇库的横向对比。相比判断哪个工具更好，本文更想梳理一种感觉：应该如何看待状态，又该在哪里划定边界。 如今，AI 工具（Claude、ChatGPT、Cursor、Gemini、Copilot）已经深度融入我们的工作。开发速度呈指数级提升，但坦率地说，我感觉服务的完成度并没有同步跟上。功能越多，随之增加的 bug 也越多，我们也越...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊一聊<strong>状态管理（State Management）</strong>。它不是一篇库的横向对比。相比判断哪个工具更好，本文更想梳理一种感觉：应该<strong>如何看待</strong>状态，又该在哪里<strong>划定边界</strong>。</p>
<p>如今，AI 工具（Claude、ChatGPT、Cursor、Gemini、Copilot）已经深度融入我们的工作。开发速度呈指数级提升，但坦率地说，我感觉服务的完成度并没有同步跟上。功能越多，随之增加的 bug 也越多，我们也越来越常听到“我不知道为什么会变成这样”。</p>
<p>开发越快，我们就越少逐行细看代码。正因如此，我认为我们更需要具备一种<strong>能够为 AI 指明正确方向的基本功</strong>。只有能发现 AI 生成代码中的问题，并重新引导它朝预期方向前进，才能守住交付质量。这些基本功可以包括从领域视角开发、抽象、TDD（Test-Driven Development，测试驱动开发）、善用库，以及建立性能优势等很多方面。</p>
<p>不过，每当我问前端同事以及其他 IT 岗位的同事“前端开发中最棘手的工作是什么？”时，听到最多的答案始终如一：<strong>“管理状态流。”</strong></p>
<p>本文将梳理为什么状态流管理如此棘手，以及要想做好这件事，需要培养怎样的判断力与感觉。</p>
<h2 id="什么是状态state"><a class="anchor" href="#什么是状态state">什么是状态（State）</a></h2>
<p>在正式展开之前，先从最基础的问题说起。我们所说的“状态”究竟是什么？</p>
<p>学习前端开发时，我常读 <a href="https://blog.hoseung.me/2021-12-05-state-management" target="_blank" rel="noopener noreferrer">hoseung.me</a> 的文章。其中将状态定义为“<strong>所有可能影响 UI 的数据</strong>”。点赞数、购物车列表、模态框是否打开、输入值、当前登录用户的信息、当前选中的标签页、搜索结果、是否正在加载——这些全都是状态。</p>
<p>React 官方文档给出了更形式化的定义。页面标题就是 <a href="https://react.dev/learn/state-a-components-memory" target="_blank" rel="noopener noreferrer">“State: A Component's Memory”</a>，展开来说，大致是指“<strong>组件在多次渲染之间保留（retain）数据，并在数据更新时触发 React 重新渲染的机制</strong>”。也就是说，这类数据不会随时间消失，会因某个事件而更新，并在更新时让 UI 重新绘制。还有一点需要指出：状态<strong>按组件实例彼此隔离。</strong> 即使页面上存在十个相同组件，它们也各自拥有独立状态。这个事实与后文“状态应该放在哪里”的讨论直接相关。</p>
<p>两种定义指向的是同一件事：“<strong>会影响渲染，并随时间变化的值</strong>”就是状态。不变的常量（constant）不是状态。构建时就固定下来的原始设计 token 不是状态，但由用户切换的深色模式是状态。（严格来说，值本身会根据深色/浅色主题这一状态完成 resolve，因此更准确的理解是，“主题选择”才是状态，token 则是映照该状态的镜子。）</p>
<p>这里还有一点需要明确：<strong>并非所有状态都存在于组件中。</strong> 有些状态存在 Cookie 中，有些存在 localStorage、sessionStorage 或 IndexedDB 中，还有些存在 URL 中。把服务端数据带到客户端并加以缓存，也会形成一种状态。浏览器自身维护的滚动位置和历史记录栈，在它们会决定应用行为时，也需要被当作状态处理。</p>
<h2 id="为什么如此棘手"><a class="anchor" href="#为什么如此棘手">为什么如此棘手</a></h2>
<p>先简单想一想，处理状态为什么困难。创建需要的状态，把它传到需要的地方，再妥善处理更新与初始化，不就可以了吗？</p>
<p>把这个问题留在脑中，然后打开你正在开发的服务中的任意一个页面。</p>
<p>这个页面有多少个组件？即使是简单页面，也可能由少则几十、多则几百个组件组成一棵树。每个组件可能持有自己的状态，可能与兄弟组件共享状态，也可能从父组件接收状态。状态还会在页面之间迁移：有些状态刷新后必须保留，有些状态则应在关闭标签页时消失。</p>
<p>状态真正难以管理的原因就在这里：<strong>我们无法一眼看清大量状态分别在哪里声明、如何更新，又在何时消亡。</strong> 职责相似的组件越多，给状态命名以及追踪修改状态的代码就越困难。</p>
<p>于是，一张看不见的蛛网形成了。A 组件中的某次点击使 B 的数据失效，B 的失效又让 C 的 UI 关闭，C 关闭时表单输入随之消失。如果这种连锁关系没有在代码的任何地方明确表达，那么调试 bug 时，我们就只能在脑中重新画出这张蛛网。</p>
<p>那么，该如何整理这张蛛网？在我看来，第一步是意识到：<strong>“状态有不同的种类。”</strong></p>
<h2 id="并非所有状态都是同一种状态"><a class="anchor" href="#并非所有状态都是同一种状态">并非所有状态都是同一种状态</a></h2>
<p><a href="https://kentcdodds.com/blog/application-state-management-with-react" target="_blank" rel="noopener noreferrer">Kent C. Dodds</a> 将状态分为 <strong>Server Cache</strong>（服务端保存信息，客户端为便于快速访问而持有的副本）与 <strong>UI State</strong>（只存在于 UI 中，用于控制界面行为）。我们经常在把两者混为一谈时犯错。</p>
<p><a href="https://tanstack.com/query/latest/docs/framework/react/guides/does-this-replace-client-state" target="_blank" rel="noopener noreferrer">TanStack Query 官方文档</a>将 TanStack Query 定义为 Server State 库，用于管理服务端与客户端之间的异步工作；Redux、MobX、Zustand 等工具则被定义为 Client State 库。（它们虽然能够存储异步数据，但这样做效率不高。）</p>
<p>关键点很明确：<strong>Server State 与 Client State 是不同的问题。</strong> Server State 是异步的，可能被其他用户修改，并会随时间变得 stale。Client State 是同步的、受我们控制，并会在刷新后消失。（准确地说，页面 unload 时，<strong>JavaScript 运行时会重新启动，heap 内存中的组件树及其状态会一并被回收。</strong> 因此再次 mount 时，会从 <code>useState</code> 的初始值重新开始。）如果试图用同一种工具管理两者，就必须亲手实现缓存失效、后台刷新、乐观更新等模式。</p>
<p>在此基础上，我会更进一步，把前端状态分为<strong>七个类别</strong>。需要提前说明的是，这七类并不能沿单一维度整齐划分。存储位置、来源、生命周期和职责彼此交织，因此一个状态可能同时属于多个类别。请不要把它看作一张完美的分类表，而应把它理解为<strong>决定如何管理状态时需要提出的一组问题</strong>。</p>
<ul>
<li><strong>局部状态（Local State）</strong>：只在一个组件或较小子树中使用的状态</li>
<li><strong>全局状态（Global State）</strong>：需要由整个应用共享的状态</li>
<li><strong>服务端状态（Server State）</strong>：以服务器为 Single Source of Truth、客户端副本仅作为缓存的状态</li>
<li><strong>表单状态（Form State）</strong>：用户输入期间暂时存在的状态</li>
<li><strong>URL 状态（URL State）</strong>：位于地址栏中、可分享且刷新后仍保留的状态</li>
<li><strong>外部状态（External State）</strong>：位于 React 外部的状态，如 Cookie、localStorage、sessionStorage 和 IndexedDB</li>
<li><strong>状态守卫（State Guard）</strong>：根据状态组合阻止、允许或校验访问与操作的逻辑，而不是状态本身</li>
</ul>
<p>除此之外，还有适合用状态机精细建模的工作流状态，以及基于 WebSocket 或 CRDT 的实时协作状态。</p>
<p>下面逐一说明为什么它们需要不同的工具，以及应以怎样的判断力来对待它们。</p>
<h2 id="局部状态local-state"><a class="anchor" href="#局部状态local-state">局部状态（Local State）</a></h2>
<p>这是最简单的一类状态。它只在一个组件内使用，外部既不需要知道，也无权知道。例如模态框是否打开、切换按钮的 on/off、悬停状态、正在输入的搜索词。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> SearchBox</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">query</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">""</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">input</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{query} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">e</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(e.target.value)} />;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>这些大家应该已经很熟悉了。但局部状态真正棘手的地方，是“<strong>这个状态应该放在哪里</strong>”这一位置决策。</p>
<p><a href="https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster" target="_blank" rel="noopener noreferrer">Kent C. Dodds 的 State Colocation</a>一文指出：<strong>人们习惯于把状态“提升（lift up）”，却不擅长在代码发生变化后，重新把状态“就近放置（colocate）”。</strong></p>
<p>当兄弟组件需要共享同一状态时，提升状态是我们很自然的做法。既然两个兄弟组件要读取同一份数据，就把状态提升到共同父组件，再通过 props 向下传递。</p>
<p>问题出在兄弟组件不再需要该状态时。我们通常不会把状态重新<strong>下移</strong>到子组件中。结果，父组件积累了大量实际上与自身无关的状态，每次父组件重新渲染时，整棵子树也会随之重新渲染。</p>
<p>因此，处理局部状态的第一条判断准则是：<strong>为了让代码更快、更简单，应让状态尽可能靠近使用它的代码。</strong> 如果某个状态只被一个组件的某个子组件使用，父组件就没有理由持有它。把它移到该子组件内部，父组件也会因此变得更轻。</p>
<h2 id="全局状态global-state"><a class="anchor" href="#全局状态global-state">全局状态（Global State）</a></h2>
<p>全局状态是需要在应用任意位置访问的状态。登录信息、主题、语言、通知（toast）等都可能属于这一类。</p>
<p>局部状态与全局状态的区别，并不只是“存在于哪里”。二者对<strong>引用方式的承诺</strong>不同。局部状态向代码承诺的是“<strong>只在这个组件内部有意义</strong>”；全局状态则向整个代码库发布一项承诺：“<strong>应用中的任何位置，都可以用这个名字引用这个值。”</strong> 这项承诺成本高昂，正是全局状态的本质。</p>
<p>创建一个全局状态，实际上就是为<strong>整个应用增加一项隐式依赖</strong>。</p>
<h2 id="服务端状态server-state"><a class="anchor" href="#服务端状态server-state">服务端状态（Server State）</a></h2>
<p>把 API 返回的数据塞进 Client State，亲自用 boolean 管理加载与错误，做着做着便会产生疑问：<strong>“为什么每次都在写同样的样板代码？”</strong></p>
<p>TanStack 的主要维护者 Tanner Linsley 曾表示：<strong>“Client State 是同步且可预测的。Server State 是异步的，会被多个组件共享，必须谨慎处理缓存、后台刷新与错误状态。”</strong> 也就是说，Server State 与 Client State <strong>本质上是不同的物种</strong>，不应使用同一种工具处理。</p>
<p>Server State 的棘手之处，不在于工具，而在于<strong>数据的本质</strong>。</p>
<p>客户端看到的数据属于服务端。客户端持有的只是<strong>某一时刻的快照</strong>。随着时间推移，这份数据会产生 staleness。它还是异步的，可能失败，并拥有 pending、error、success 等状态。</p>
<p>最重要的本质是：<strong>响应无法保证按照请求发出的顺序返回。</strong> 假设用户在搜索框中快速输入“react”。r → re → rea → reac → react 的请求会依次发出，但如果“react”的响应先到，随后“rea”的响应才到，页面最终显示的就会是“rea”的结果。要避免这类问题，每次都得手动编写 AbortController 或请求 ID 追踪逻辑，因此必须关注这种<strong>并发风险（race conditions）</strong>。</p>
<h2 id="表单状态form-state"><a class="anchor" href="#表单状态form-state">表单状态（Form State）</a></h2>
<p>表单是一类微妙的状态。用户输入期间，它会剧烈变化；一旦提交，通常就会消失。它不会与其他地方共享，也（大多）没有需要保存的去处。</p>
<p>问题在于，这种“剧烈变化”代价不菲。如果每次按键都触发 React 重新渲染，那么在大型表单中，输入延迟会明显到可以被用户察觉。而且表单不只是“持有值”。<strong>校验、dirty check、提交状态、错误信息、多步骤流程</strong>等多种状态，会同时在一个表单内部运转。</p>
<p>像三步支付流程这样的多步骤表单，通常会被期待“<strong>即使中途刷新，进度状态也能保留</strong>”。如果只用 useState 保存表单值，刷新后所有内容都会丢失。更自然的做法是保存在 <strong>sessionStorage</strong>（标签页级临时存储）或 <strong>URL</strong>（可分享的步骤）中。也就是说，表单状态会根据生命周期要求，与<strong>外部状态</strong>或 <strong>URL 状态</strong>结合。</p>
<h2 id="url-状态url-state"><a class="anchor" href="#url-状态url-state">URL 状态（URL State）</a></h2>
<p>假设我们正在搜索页面中按分类、排序方式和页码进行筛选。如果用 useState 保存这些状态，会同时出现三个问题。</p>
<ul>
<li>刷新后，所有筛选条件都会重置</li>
<li>即使把 URL 分享给朋友，对方看到的仍是没有应用筛选条件的页面</li>
<li>点击后退，也无法回到之前的筛选条件</li>
</ul>
<p>解决这些问题时，<strong>把状态放进 URL 是很自然的选择。</strong> URL 本身就是一种无需额外成本的持久存储，天然支持刷新、分享和历史记录。</p>
<pre><code>/products?category=shoes&#x26;sort=price-desc&#x26;page=2
</code></pre>
<p>这一行 URL 中已经包含了“<strong>鞋类商品按价格降序排列后的第 2 页</strong>”这一完整状态，无需再用 useState 单独保存。</p>
<p>那么，什么时候适合用 URL 管理状态？<strong>URL 是公开接口。</strong> 密码、认证 token、用户不愿让他人看到的临时备忘等信息，都不应放入 URL。此外，如果把变化过于频繁的值（每次输入都会变化的搜索词）直接写进 URL，历史记录栈就会被垃圾填满。这种情况下，应在 debounce 后再同步，仅在合适时使用 <code>push</code>，而对不应新增历史记录的更新使用 <code>replace</code>。</p>
<p>URL 中的值<strong>始终是字符串</strong>。数字、boolean、数组、对象都必须经过序列化与反序列化。并且 URL 必须遵循<a href="https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams" target="_blank" rel="noopener noreferrer">百分号编码（percent-encoding）</a>规则，<code>&#x26;</code>、<code>=</code>、韩文、空格等字符都需要特殊处理。如果每次都手写这些逻辑，很快就会成为 bug 的温床。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> params</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> URLSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(location.search);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> page</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(params.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"page"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "1"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">params.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"page"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">String</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(page </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">navigate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`?${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">params</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toString</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">()</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">page</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setPage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQueryState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"page"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, parseAsInteger.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">withDefault</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span></code></pre></figure>
<p><a href="https://nuqs.dev/" target="_blank" rel="noopener noreferrer">nuqs</a> 之类的库通过<strong>解析器（parser</strong>）概念解决了这两个问题。<code>parseAsInteger</code>、<code>parseAsBoolean</code>、<code>parseAsJson</code> 等解析器统一负责序列化、反序列化与类型。它支持 Next.js（App/Pages Router）、React Router v6/v7、TanStack Router、Remix 等大多数环境。</p>
<p>那么，可以把任意多的状态都塞进 URL 吗？除了序列化和类型问题之外，还有最后一个约束需要注意。<a href="https://datatracker.ietf.org/doc/html/rfc7230" target="_blank" rel="noopener noreferrer">RFC 7230</a> 并未规定精确上限，但建议“服务器至少应支持 8,000 个 octet（在网络或数据通信中，用于明确指代由 8 Bit 组成的 1 Byte 的单位）”。不同浏览器的限制也各不相同。现代浏览器大多允许 8KB 到数万字符，但<strong>搜索引擎、社交媒体的 OG/分享处理，以及部分网关，可能在接近 2KB 时就截断内容</strong>。因此，不要无限制地往 URL 中塞数据。只保留<strong>可分享的核心筛选条件</strong>，其余内容交给 sessionStorage 或服务端存储会更安全。</p>
<h2 id="外部状态external-state"><a class="anchor" href="#外部状态external-state">外部状态（External State）</a></h2>
<p>React 只知道自身内部的状态，但我们的应用也在不断与 React 之外的世界交互。存在于那个世界中的状态，不受 React 生命周期约束，会独立存续，也会发生变化。这里所说的外部状态包括 <strong>Cookie、localStorage、sessionStorage、IndexedDB</strong>。</p>
<p>应该如何选择存储方式？我通常从<strong>生命周期、容量、同步性、安全性</strong>四个维度思考。</p>
<p>对于<strong>认证 token</strong>，<a href="https://owasp.org/www-community/HttpOnly" target="_blank" rel="noopener noreferrer">OWASP 的建议</a>首选 <strong>HttpOnly + Secure Cookie</strong>。localStorage 可由 JavaScript 访问，因此<strong>一旦暴露于 XSS，token 就会被直接窃取</strong>。部分安全指南建议采用混合模式：<strong>access token 放在内存中，refresh token 放在 HttpOnly Cookie 中</strong>。需要持久保存、不敏感且不经常变化的数据，可以放在 localStorage；应随标签页关闭而消失的数据，则可以使用 sessionStorage。离线缓存、大体量数据和文件通常使用 IndexedDB。</p>
<p>Cookie 与 Web Storage（local/session）<strong>只能存储字符串</strong>。因此，要存入对象就必须经过 <code>JSON.stringify</code>/<code>JSON.parse</code>。但 JSON 存在局限。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ when: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// → { "when": "2026-05-19T..." } — Date becomes a string</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ map: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"a"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]]) });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// → { "map": {} } — Map is lost entirely</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ value: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">undefined</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// → "{}" — the undefined field is omitted</span></span></code></pre></figure>
<p><code>Date</code> 在 JSON 往返转换后会变成字符串，<code>Map</code>、<code>Set</code> 和 <code>undefined</code> 则可能丢失数据。默认情况下，<code>BigInt</code> 会让 <code>JSON.stringify</code> 抛出 <code>TypeError</code>，导致序列化直接失败。把对象存入外部存储时，必须始终留意<strong>哪些类型可能消失、改变或导致序列化失败</strong>，必要时应提供序列化适配器。</p>
<p>外部状态真正的难点是：<strong>React 无法自动检测它的变化。</strong> 即使向 localStorage 写入值，React 组件也不会重新渲染。解决这一问题通常有三种模式。</p>
<ul>
<li><strong>用自定义 hook（useLocalStorage）再封装一层，把外部状态同步为 React state。</strong> 这种方式轻量，但如果自行实现，就必须处理多标签页、SSR、tearing 等各种边界情况。</li>
<li>使用 React 18 引入的 <code>useSyncExternalStore</code> hook，<strong>“与 React 外部状态同步”。</strong> 借此可以<strong>确保并发渲染中不会发生 tearing。</strong> 它是连接 localStorage、浏览器 API 与外部 store 的标准工具。</li>
<li>Zustand 的 <code>persist</code> middleware、Jotai 的 <code>atomWithStorage</code> 等状态库，把外部存储集成作为一等能力提供，因此也可以直接使用已有的库。</li>
</ul>
<p>这里再补充一条判断准则：<strong>一旦把外部状态带入 React，同步责任就落到了我们身上。</strong> 如果另一个标签页更新了它呢？如果服务端修改了 Cookie 呢？如果用户通过浏览器开发者工具直接修改 localStorage 呢？这些情况往往会成为最严重的 bug 温床。</p>
<h2 id="状态守卫state-guard"><a class="anchor" href="#状态守卫state-guard">状态守卫（State Guard）</a></h2>
<p>最后一类稍有不同。它不是状态本身，而是<strong>根据状态组合来阻止、允许或校验某个流程的逻辑</strong>。</p>
<p>最常见的例子是<strong>认证守卫（Auth Guard）</strong>。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ProtectedRoute</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isAuthenticated</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useAuth</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isLoading) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Spinner</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">isAuthenticated) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Navigate</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> to</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"/login"</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> replace</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> children;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>这里，<code>isAuthenticated</code> 状态控制了路由流程，这就是守卫逻辑。守卫有很多种，包括认证守卫（是否已认证）、权限守卫（特定角色、权限）、流程守卫（进入分支）、校验守卫（启用步骤）等。</p>
<p>守卫逻辑很容易堆积在一个地方。常见的情况是，一个组件里同时写满了“<strong>未登录就去登录页、无权限就去 403、购物车为空就去商品页、用户被封禁就显示封禁提示</strong>”。守卫越臃肿，就越难调试究竟是哪个条件在哪里阻断了流程。</p>
<p>好的守卫<strong>只检查一件事。</strong> 组合则通过 Composition 完成。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">AuthGuard</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">RoleGuard</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> role</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"admin"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">FlowGuard</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> require</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"cartHasItems"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">CheckoutPage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">FlowGuard</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">RoleGuard</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">AuthGuard</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>每个守卫只作出一个决定，组合关系由树形结构负责。添加新守卫时，无需修改现有守卫。</p>
<p>处理守卫时，比“是否拦截”更需要仔细考虑的是：<strong>拦截后把用户送到哪里，以及后续如何处理。</strong> 只会拦截、没有 fallback 的守卫，最终只会留下白屏或无限 spinner。</p>
<p>最常见的 bug 是：<strong>“在守卫的异步检查完成前，受保护内容会短暂闪现。”</strong> 认证 token 校验、权限查询大多是异步操作，在此期间，<code>isAuthenticated</code> 会有一小段时间处于 <code>undefined</code> 或 <code>false</code>。<strong>如果不显式处理加载状态，受保护页面就可能在这个间隙暴露，或用户可能被错误重定向到登录页。</strong></p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// Ignores loading and handles only missing data => incorrect</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Navigate</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> to</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"/login"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// Treat loading as a first-class state (early return) => correct</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isLoading) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Spinner</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Navigate</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> to</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"/login"</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> replace</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> children;</span></span></code></pre></figure>
<p>编写权限守卫时，常见的模型有两种。</p>
<ul>
<li><strong>RBAC（Role-Based Access Control）</strong>：按角色授予权限，例如“admin 可以查看所有用户信息”。它简单、快速，但随着角色不断细分，角色数量会爆炸式增长</li>
<li><strong>ABAC（Attribute-Based Access Control）</strong>：根据属性组合决定权限，例如“用户是该帖作者、与作者属于同一团队，或用户是 admin”。它表达能力强，但实现和调试更困难</li>
</ul>
<p>如 <a href="https://tanstack.com/router/v1/docs/framework/react/how-to/setup-rbac" target="_blank" rel="noopener noreferrer">TanStack Router 的 RBAC 指南</a>所示，推荐在路由层的 <code>beforeLoad</code> 中设置守卫。关键在于：<strong>权限检查不应散落在代码各处，而应能表达为数据（角色/权限列表）</strong>。这样一来，权限策略调整只需变更<strong>数据</strong>。</p>
<h2 id="总结"><a class="anchor" href="#总结">总结</a></h2>
<p>总结一下。状态管理之所以困难，不是因为库难用，而是因为我们经常忘记：<strong>状态有不同种类</strong>，也容易忽略不同种类需要不同的工具与思考方式。</p>
<p>局部状态应尽量就近放置；面对全局状态，要再确认一次它是否真的需要全局；Server State 应作为缓存处理；表单应与领域分离；URL 应更积极地使用；外部存储需要明确意识到自身责任；守卫则应拆薄后进行组合。这就是处理七类状态的基本功。</p>
<p>而凌驾于这些具体做法之上的判断力，最终可以浓缩为四个问题。</p>
<ul>
<li>这份数据的 Single Source of Truth 在哪里？</li>
<li>这是可以计算得到的值，还是确实必须存储的值？</li>
<li>这些状态的组合中，是否存在不可能的组合？</li>
<li>这个状态真的应该位于这里吗？</li>
</ul>
<p>每次开发新页面、审查 PR、接收 AI 生成的代码时，都把这些问题问上一遍。我相信，这是培养判断力与感觉最可靠的方式。</p>
<p>正如开头所说，AI 会长久地留在我们身边。我们逐行查看代码的时间会越来越少。但越是如此，能够回答“<strong>这个状态应该放在哪里</strong>？”这类小问题的能力就越有价值。让 AI“在这里再加一个 useState”很容易；但那一行代码会在应用的蛛网上增添一根怎样的丝线，只有阅读代码的人凭借自己的判断力才能看清。</p>
<p>不存在唯一正确答案。但至少，“<strong>不知道状态是什么就创建状态</strong>”与“<strong>意识到状态的种类和位置后再创建状态</strong>”之间，有着明确的区别。希望读者下次写下一行 <code>useState</code> 之前，也能暂时停一下，问问自己：“它属于哪一类状态？”</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/learn/choosing-the-state-structure" target="_blank" rel="noopener noreferrer">React, Choosing the State Structure</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://legacy.reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html" target="_blank" rel="noopener noreferrer">React, You Probably Don't Need Derived State</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://xstate.js.org/" target="_blank" rel="noopener noreferrer">XState</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.syncfusion.com/blogs/post/react-state-management-libraries" target="_blank" rel="noopener noreferrer">Top 5 React State Management Tools in 2026</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>前端</category>
            <category>状态管理</category>
            <category>React</category>
            <category>架构</category>
        </item>
        <item>
            <title><![CDATA[领域模型]]></title>
            <link>https://hooninedev.com/zh-CN/260418</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/260418</guid>
            <pubDate>Sat, 18 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊聊前端的领域逻辑应该放在哪里。 本文写给这样的前端开发者：税额计算、申报状态判断这类业务规则散落在组件、Hook 和 utils 里，每改一条规则都要四处寻找并修改好几个文件。读完之后，你会知道如何把这些规则移到不依赖 React 的纯函数中，以及判断这种拆分应在何处停止的标准。 文中的例子全部使用综合所得税的计算，这是笔者一直关注的领域。 前端的领域逻辑应该放在哪里？ 回答之前，先统...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊<strong>前端的领域逻辑应该放在哪里</strong>。</p>
<p>本文写给这样的前端开发者：税额计算、申报状态判断这类业务规则散落在组件、Hook 和 utils 里，每改一条规则都要四处寻找并修改好几个文件。读完之后，你会知道如何把这些规则移到不依赖 React 的纯函数中，以及判断这种拆分应在何处停止的标准。</p>
<p>文中的例子全部使用综合所得税的计算，这是笔者一直关注的领域。</p>
<h2 id="前端的领域逻辑应该放在哪里"><a class="anchor" href="#前端的领域逻辑应该放在哪里">前端的领域逻辑应该放在哪里？</a></h2>
<p>回答之前，先统一一个术语。本文所说的<strong>领域模型</strong>，是把业务概念和作用于其上的规则放在一起的东西。它不同于像 API 响应类型那样、只定义数据以什么形状传递的<strong>数据模型</strong>。组件一旦直接依赖响应结构，后端 schema 每次变化都会让整个前端跟着动摇，原因也正是这个区别。领域、领域模型、领域对象、领域对象模型这四个术语有什么不同，另外整理在<a href="/260413" target="_blank" rel="noopener noreferrer">领域术语</a>中。</p>
<p>热衷于软件设计的 <a href="https://khalilstemmler.com/about/" target="_blank" rel="noopener noreferrer">Khalil Stemmler</a> 起初主张“业务逻辑不属于前端”，后来又调整了立场，表示“后端在架构层面所做的几乎一切，前端也能做，而且应该做。”</p>
<p>我也认同这一观点。当然，前端不应成为业务逻辑的<strong>唯一事实来源（Single Source of Truth）</strong>，那是后端的职责。但前端确实存在<strong>前端独有的领域逻辑</strong>。</p>
<p>设想这样一种情况：“需要根据用户输入的信息，实时展示预计退税额。”如果这类计算逻辑只存在于后端，用户每修改一个收入金额的字符，就要调用一次 API。用户界面会在网络往返期间停顿；如果用户输入很快，还会产生爆炸式增长的无用请求。即使加入防抖，数百毫秒的延迟也足以破坏“实时预览”的体验。<strong>最终，需要即时反馈的计算只能由前端自行完成，于是也就出现了只能在前端执行的逻辑。</strong></p>
<h3 id="领域逻辑混入组件的情况"><a class="anchor" href="#领域逻辑混入组件的情况">领域逻辑混入组件的情况</a></h3>
<p>以综合所得税预览页面为例。用户输入收入信息后，页面会实时展示预计税额。下面是一段常见的代码，其中领域逻辑和用户界面逻辑混在了一起。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxPreviewPage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">총수입</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">set총수입</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">경비율</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">set경비율</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.641</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">인적공제대상인원</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">set인적공제대상인원</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 종합소득금액</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 총수입 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 총수입 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 경비율;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 소득공제합계</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 인적공제대상인원 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1_500_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 과세표준</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">max</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, 종합소득금액 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 소득공제합계);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 14_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.06</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 50_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.15</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1_260_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 88_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.24</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 5_760_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 150_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.35</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 15_440_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.38</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 19_940_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 기납부세액</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 총수입 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.033</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> refundOrPayment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 기납부세액 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> calculatedTax;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>...&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>看出这段代码的问题了吗？“每人 150 万韩元的人身扣除”“8 级累进税率”“3.3% 预扣税”等<strong>由税法规定的业务规则</strong>被直接写死在 React 组件中。税法每年都会修订，如果这些规则散落在各个组件里，修订时就必须四处寻找需要修改的位置。如果质量保证团队还有端到端测试场景需要维护，测试成本也不会低。</p>
<p>最终，视图逻辑与业务逻辑变得难以区分，代码也会被大量条件语句和自定义钩子缠成一团。</p>
<h3 id="分离领域逻辑"><a class="anchor" href="#分离领域逻辑">分离领域逻辑</a></h3>
<p>借用 Alex Bespoyasov 的 Clean Architecture 方法中的一项核心原则：把领域逻辑分离为<strong>不依赖框架的纯函数</strong>。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>领域是区分一个应用与另一个应用的核心。可以把领域理解为，即使从 React 迁移到 Angular，也不会发生变化的部分。</p></div><div class="quote-original" lang="en"><p>The domain is the core that distinguishes one application from another. You can think of the domain as something that won't change if we move from React to Angular.</p></div></blockquote>
<p>来重构上面的税额计算示例。</p>
<p>首先定义领域类型和规则，将相关信息聚合起来。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Income</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  grossAmount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  expenseRate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Deductions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  personalCount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  pensionPaid</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  additionalDeductions</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> PERSONAL_DEDUCTION_PER_PERSON</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1_500_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> WITHHOLDING_RATE</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.033</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> TAX_BRACKETS</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { limit: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">14_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, rate: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.06</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, progressiveDeduction: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> },</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  /** ...구간들... **/</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>然后，将领域逻辑分离为纯函数。</p>
<p>把前面计算收入、扣除、计税依据、税额、退税额等逻辑分离到 <code>computeFullTax</code> 函数中，再把每个阶段拆成更小的纯函数。结果类型可以通过 <code>ReturnType&#x3C;typeof computeFullTax></code> 推断，无需单独声明接口。</p>
<p>之后，组件只需“使用”领域逻辑。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { computeFullTax } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "../domain/tax"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxPreviewPage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">income</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setIncome</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Income</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    grossAmount: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    expenseRate: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.641</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">deductions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setDeductions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Deductions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    personalCount: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    pensionPaid: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    additionalDeductions: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> result</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> computeFullTax</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(income, deductions);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">IncomeForm</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{income} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{setIncome} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DeductionForm</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{deductions} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{setDeductions} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">TaxResultSummary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> result</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{result} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>发生了哪些变化？</p>
<ul>
<li><strong>8 级累进税率表</strong>（<code>TAX_BRACKETS</code>）集中在一处，税法修订时只需修改 <code>domain/tax.ts</code></li>
<li><strong>计算流水线</strong>聚合在 <code>computeFullTax</code> 这一个函数中，整体流程一目了然。（为了让示例保持简单，这里将它合并成了一个函数；在实际项目中，更适合按收入计算、扣除计算、税额计算等目的进一步细分。）</li>
<li><strong>组件只专注于“如何展示”</strong>。即使税率变化，也无需修改组件</li>
<li>即使从 React 迁移到其他框架，<code>domain/tax.ts</code> 也<strong>无需变化</strong></li>
</ul>
<p>分离领域逻辑后，测试会变得出奇简单。在税务领域，<strong>计算是否准确直接关系到用户的钱</strong>，所以这一点尤其重要。</p>
<p>包含税务计算逻辑的纯函数不需要 React Testing Library，也不需要 <code>render</code> 或 <code>screen.getByText</code>。传入输入、检查输出即可。“1,400 万韩元及以下适用 6% 税率”“计税依据为 0 韩元时税额也为 0 韩元”“自由职业者收入 3,000 万韩元时的退税额”等用例，都可以用一行 <code>it</code> 来表达。领域单元测试会自然地帮助确定组件的拆分边界，测试代码本身也能充当文档。</p>
<h2 id="贫血领域模型anemic-domain-model"><a class="anchor" href="#贫血领域模型anemic-domain-model">贫血领域模型（Anemic Domain Model）</a></h2>
<p>上一节分离了<strong>计算逻辑</strong>。但领域逻辑除了计算，还包括<strong>状态转换规则</strong>和<strong>权限判断</strong>。例如：“现在可以修改这份申报单吗？”“可以提交吗？”“可以切换申请方式吗？”在分离这些规则时，很容易落入一个陷阱，也就是 Martin Fowler 命名的<strong>贫血领域模型（Anemic Domain Model）</strong>。</p>
<p>贫血领域模型是指：<strong>类型虽然用领域语言定义得很好，但作用于类型之上的规则却散落到了领域之外</strong>。以报税申报（Filing）领域为例，类型本身很整洁。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// types/filing.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "submitted"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "reviewing"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amended"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  taxYear</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  filingType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "regular"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "late"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  determinedTax</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>但针对这个类型的判断和转换规则，却写死在其他地方。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// utils/filingHelpers.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> canAmendFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.filingType </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// components/FilingDetail.tsx</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingDetail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 같은 도메인 규칙을 컴포넌트 안에 다시 작성한다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> canEdit</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "reviewing"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// hooks/useSubmitFiling.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> handleSubmitFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>同一条领域规则分别以不同形式存在于工具函数、组件和钩子这三个地方。此时，如果收到“申请条件将发生变化”的需求，就必须四处寻找要修改的位置；任何一个被遗漏的地方，都会在网站的某处作出错误判断。Fowler 批评这类代码，称其“<strong>与只披了一层面向对象外衣的过程式代码别无二致</strong>”。</p>
<p>解决办法与上一节处理计算逻辑的方法相同：<strong>把规则放在类型旁边。</strong></p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  taxYear</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  filingType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  determinedTax</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "submitted"</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "reviewing"</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amended"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "regular"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "late"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 도메인 규칙은 도메인 옆에 둔다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> canSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.determinedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.filingType </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>现在，申报相关规则都在 <code>domain/filing.ts</code> 中统一管理。任何组件只需调用 <code>canAmend(filing)</code>；规则发生变化时，也只需修改这一个文件。关键在于，<strong>应该把类型及其上的规则视为一个整体</strong>。如果只把类型放进领域文件夹，却把规则抽到工具函数中，这种局部分离即使外表整洁，仍然处于贫血状态。</p>
<h2 id="api-响应与领域模型之间的转换层"><a class="anchor" href="#api-响应与领域模型之间的转换层">API 响应与领域模型之间的转换层</a></h2>
<p>在实际工作中，还要考虑另一件事：后端 API 的响应结构不一定总与前端领域模型一致。如果是与国家机关对接的税务服务，就更是如此。韩国国税厅 Hometax 的对接数据充斥着缩写和代码值，几乎不可能以与前端领域模型相同的形式返回。</p>
<p>此时就需要<strong>转换层（Mapper）</strong>。不要让 API 响应类型原封不动地一路流入组件，而应先将其整理成领域类型，再交给组件使用。一个纯函数就足够了。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> type</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { Income } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "../domain/tax"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> HometaxIncomeResponse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  총수입금액</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  경비율</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  소득유형코드</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ... 나머지 약어 필드들</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> toIncome</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">response</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> HometaxIncomeResponse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Income</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    총수입_금액: response.총수입금액,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    경비_비율: response.경비율,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>这样一来，就能在<strong>一个地方</strong>把 API 响应中的 <code>총수입금액</code>、<code>경비율</code> 等缩写，以及基于代码值的分类，转换为适合前端领域的形式。像收入类型代码这样需要展开为枚举的值，可以在转换器中放一张小型查找表。即使 Hometax API 的字段名发生变化，也只需修改一个转换器。</p>
<h2 id="工具函数与领域逻辑"><a class="anchor" href="#工具函数与领域逻辑">工具函数与领域逻辑</a></h2>
<p>分离领域逻辑时，必然会遇到一个问题：<strong>“这不就是工具函数吗？”</strong></p>
<p>来看下面两个函数。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatCurrency</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">amount</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toLocaleString</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">()</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}원`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> calculateTax</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">taxableBase</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> bracket</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> TAX_BRACKETS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">find</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">bracket</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> taxableBase </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> bracket.limit);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(taxableBase </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> bracket.rate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> bracket.progressiveDeduction);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>formatCurrency</code> 是把数字转换成字符串的纯<strong>表现层逻辑（Presentation）</strong>。加上“韩元”单位和千位分隔符并不是业务规则，而是如何把内容展示给用户的问题。相反，<code>calculateTax</code> 包含“应用 8 级累进税率”这一<strong>基于税法的业务规则</strong>。即使没有用户界面，这条领域规则也必须同样适用。</p>
<p>我在实际工作中使用的判断标准是：</p>
<blockquote>
<p><strong>如果这段逻辑消失，受损的是业务，还是只有页面？</strong></p>
</blockquote>
<p>如果业务受损，它就是领域逻辑；如果只有页面受损，它就是表现层逻辑。仅凭这个问题，就能划清大多数边界。</p>
<table>
<thead>
<tr>
<th>判断标准</th>
<th>领域逻辑</th>
<th>工具/表现层逻辑</th>
</tr>
</thead>
<tbody>
<tr>
<td>缺少它会导致什么问题？</td>
<td>税额计算错误</td>
<td>页面（用户界面）显示异常</td>
</tr>
<tr>
<td>框架变化时呢？</td>
<td>保持不变</td>
<td>可能变化</td>
</tr>
<tr>
<td>需求文档中有明确规定吗？</td>
<td>“计税依据 × 税率 - 累进扣除额”</td>
<td>“金额使用千位分隔符”</td>
</tr>
<tr>
<td>后端也有同样的逻辑吗？</td>
<td>已经有，或应该有</td>
<td>没有（仅属于前端的关注点）</td>
</tr>
</tbody>
</table>
<p>但现实并没有这么清晰。最棘手的是，<strong>有些逻辑看起来像领域逻辑，其实却是表现层逻辑</strong>。</p>
<p>来看下面的代码。因为它以 FilingStatus 这一领域概念作为参数，所以被归类为领域逻辑。但它真的是领域逻辑吗？</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// domain/filing.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getStatusBadgeColor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> colors</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Record</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    draft: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"gray"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    submitted: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"blue"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    reviewing: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"yellow"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    completed: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"green"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    amended: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"purple"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> colors[status];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getStatusDisplayText</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> labels</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Record</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    draft: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"작성 중"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    submitted: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"제출 완료"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    reviewing: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"검토 중"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    completed: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"신고 완료"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    amended: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"경정청구"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> labels[status];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>getStatusBadgeColor</code> 和 <code>getStatusDisplayText</code> 虽然使用了 <code>FilingStatus</code> 这一领域概念，但它们做的事情是<strong>页面呈现</strong>。徽章颜色变化并不会对业务造成任何影响。把这类函数放进 <code>domain/filing.ts</code>，会让领域模块越来越臃肿，真正的领域逻辑和表现层逻辑也会混在一起。</p>
<h3 id="分离领域模型与-viewmodel"><a class="anchor" href="#分离领域模型与-viewmodel">分离领域模型与 ViewModel</a></h3>
<p>有一种实用的方法可以解决这个问题：<strong>在同一个领域文件夹中，把 ViewModel 分离到单独的文件里</strong>。与 <code>.ui.ts</code> 相比，采用 <code>.viewModel.ts</code> 这一命名能自然地与 MVVM 模式中的 ViewModel 概念衔接，因为“把领域数据转换成适合页面呈现的形式”这一职责能够直接从名称中体现出来。</p>
<pre><code>domains/
└── filing/
    ├── filing.ts              # 순수 도메인 모델 + 도메인 로직
    ├── filing.viewModel.ts    # ViewModel (표현 변환 계층)
    ├── filing.test.ts         # 도메인 로직 테스트
    └── filingMapper.ts        # API ↔ 도메인 변환
</code></pre>
<p>把前面看到的 <code>getStatusBadgeColor</code>、<code>getStatusDisplayText</code> 原样移到 <code>filing.viewModel.ts</code>。此外，像 <code>getFilingTypeLabel(type: FilingType): string</code> 这样把申报类型转换成韩文标签的逻辑，也集中放在这里。<code>filing.ts</code> 只负责业务规则，<code>filing.viewModel.ts</code> 只负责页面呈现。</p>
<p>关键在于<strong>依赖方向</strong>。<code>filing.viewModel.ts</code> 会导入 <code>filing.ts</code>，但 <code>filing.ts</code> 绝不能导入 <code>filing.viewModel.ts</code>。领域不了解表现层，而表现层了解领域。可以把它看作 Robert C. Martin 所说的依赖规则（Dependency Rule）的缩小版。</p>
<p>我认为共同变化的文件应该放在同一目录，因此把它们放在了同一个文件夹。给 <code>FilingStatus</code> 类型添加新值（例如 <code>'rejected'</code>）时，<code>filing.ts</code> 和 <code>filing.viewModel.ts</code> 都需要修改。由于位于同一个文件夹，修改范围一目了然。</p>
<h2 id="边界与内聚"><a class="anchor" href="#边界与内聚">边界与内聚</a></h2>
<p>与分离领域逻辑同样重要的是：<strong>边界应该画在哪里</strong>。下面整理几种我在实际工作中经常遇到的边界判断问题。</p>
<p>前端处理的数据大致来自四种来源。</p>
<ul>
<li><strong>服务端数据</strong>：通过 API 响应获得的数据</li>
<li><strong>派生数据</strong>：由服务端数据计算得到的数据</li>
<li><strong>用户界面状态</strong>：用于控制页面的状态，以及用户交互</li>
<li><strong>用户输入</strong>：正在表单中输入的数据</li>
</ul>
<p>如果把这四类数据混进同一个类型，领域模型就会受到污染。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 안티패턴: 모든 것이 섞인 타입</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 서버 데이터 (도메인)</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  determinedTax</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 파생 데이터 (도메인)</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  refundAmount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  canAmend</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // UI 상태 (표현)</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  isExpanded</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  activeStep</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 임시 상태</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  editingDeductions</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Deduction</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>这个类型把领域概念、用户界面状态和临时数据都塞进了同一个篮子。每次 <code>activeStep</code> 变化，就等于更新了一次申报领域。（表单步骤变化并不是业务事件。）</p>
<p>改进方法是按照边界拆分类型。<strong>领域模型</strong>只包含 <code>id</code>、<code>status</code>、<code>determinedTax</code> 等业务概念；<strong>用户界面状态</strong>（<code>FilingFormViewState</code>）只包含 <code>isExpanded</code>、<code>activeStep</code> 等页面控制信息；<strong>表单状态</strong>（<code>DeductionEditForm</code>）只保存正在输入的临时数据。</p>
<p>这样一来，每种类型都只有<strong>一个变化原因</strong>。领域类型只在税法变化时修改，用户界面状态只在页面设计变化时修改，表单状态只在输入体验变化时修改。</p>
<h3 id="把共同变化的内容放在一起"><a class="anchor" href="#把共同变化的内容放在一起">把共同变化的内容放在一起</a></h3>
<p>Eric Evans 的 DDD 中有一个<strong>聚合（Aggregate</strong>）概念，指的是“把一组相关对象作为一个单元来处理”。前端无需照搬这一概念，但其中的核心原则值得借鉴：<strong>把共同变化的数据和规则放在一起。</strong></p>
<p>以税务服务为例，<code>Income</code>（收入）和 <code>ExpenseRate</code>（费用率）总是共同变化。收入类型变化时，适用的费用率也会变化，综合所得金额的计算也会受到影响。因此，应把这些内容聚合到同一个文件 <code>domain/tax.ts</code> 中。</p>
<p>相反，<code>TaxFiling</code>（申报单）可以独立于税额计算而变化。即使申报单的状态转换规则发生变化，税率计算逻辑也不会受到影响。因此，把它分离到 <code>domain/filing.ts</code> 才是合适的做法。</p>
<pre><code>이렇게 묻자: "A가 변할 때 B도 반드시 변해야 하는가?"
  → Yes: 같은 모듈에 둔다 (Income + ExpenseRate + TaxBracket)
  → No: 분리한다 (Tax 계산 ↔ Filing 상태관리)
</code></pre>
<h2 id="类与函数式风格"><a class="anchor" href="#类与函数式风格">类与函数式风格</a></h2>
<p>读到这里，可能会产生一个根本问题：前面的示例都是 <code>interface</code> + 纯函数的组合，如果用类来表达领域，内聚不是会更自然吗？</p>
<p>确实如此。使用类来表达领域，会把数据与行为封装在同一个对象中，因此内聚性能够直接体现在代码结构里。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  constructor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> taxYear</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> filingType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> determinedTax</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  ) {}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.filingType </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  canSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.determinedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">  "F-001"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">  "completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  2025</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">  "regular"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  547200</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">filing.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p>使用类时，行为归属于数据，而且调用处的主语非常明确。<code>filing.canAmend()</code> 读起来像自然语言一样直观，主语（filing）和动词（canAmend）清楚地结合在一起。就像写下 <code>jihoon.eat('감자탕')</code>，马上就能读出“Jihoon 吃脊骨土豆汤”。</p>
<p>而函数式风格是这样的。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filing);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">eat</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"jihoon"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"감자탕"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>在函数式风格中，数据存在于函数之外。上面第一段代码接收 <code>filing</code> 数据作为参数并执行某种行为；<code>eat</code> 函数则接收 <code>jihoon</code> 和 <code>감자탕</code> 两项数据作为参数来执行行为。</p>
<p>这样一来，主语和动词的结合会变得松散。只有打开文件或查看类型签名，才能知道 <code>canAmend</code> 函数与 <code>TaxFiling</code> 有关。如果同一文件中混杂着 <code>canAmend(filing)</code>、<code>canEdit(filing)</code>、<code>calculateTax(taxableBase)</code> 等函数，就可能很难一眼看出每个函数分别属于哪个领域。</p>
<h3 id="那么应该使用类吗"><a class="anchor" href="#那么应该使用类吗">那么，应该使用类吗？</a></h3>
<p>坦率地说，答案是“<strong>视情况而定</strong>”。但根据我的经验，在 React + TypeScript 环境中，类并非万能，这背后有一些现实原因。</p>
<h4 id="与-react-状态管理之间的摩擦"><a class="anchor" href="#与-react-状态管理之间的摩擦">与 React 状态管理之间的摩擦</a></h4>
<p>React 的状态管理与<strong>普通对象（Plain Object</strong>）配合得最自然。<code>useState</code> 和 <code>useReducer</code> 在技术上可以保存任何值，Redux DevTools 本身也不会移除类实例的原型。但如果 Redux/Zustand 持久化中间件以 JSON 保存并恢复状态，类实例就会在 <code>JSON.stringify</code> → <code>JSON.parse</code> 循环中丢失方法和原型，退化为普通对象。从 React Server Component 向 Client Component 传递 props 的边界则有另一种限制：它只接受受支持的可序列化（serializable）值，因此任意类实例从一开始就无法通过该边界。</p>
<p>来看下面的代码。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">filing</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"F-001"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2025</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"regular"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>仅仅更新 React 状态并不会让 <code>filing</code> 失去 <code>TaxFilingModel</code> 实例的身份。但如果 Redux/Zustand 持久化以 JSON 保存并恢复它，恢复后的值可能变成没有方法的普通对象，此时无意间调用 <code>filing.canAmend()</code> 就可能触发运行时错误。从 React Server Component 传递到 Client Component 时，失败会更早发生，因为类实例不是受支持的可序列化 props 值。</p>
<h4 id="难以保证不可变性"><a class="anchor" href="#难以保证不可变性">难以保证不可变性</a></h4>
<p>React 基于<strong>引用相等性（referential equality</strong>）检测状态变化。如果类实例的方法执行 <code>this.items.push(...)</code> 之类的内部修改，引用保持不变，React 就不会触发重新渲染。因此，最终只能让 <code>addDeduction(item)</code> 像 <code>return new DeductionList([...this.items, item])</code> 一样，每次都返回一个新实例。这样一来，类的优势——“封装后的状态变更”——也就失去了意义，代码与函数式更新并没有太大区别。</p>
<h3 id="在函数式风格中获得内聚的策略"><a class="anchor" href="#在函数式风格中获得内聚的策略">在函数式风格中获得内聚的策略</a></h3>
<p>那么，在函数式风格中，如何改善 <code>eat('jihoon', '감자탕')</code> 这种内聚松散的问题？下面介绍三种我认为有效的方法。</p>
<h4 id="通过模块命名空间实现内聚"><a class="anchor" href="#通过模块命名空间实现内聚">通过模块命名空间实现内聚</a></h4>
<p>这是最直观的方法。把文件（模块）本身按领域组织，并在导入时使用命名空间。直接使用前面定义的 <code>domain/filing.ts</code> 即可。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> *</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> as</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> FilingModel </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "../domain/filing"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">FilingModel.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filing);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">FilingModel.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filing);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">FilingModel.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filing);</span></span></code></pre></figure>
<p><code>FilingModel.canAmend(filing)</code> 虽然不如 <code>filing.canAmend()</code> 简洁，但至少能直接从代码中看出这个函数属于申报领域，也不会再出现函数跨多个领域混杂的风险。</p>
<h4 id="第一个参数是领域主体"><a class="anchor" href="#第一个参数是领域主体">第一个参数是领域主体</a></h4>
<p>函数式风格还有另一种表达内聚的约定：<strong>始终把第一个参数设为“行为主体</strong>”。将签名统一为 <code>canAmend(filing)</code>、<code>calculateTotalIncome(income)</code> 这样的形式后，<code>canAmend(filing)</code> 就可以理解为“询问 filing 是否可以修改”。这也与 Unix 的流水线思维（<code>data |> transform</code>）一脉相承。事实上，Go 语言的方法接收者正是这种模式，Rust 的 <code>impl</code> 块把 <code>self</code> 作为第一个参数也是同样的思路。</p>
<h4 id="用-factory-函数聚合行为"><a class="anchor" href="#用-factory-函数聚合行为">用 Factory 函数聚合行为</a></h4>
<p>想念类的内聚性时，可以使用这种模式。工厂函数一次性返回领域对象及其行为。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    ...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">data,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    canSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.determinedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      data.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.filingType </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(rawFiling);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">filing.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">filing.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p>这种模式能够同时获得类的表现力（<code>filing.canAmend()</code>）和通过对象字面量组合行为的实用性。不过，返回的对象包含函数属性，因此它本身并不是可 JSON 序列化的数据。它每次还会创建新的函数对象，但对于前端处理的数据规模来说，几乎不会构成性能问题。</p>
<h2 id="应该分离到什么程度"><a class="anchor" href="#应该分离到什么程度">应该分离到什么程度？</a></h2>
<p>阅读 Clean Architecture 时，会看到一种理想结构：划分 3～4 个层，并定义端口与适配器。但在现实中，把这套结构应用于所有项目，可能会造成过度设计（over-engineering）。</p>
<p>我认为实用的判断标准如下。</p>
<ul>
<li><strong>将领域类型与 API 响应类型分离</strong>。无论使用 <code>interface</code> 还是 <code>type</code>，都应在单独的文件中定义前端使用的领域概念。</li>
<li><strong>把包含业务规则的逻辑移出组件</strong>。不放在 <code>domain/</code> 文件夹也没关系，重要的是把它写成不依赖 React 的纯函数。</li>
<li><strong>集中完成 API 响应 → 领域模型的转换</strong>。无论使用转换器函数还是 Zod schema，都要建立一种结构：只需修改这一处，变化就不会继续传播。</li>
</ul>
<p>如果项目变得更加复杂，还可以进一步考虑以下做法。</p>
<ul>
<li><strong>按限界上下文划分文件夹。</strong><a href="https://frontend-fundamentals.com/" target="_blank" rel="noopener noreferrer">Toss 前端团队</a>也强调“把共同变化的文件放在同一目录”这一原则。按领域划分文件夹后，导入路径会自然地显露领域边界。</li>
<li><strong>引入用例层</strong>。当领域逻辑的组合变得复杂时，就需要一个应用层，将“查询收入信息 → 应用费用率 → 计算扣除项目 → 计算税额 → 确定退税额”这一场景封装成一个函数。</li>
</ul>
<pre><code>src/
├── domains/
│   ├── tax/
│   │   ├── tax.ts                  # 세액 계산 도메인 (세율, 공제, 계산 파이프라인)
│   │   ├── tax.viewModel.ts        # 세액 표현 (금액 포맷, 구간 라벨)
│   │   ├── tax.test.ts             # 세액 계산 테스트
│   │   └── incomeMapper.ts         # 홈택스 API ↔ 도메인 변환
│   ├── filing/
│   │   ├── filing.ts               # 신고 상태 도메인 (상태 전이, 권한)
│   │   ├── filing.viewModel.ts     # 신고 표현 (상태 배지, 라벨)
│   │   ├── filing.test.ts
│   │   └── filingMapper.ts
│   └── deduction/
│       ├── deduction.ts            # 공제 항목 도메인 (자격 조건, 한도)
│       └── deduction.viewModel.ts
├── hooks/                           # React 의존 로직
├── components/                      # UI 컴포넌트
└── api/                             # API 호출
</code></pre>
<p>即使同属税务这一领域，<strong>税额计算（tax</strong>）、<strong>申报管理（filing</strong>）、<strong>扣除项目（deduction</strong>）也会被分成彼此独立的子领域。即使税率发生变化，申报状态转换逻辑也不会受到影响；即使新增扣除项目，申报单的提交流程也保持不变。这就是限界上下文在实践中的应用。</p>
<h2 id="结语"><a class="anchor" href="#结语">结语</a></h2>
<p>总而言之，前端也有前端自己的领域逻辑，这些规则不应放在组件里，而应放在类型旁边的纯函数中。API 响应在一个 mapper 中转换为领域类型，界面表现拆分到 ViewModel，一起变化的东西放在一起。</p>
<p>在前端实践这一点，并不只是划分文件夹，更是要<strong>有意识地判断多层边界</strong>。“这是业务规则，还是表现层逻辑？”“这些数据属于领域状态，还是用户界面状态？”“这个函数的内聚性足够吗？”只要养成反复提出这些问题的习惯，代码结构自然会逐渐改善。</p>
<p>当然，并非所有项目都需要完整搭建 Clean Architecture 的各个层。为简单的增删改查应用划分四个层，并给每个领域都应用工厂模式，未免本末倒置。在类的优雅内聚与函数式风格的实用灵活之间，答案取决于项目复杂度和团队语境。</p>
<p>没有唯一正确答案。但至少，“<strong>不知道领域是什么就开始写代码</strong>”与“<strong>识别领域、判断边界并有意识地分离代码</strong>”之间有着明确差异。希望读者也能在自己的项目中问一次：“这里的领域是什么？这段代码又应该放在哪里？”</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215" target="_blank" rel="noopener noreferrer">Eric Evans，《领域驱动设计》（书籍）</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html" target="_blank" rel="noopener noreferrer">Robert C. Martin，Clean Architecture</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://khalilstemmler.com/articles/typescript-domain-driven-design/ddd-frontend/" target="_blank" rel="noopener noreferrer">Khalil Stemmler，DDD 属于前端吗？</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://bespoyasov.me/blog/clean-architecture-on-frontend/" target="_blank" rel="noopener noreferrer">Alex Bespoyasov，前端 Clean Architecture</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://toss.tech/article/income-qa-e2e-automation" target="_blank" rel="noopener noreferrer">Toss，端到端自动化之旅</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>前端</category>
            <category>架构</category>
            <category>DDD</category>
        </item>
        <item>
            <title><![CDATA[领域术语]]></title>
            <link>https://hooninedev.com/zh-CN/260413</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/260413</guid>
            <pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊聊领域、领域模型、领域对象与领域对象模型之间到底有什么区别。 本文写给在阅读 DDD 文章时，曾经分不清这些词是否指同一件事的前端开发者。读完之后，你可以把这四个术语放在一条从抽象到具体的线上，并说明为什么 API 响应类型不是领域模型。 笔者在开发中也经常接触这些词，但真被问到“领域到底是什么？”时，却很难给出清晰的回答。（说实话，刚开始写代码时，我以为领域指的是 www 那种域名。...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊<strong>领域、领域模型、领域对象与领域对象模型之间到底有什么区别</strong>。</p>
<p>本文写给在阅读 DDD 文章时，曾经分不清这些词是否指同一件事的前端开发者。读完之后，你可以把这四个术语放在一条从抽象到具体的线上，并说明为什么 API 响应类型不是领域模型。</p>
<p>笔者在开发中也经常接触这些词，但真被问到“领域到底是什么？”时，却很难给出清晰的回答。（说实话，刚开始写代码时，我以为领域指的是 www 那种域名。）文中的例子全部使用综合所得税的计算。</p>
<h2 id="领域domain"><a class="anchor" href="#领域domain">领域（Domain）</a></h2>
<p>先从最基础的问题开始。什么是<strong>领域</strong>？</p>
<p>Eric Evans 在其著作 <strong>Domain-Driven Design: Tackling Complexity in the Heart of Software（2003）</strong> 中对领域作出了如下定义。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>知识、影响力或活动的范围。</p></div><div class="quote-original" lang="en"><p>"A sphere of knowledge, influence, or activity."</p></div></blockquote>
<p>简单来说，领域就是<strong>希望通过编程解决的问题范围</strong>本身。如果要开发报税服务，“报税”就是领域；如果要开发保险理赔平台，“保险理赔”就是领域。领域不是代码，而是在软件出现之前就已存在的现实世界问题范围。</p>
<p>这对前端开发者意味着什么？我们构建的用户界面，归根结底是一个让用户看见并操作领域的<strong>窗口（window</strong>）。如果开发 Toss Income、3o3 这类以税务领域为核心的退税服务，就要通过用户界面呈现收入类型、费用率、所得扣除、税额抵免、退税额等领域概念。因此，前端开发者也必须深入理解自己处理的领域。换句话说，了解“<strong>这项服务要解决什么问题</strong>”，与出色地实现用户界面组件同样重要。</p>
<p>然而，即便只有“税务”这一个领域，深入其中也会发现大量子领域。仅以我略知皮毛的综合所得税计算流水线为例，就已经是这样。</p>
<p><picture><source type="image/webp" srcset="/content/260413/1.720w.webp 720w, /content/260413/1.1090w.webp 1090w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260413/1.png" alt="综合所得税计算流水线：从总收入到缴纳或退税的每个步骤，按收入、扣除、税额、结果四个子域着色" width="1090" height="566" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>这条流水线的每一个阶段，都是拥有独立规则和数据的子领域。“税务”这一大领域内部，交织着收入（Income）、扣除（Deduction）、税额（Tax）、申报结果（Filing）等细分领域。如何在代码中划分它们，正是领域建模的核心问题。</p>
<h2 id="领域模型domain-model"><a class="anchor" href="#领域模型domain-model">领域模型（Domain Model）</a></h2>
<p>那么，什么是领域模型？领域和“领域模型”有什么区别？</p>
<p><a href="https://martinfowler.com/eaaCatalog/domainModel.html" target="_blank" rel="noopener noreferrer">Martin Fowler</a> 把领域模型定义为同时包含行为和数据的领域对象模型。Eric Evans 的定义则更进一步。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>一种描述领域中选定方面的抽象体系，可用于解决与该领域相关的问题。—— Eric Evans</p></div><div class="quote-original" lang="en"><p>A system of abstractions that describes selected aspects of a domain and can be used to solve problems related to that domain.</p></div></blockquote>
<p>关键在于“<strong>选择性抽象</strong>”。领域模型不会囊括现实世界的一切。就像电影导演不会拍下现实中的所有场景，而只选择叙事所需的场景一样，领域模型也是<strong>选取解决问题所需的方面并加以结构化</strong>的结果。</p>
<p>这里有一点很重要：领域模型不一定非得是代码。它可以是白板上的图，也可以是团队成员头脑中共享的心智模型（Mental Model）。归根结底，“领域模型”这个术语本身可以是一个独立于软件的概念。</p>
<p>这里还有一个前端开发者特别容易混淆的地方：看到 API 响应结构，就认为“这就是领域模型”。但它其实是<strong>数据模型（Data Model）</strong>，而不是领域模型。</p>
<p>数据模型与领域模型的区别如下。</p>
<table>
<thead>
<tr>
<th>区分项</th>
<th>领域模型</th>
<th>数据模型</th>
</tr>
</thead>
<tbody>
<tr>
<td>目的</td>
<td>表达业务概念与规则</td>
<td>定义存储/传输结构</td>
</tr>
<tr>
<td>语言</td>
<td>业务术语（计税依据、税额抵免、退税额）</td>
<td>技术术语（string、number、array）</td>
</tr>
<tr>
<td>包含要素</td>
<td>数据 + 行为（规则）</td>
<td>仅数据结构</td>
</tr>
<tr>
<td>示例</td>
<td>“计税依据不超过 1,400 万韩元的区间税率为 6%”</td>
<td><code>{ taxableBase: number, taxRate: number }</code></td>
</tr>
</tbody>
</table>
<p>数据模型定义“数据以什么形式流转”，而<strong>领域模型定义“这些数据在业务上意味着什么，又遵循哪些规则</strong>”。如果无法区分两者，组件就会直接依赖 API 响应结构，每当后端 schema 发生变化，整个前端都会受到牵连。</p>
<h2 id="领域对象domain-object"><a class="anchor" href="#领域对象domain-object">领域对象（Domain Object）</a></h2>
<p>如果说领域模型是一套概念体系，那么<strong>领域对象</strong>就是这些概念在代码中的具体实现。</p>
<p>经营 Code with Jason 的 <a href="https://www.codewithjason.com/difference-domains-domain-models-object-models-domain-objects/" target="_blank" rel="noopener noreferrer">Jason Swett 在文章中</a>这样定义领域对象。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>在我的对象模型中，凡是在领域模型里也作为一个概念存在的对象，我都会称之为领域对象。</p></div><div class="quote-original" lang="en"><p>Any object in my object model that also exist as a concept in my domain model I would call a domain object.</p></div></blockquote>
<p>也就是说，如果领域模型中存在“综合所得”这一概念，代码中又有名为 <code>Income</code> 的类型，那么这个 <code>Income</code> 就是领域对象。但并非所有代码对象都是领域对象。<code>HttpClient</code>、<code>LocalStorageAdapter</code>、<code>useDebounce</code> 等只是技术工具，并不是领域概念。</p>
<h3 id="实体与值对象"><a class="anchor" href="#实体与值对象">实体与值对象</a></h3>
<p>Evans 将领域对象分为<strong>实体（Entity</strong>）、<strong>值对象（Value Object</strong>）、<strong>服务（Service</strong>）三类。（Martin Fowler 将这种分类称为“Evans Classification”。）服务是一个独立概念，用来表达“无法自然归属于某个特定对象的领域操作”。不过，本节关注的核心是如何识别数据，因此将重点讨论实体和值对象。</p>
<p><strong>实体（Entity</strong>）是具有唯一身份、能够贯穿时间与多种表现形式的对象。报税申报单（TaxFiling）、纳税人（Taxpayer）、收入记录（IncomeRecord）等都通过唯一标识符识别；即使属性发生变化，只要标识符相同，就仍是同一个实体。即使修改了申报单的扣除项目，只要申报单标识符没有变化，它就仍是同一份申报单。</p>
<p><strong>值对象（Value Object</strong>）是仅由属性组合赋予意义的对象，所有属性值都相同时，就视为同一个对象。金额（Money）、税率（TaxRate）、税级（TaxBracket）等都属于其数值本身即有意义的对象。“6% 的税率”无论用在哪里，都只是“6% 的税率”。</p>
<p>为什么这种区分在前端很重要？来看下面的代码示例。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  taxpayerName</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  taxYear</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isSameFiling</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> a.id </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.id;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Money</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  currency</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "KRW"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "USD"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isSameMoney</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Money</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Money</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  a.amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> a.currency </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.currency;</span></span></code></pre></figure>
<p>TaxFiling 以 id 作为身份判断标准，因此是实体。（拥有 id 字段本身并不是实体的定义，关键在于“用这个 id 判断对象是否相同”。）Money 没有 id，仅通过 amount 与 currency 的组合来识别；所有属性相同时，就视为同一个值。</p>
<p>实体基于标识符比较，值对象基于属性比较。明确这种区分后，状态管理中判断“这份数据是否相同”的逻辑就会自然地得到梳理。比如更新列表项时，如果是实体，就通过标识符找到并替换；如果是值对象，则执行不可变替换（immutable replace）。</p>
<h2 id="领域对象模型domain-object-model"><a class="anchor" href="#领域对象模型domain-object-model">领域对象模型（Domain Object Model）</a></h2>
<p>已经知道了“领域模型”和“领域对象”，那么<strong>领域对象模型</strong>又是什么？</p>
<p>查阅资料后发现，这个概念出人意料地没有公认定义。许多文献把“领域模型”“领域对象模型”“概念模型（conceptual model）”“分析对象模型（analysis object model）”视为<strong>实质上的同义词</strong>，认为它们只是对面向对象分析阶段所绘制概念模型的不同称呼。</p>
<p>但也有观点认为，它们属于划分得更细的不同层次。其中有一种典型解释：<strong>领域模型转化为实际代码的地方，正是对象模型</strong>。</p>
<p>按照第二种观点，<strong>对象模型</strong>是系统中<strong>所有代码对象的结构</strong>，也包含 <code>HttpClient</code>、<code>useDebounce</code> 等技术工具。其中，<strong>用于表达领域概念的对象子集及其相互关系</strong>，就是<strong>领域对象模型</strong>。这也与面向对象建模的传统一脉相承——在这一传统中，“对象模型”被定义为系统的静态结构，包括类、属性、操作与关系。</p>
<p>我认为，这种观点对前端开发者更实用，因为我们实际编写的代码总是混合着领域对象和技术对象。</p>
<p>归根结底，<strong>领域 → 领域模型 → 领域对象模型 → 领域对象</strong>是一组从抽象走向具体的层次关系。领域最宽泛，领域对象最具体。因此，编写前端代码时，我们真正要思考的终究是：<strong>如何组织领域对象模型，也就是表达领域概念的类型及其相互关系</strong>。</p>
<h2 id="结语"><a class="anchor" href="#结语">结语</a></h2>
<p>总而言之，<strong>领域</strong>是我们要解决的问题范围；<strong>领域模型</strong>是对这一问题进行选择性抽象后形成的概念体系；<strong>领域对象模型</strong>在笔者采用的视角下，是用代码实现这套概念体系的结果；<strong>领域对象</strong>则是实现中的各个具体对象。</p>
<p>这种区分在代码中会带来什么不同，也就是像税额计算这样的领域逻辑应该放在组件之外的哪里、要拆分到什么程度，会在<a href="/260418" target="_blank" rel="noopener noreferrer">领域模型</a>中继续讨论。</p>
<p>希望读到这里的各位，下次看到 API 响应类型时，也能问自己一次：“这是数据模型，还是领域模型？”</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215" target="_blank" rel="noopener noreferrer">Eric Evans，《领域驱动设计》（书籍）</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>前端</category>
            <category>架构</category>
            <category>DDD</category>
        </item>
        <item>
            <title><![CDATA[Toss Frontend Fundamentals 模拟考试第 2 期重构复盘]]></title>
            <link>https://hooninedev.com/zh-CN/260328</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/260328</guid>
            <pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊聊我参加 Toss Frontend Fundamentals 模拟考试第 2 期时所经历的重构过程。 我平时就对代码审查和重构很感兴趣，因此参与了 Toss 发布的一项形式颇为有趣的 Frontend Fundamentals 模拟考试。题目提供了一个会议室预订应用，要求对它进行重构。项目还附带了测试代码，为验证重构过程中功能是否遭到破坏提供了安全网。 最终，我用了两天时间完成重构，...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊我参加 Toss Frontend Fundamentals 模拟考试第 2 期时所经历的重构过程。</p>
<p>我平时就对代码审查和重构很感兴趣，因此参与了 Toss 发布的一项形式颇为有趣的 Frontend Fundamentals 模拟考试。题目提供了一个会议室预订应用，要求对它进行重构。项目还附带了测试代码，为验证重构过程中功能是否遭到破坏提供了安全网。</p>
<p>最终，我用了两天时间完成重构，也想借此整理一下过程中的感受。</p>
<h2 id="初次面对代码时"><a class="anchor" href="#初次面对代码时">初次面对代码时</a></h2>
<p>第一次打开代码时，我做的第一件事就是<strong>阅读测试规格</strong>。因为测试代码是最如实说明这个应用应该做什么的文档。我浏览了 <code>App.easy.spec.tsx</code> 和 <code>App.hard.spec.tsx</code>，了解了应用的整体需求。</p>
<p>接着查看实际代码时，最先映入眼帘的是两个单体组件。</p>
<ul>
<li><code>ReservationStatusPage</code> 是一个 385 行的组件，日期选择、时间线可视化、预订详情工具提示、我的预订列表和取消功能全都塞在同一个文件里。</li>
<li><code>RoomBookingPage</code> 是一个 300 多行的组件，筛选器、房间列表、创建预订逻辑和 URL 参数同步交织在一起。</li>
</ul>
<p>阅读代码时，比起先下结论说“需要改进”，我更专注于<strong>对代码的特性进行分类</strong>。也就是区分哪些代码承载领域信息，哪些具有工具性质，哪些属于纯粹的 UI 层。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 도메인 정보: 장비 라벨, 타임 슬롯 등 비즈니스 상수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> EQUIPMENT_LABELS</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Record</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  tv: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'TV'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, whiteboard: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'화이트보드'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, video: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'화상장비'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, speaker: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'스피커'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 유틸리티: 날짜 포맷, 시간 변환</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatDate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> timeToMinutes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">time</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 서버 상태: 인라인 useQuery, useMutation 호출</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">rooms</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [] } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'rooms'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], getRooms);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">reservations</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [] } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'reservations'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, date], () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getReservations</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(date));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// UI + 비즈니스 로직 혼재: 필터링, 정렬, 충돌 감지가 JSX 사이에 산재</span></span></code></pre></figure>
<p>完成这样的特性分类后，该从哪里着手也就自然清晰起来。我在各个代码区域旁加上简短注释，记录改进方向。（感觉就像刚进现在这家公司时迁移基于 jquery 的项目一样）</p>
<p>那么，究竟应该从哪里开始呢？</p>
<h2 id="制定重构策略"><a class="anchor" href="#制定重构策略">制定重构策略</a></h2>
<p>我计划按照以下顺序进行重构。</p>
<ol>
<li><strong>处理服务器代码</strong>：拆分 query、mutation</li>
<li><strong>拆分领域逻辑</strong>：Equipment、Room、Reservation 模型</li>
<li><strong>声明类型</strong>：梳理基于领域模型的类型体系</li>
<li><strong>拆分工具函数</strong>：日期格式化、时间线计算等</li>
<li><strong>拆分 UI 层</strong>：按关注点将组件划分为节奏合适的单元</li>
<li><strong>抽象与关注点分离</strong>：错误/加载处理、查询键管理</li>
</ol>
<p>之所以选择这个顺序，是为了沿着<strong>依赖方向从外到内</strong>推进。先整理基础设施（服务器代码、工具），确立领域模型，最后再打磨 UI。如果先拆分 UI 组件，尚未整理好的领域逻辑和查询代码可能就得在多个组件之间反复搬动。</p>
<p>策略已经定好，接下来逐项付诸实践。</p>
<h2 id="先整理服务器代码和工具"><a class="anchor" href="#先整理服务器代码和工具">先整理服务器代码和工具</a></h2>
<h3 id="拆分日期显示工具"><a class="anchor" href="#拆分日期显示工具">拆分日期显示工具</a></h3>
<p>我最先处理的是 <code>formatDate</code> 函数，因为两个页面里分别内联定义了同一个函数。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// utils/formatYYYYMMDD.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatYYYYMMDD</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> year</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getFullYear</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> month</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> String</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getMonth</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">padStart</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'0'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> String</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getDate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">padStart</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'0'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">year</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}-${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">month</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}-${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">date</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>改动虽小，但作为重构的第一个提交却有着重要意义。先从最独立、副作用最少的部分入手，再确认测试是否依然通过，就像一次<strong>热身</strong>。</p>
<h3 id="拆分-react-query-hook"><a class="anchor" href="#拆分-react-query-hook">拆分 React Query hook</a></h3>
<p>接下来，我把直接写在组件内部的 <code>useQuery</code>、<code>useMutation</code> 调用拆到了单独的文件中，并利用 <code>queryOptions</code> 模式将查询配置整理为可复用单元。</p>
<p>在这个过程中，也显式定义了 <code>remotes.ts</code> 中的 API 响应类型。原先以 <code>any</code> 流转的类型，变得明确为 <code>GetRoomsResponse</code>、<code>GetReservationsResponse</code> 等。</p>
<p>基础设施层已经整理完毕，接下来把目光转向领域模型。</p>
<h2 id="拆分领域模型"><a class="anchor" href="#拆分领域模型">拆分领域模型</a></h2>
<p>这次重构最重要的转折点，是<strong>将领域模型拆分到独立的 <code>models/</code> 目录中</strong>。</p>
<p>原有代码把 <code>EQUIPMENT_LABELS</code>、<code>TIME_SLOTS</code> 等业务常量声明在组件文件顶部。<code>Room</code> 或 <code>Reservation</code> 的类型也只存在于服务器处理器（<code>_tosslib/server/types.ts</code>）中，在客户端代码中使用时几乎等同于 <code>any</code>。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// models/equipment.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> EQUIPMENT_LABELS</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  tv: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'TV'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, whiteboard: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'화이트보드'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, video: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'화상장비'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, speaker: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'스피커'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> keyof</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> typeof</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> EQUIPMENT_LABELS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> ALL_EQUIPMENT</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Object.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">keys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">EQUIPMENT_LABELS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span></code></pre></figure>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// models/reservation.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  floor</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  capacity</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  equipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Reservation</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  roomId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  start</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  end</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  attendees</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  equipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>为什么拆分领域模型如此重要？如果业务逻辑依附于 UI 组件，修改这段逻辑时，就不得不连组件的渲染逻辑一起查看。反过来，如果它独立存在于 <code>models/</code> 目录，业务规则的变更就可以与 UI 分离。当然，现实中很难做到完美分离，但关键至少是构建一种<strong>能让人预测“这段逻辑应该在这里”的结构</strong>。</p>
<p>既然领域模型已经拆分，UI 又能变得多轻量呢？</p>
<h2 id="拆解组件"><a class="anchor" href="#拆解组件">拆解组件</a></h2>
<h3 id="reservationstatuspage"><a class="anchor" href="#reservationstatuspage">ReservationStatusPage</a></h3>
<p>这是带来最显著变化、同时也最耗时的一次提交。我将 385 行的单体组件拆分成了以下结构。</p>
<pre><code>ReservationStatusPage/
├── index.tsx                    # 페이지 레벨
└── components/
    ├── DateSelector.tsx         # 날짜 선택 UI
    ├── ReservationTimeline.tsx  # 타임라인
    └── MyReservation.tsx        # 내 예약 목록 + 취소
</code></pre>
<p>拆分标准是“<strong>这段代码能否独立表达意义</strong>”。时间线可视化接收某个日期的预订数据并绘制网格，是一个独立的关注点。我的预订列表负责查询和取消用户的预订数据，同样是一个独立的关注点。它们没有理由待在同一个文件里。</p>
<p>拆分后，<code>index.tsx</code> 只承担<strong>协调者（orchestrator</strong>）的角色。它仅负责状态管理、消息展示和组合子组件，而将实际的数据获取和渲染细节交给子组件。</p>
<h3 id="roombookingpage"><a class="anchor" href="#roombookingpage">RoomBookingPage</a></h3>
<p>预订页面也按照同样的原则进行了拆分。</p>
<pre><code>RoomBookingPage/
├── index.tsx                    # 페이지 레벨
├── components/
│   ├── BookingFilter.tsx        # 날짜, 시간, 인원, 장비, 층 UI
│   └── AvailableRoomList.tsx    # 예약 가능 방 목록
└── hooks/
    └── useBookingParams.ts      # URL searchParams 기반 상태 관리
</code></pre>
<p>这个过程中有一个有趣的取舍。起初，我尝试引入 <code>react-hook-form</code> + <code>zod</code> 来进行表单校验。但最终还是将它移除，换成了自定义 hook <code>useBookingParams</code>。关于这个决定，后面还会更详细地谈到。</p>
<p>读到这里，自然会浮现出一个问题：究竟应该抽象到什么程度？</p>
<h2 id="抽象的适当边界"><a class="anchor" href="#抽象的适当边界">抽象的适当边界</a></h2>
<p>这一节是我在本次模拟考试中思考最多的部分。</p>
<h3 id="嵌套条件语句应该拆到什么程度"><a class="anchor" href="#嵌套条件语句应该拆到什么程度">嵌套条件语句，应该拆到什么程度</a></h3>
<p>判断房间能否预订的逻辑结合了多个条件：容纳人数是否足够、是否具备所需设备、是否符合偏好的楼层，以及时间是否冲突。在原始代码中，所有条件都内联写在一个 <code>filter</code> 回调里。</p>
<p>我将其提取到 <code>models/roomFilter.ts</code> 时，把每个条件拆成了<strong>有名字的函数</strong>。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isEnoughCapacity</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">attendees</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> room.capacity </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> attendees;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> hasRequiredEquipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">equipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  equipment.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">every</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">eq</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> room.equipment.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">includes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(eq));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isOnPreferredFloor</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">floor</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  floor </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> room.floor </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> floor;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> hasNoTimeConflict</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reservations</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Reservation</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[], </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">start</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">end</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  !</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">reservations.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">some</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reservation</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> reservation.roomId </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> room.id </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> reservation.date </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> date </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> reservation.start </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> end </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> reservation.end </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> start);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> filterAvailableRooms</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">rooms</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[], </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reservations</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Reservation</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[], </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">params</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Params</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[] {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> rooms</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      isEnoughCapacity</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(room, params.attendees) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      hasRequiredEquipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(room, params.equipment) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      isOnPreferredFloor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(room, params.floor) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      hasNoTimeConflict</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(room, reservations, params.date, params.startTime, params.endTime)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">sort</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (a.floor </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.floor) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> a.floor </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.floor;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> a.name.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">localeCompare</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(b.name);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>这里的关键在于，<strong>只有在能够明确命名抽象时才拆分成函数</strong>。像 <code>isEnoughCapacity</code>、<code>hasRequiredEquipment</code> 这样的名字，无需查看实现也能预测它们的作用。如果名字只能像 <code>processRoomConditions</code> 一样模糊，这种抽象反而会增加读者的认知负担。</p>
<p>当然，这并不意味着它就是标准答案。我的判断标准只是：“<strong>仅凭函数名能否预测其行为</strong>？”如果可以预测，就进行抽象；如果不能，保留内联反而可能更有利于可读性。</p>
<h3 id="searchparams-与-form-state"><a class="anchor" href="#searchparams-与-form-state">searchParams 与 form state</a></h3>
<p>预订筛选状态应该放在哪里，也是让我颇费思量的问题。原始代码使用 <code>useState</code> 管理各项筛选值，再通过 <code>useEffect</code> 与 URL searchParams 同步。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 원본: useState + useEffect 동기화 방식</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setDate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(searchParams.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'date'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatDate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">startTime</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setStartTime</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(searchParams.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'startTime'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ... 6개의 개별 상태</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> params</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Record</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {};</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (date) params.date </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> date;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ... 모든 상태를 searchParams에 동기화</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  setSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(params, { replace: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [date, startTime, endTime, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]);</span></span></code></pre></figure>
<p>我一开始引入了 <code>react-hook-form</code> + <code>zod</code>，尝试以表单的方式管理。但最终还是将其移除，改用把 <strong>searchParams 作为单一事实来源（Single Source of Truth</strong>）的 <code>useBookingParams</code> hook。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// useBookingParams: searchParams가 곧 상태</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useBookingParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">searchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> params</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useMemo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">BookingParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    date: searchParams.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'date'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatYYYYMMDD</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    startTime: searchParams.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'startTime'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }), [searchParams]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> updateParam</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">K</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> extends</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> keyof</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> BookingParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">key</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> K</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> BookingParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">K</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    setSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">prev</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">      // 기존 파라미터 병합 후 업데이트</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> result;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }, { replace: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, [setSearchParams]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { params, updateParam };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>这一决定的核心依据，是我认为“<strong>让状态各自独立变动并不合适</strong>”。如果 <code>useState</code> 和 <code>searchParams</code> 各自持有状态，就可能因同步时机不同而产生不一致。反之，如果只用 searchParams 作为状态，URL 就等同于应用状态，同步问题本身也就消失了。用户分享 URL 时能够复现相同的筛选状态，则算是额外的好处。</p>
<p>在其他参与者的复盘中，我也发现了类似的思考。<strong>“将 URL searchParams 统一为单一事实来源”“选择把各个筛选器 prop 合并为一个 <code>filter</code> 对象。”<strong>表达方式虽然不同，但都意识到了同一个问题：</strong>“需要将分散的状态归拢成一个概念。”</strong></p>
<h2 id="稳定性"><a class="anchor" href="#稳定性">稳定性</a></h2>
<h3 id="suspense-与-errorboundary"><a class="anchor" href="#suspense-与-errorboundary">Suspense 与 ErrorBoundary</a></h3>
<p>组件结构确定后，我才添加了错误和加载状态处理。之所以顺序重要，是因为只有在组件树确定后，才能判断边界（Boundary）应该设置在哪里。</p>
<p>我使用 <code>react-error-boundary</code> 库，为每个独立的数据获取单元分别包裹 <code>ErrorBoundary</code> 和 <code>Suspense</code>。这样即使时间线加载失败，我的预订列表仍能正常显示，反之亦然。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 각 영역이 독립적으로 에러/로딩을 처리 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FallbackComponent</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{ErrorFallback} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">resetKeys</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{[date]}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Loading</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> message</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"예약 현황을 불러오는 중..."</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ReservationTimeline</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{date} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FallbackComponent</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{ErrorFallback}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Loading</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> message</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"내 예약을 불러오는 중..."</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">MyReservation</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onCancel</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{handleCancel} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<h3 id="集中管理-query-key"><a class="anchor" href="#集中管理-query-key">集中管理 Query Key</a></h3>
<p>重构过程中，随着查询 hook 被拆分，query key 也开始分散到多个文件里。于是，在 mutation 的 <code>onSuccess</code> 中执行 invalidation 时，很难追踪究竟应该使用哪个 key。</p>
<p>我引入 <code>@lukemorales/query-key-factory</code>，改为集中管理查询键。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// queries/queryKeys.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> roomKeys</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'rooms'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  list: { queryKey: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> remotes.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getRooms</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> reservationKeys</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'reservations'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  list</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ queryKey: [date], </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> remotes.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getReservations</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(date) }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  my: { queryKey: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> remotes.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getMyReservations</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>这样就能以 <code>useSuspenseQueries({ queries: [roomKeys.list, reservationKeys.list(date)] })</code> 的形式使用，让查询键和获取函数始终一起移动。此外，我还把 route 路径提取为 <code>PATHS</code> 常量，移除了硬编码字符串。</p>
<h2 id="出题者的意图是什么"><a class="anchor" href="#出题者的意图是什么">出题者的意图是什么</a></h2>
<p>完成重构后，我退一步重新思考：这场模拟考试究竟想考察什么？</p>
<p>阅读其他参与者的复盘时，我发现了一个有趣的共通点。几乎所有文章里都出现了这样一句话：“<strong>代码不是靠阅读，而是靠预测</strong>。”我们的大脑并非逐行解释代码，而是根据经验中积累的模式进行预测式阅读。当预测落空时，认知负担就会急剧增加。</p>
<p>从这个角度来看，模拟考试评估的不只是编码能力，而是“<strong>能把写给同事阅读的代码变得多可预测</strong>”这种协作能力。（也许读懂出题者和同事的心，才是软件工程师真正的能力）</p>
<p>查看其他参与者的复盘后，“<strong>理解别人写的代码并不容易”“优先设计接口很重要，但面对庞大的现有代码时，这种方法也可能动摇</strong>”等内容让我很有共鸣。我也有过类似的经历。当现有代码已经能够运行时，人很容易产生替它的结构寻找合理解释的诱惑，也就是“代码都已经能跑了，有必要吗？”这样的念头。但模拟考试的核心，正是越过这种诱惑，以“<strong>不是我，而是其他人阅读这段代码时，能多快理解它；能否依据自己的认知判断并逐步解决问题</strong>”为标准做出判断。</p>
<h2 id="从重构中学到的事"><a class="anchor" href="#从重构中学到的事">从重构中学到的事</a></h2>
<p><strong>重构顺序决定结果</strong>。从外部（基础设施）向内部（UI）推进，是避免中途纠缠的稳妥路径。在工具和领域模型都梳理完毕后再拆分组件，各组件依赖什么便一目了然。</p>
<p><strong>判断是否抽象的标准是“命名</strong>”。如果提取成函数或变量后，其名称能够说明行为，就值得抽象。如果名字不可避免地变得模糊，保留内联反而可能是更好的选择。</p>
<p><strong>状态的位置就是架构</strong>。需要一起变化的状态应该放在同一个地方。与其同步 <code>useState</code> 和 <code>searchParams</code>，不如只把 searchParams 作为事实来源，这在结构上更加健康。</p>
<h2 id="结语"><a class="anchor" href="#结语">结语</a></h2>
<p>完成题目后，我和两位同事聊了聊。独自阅读代码时看不见的问题，在通过对话梳理思路的过程中逐渐显露出来。当别人对我觉得理所当然、随手略过的结构选择问出“为什么这么做？”时，我才看见自己此前没有意识到的判断盲点。</p>
<p>AI 确实正在大幅缩短编写和审查代码所需的时间。但即便如此，我依然认为代码审查和每日会议很重要，原因正是这种经历。AI 可以验证代码的一致性，但指出“<strong>你遗漏的是这个视角</strong>”，终究还是要靠共享相同语境的同事。发现我未曾看到的部分，再通过这些发现让产品更加稳定——这或许就是协作的本质。</p>
<p>在解决问题、编写代码的过程中，并不存在唯一的正确答案。参加同一场模拟考试的其他人也选择了各自不同的路径，并且都有自己的依据。重要的是，能够解释“<strong>为什么要这样写</strong>”。也建议各位读者偶尔用第一次看到自己代码的人的视角重新审视它。仅仅这一种视角，就可能成为决定代码质量的最有力标准。</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>前端</category>
            <category>React</category>
            <category>重构</category>
        </item>
        <item>
            <title><![CDATA[AI 前端工程师]]></title>
            <link>https://hooninedev.com/zh-CN/260302</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/260302</guid>
            <pubDate>Mon, 02 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想从个人视角聊一聊：在 AI 时代，工程师该如何成长并生存下去。 在我还是初级工程师时，读过的文章中给我印象最深的一篇，是裴辉东的《前端工程师职业路线图：面向初级工程师的三条专业发展路径》。文章将前端工程师的职业发展分为三条路径：Web 专精（Software Engineer）/ 产品专精（Product Engineer）/ 运营专精（Full-Stack Engineer），还进一步...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想从个人视角聊一聊：<strong>在 AI 时代，工程师该如何成长并生存下去</strong>。</p>
<p>在我还是初级工程师时，读过的文章中给我印象最深的一篇，是裴辉东的《<a href="https://kr.linkedin.com/posts/hwidongbae_%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%97%94%EC%A7%80%EB%8B%88%EC%96%B4-%EC%BB%A4%EB%A6%AC%EC%96%B4-%EB%A1%9C%EB%93%9C%EB%A7%B5-%EC%A3%BC%EB%8B%88%EC%96%B4%EB%A5%BC-%EC%9C%84%ED%95%9C-3%EA%B0%80%EC%A7%80-%EC%A0%84%EB%AC%B8%EC%84%B1-%ED%8A%B8%EB%9E%99-activity-7013888624140189696-XiIz" target="_blank" rel="noopener noreferrer">前端工程师职业路线图：面向初级工程师的三条专业发展路径</a>》。文章将前端工程师的职业发展分为三条路径：<strong>Web 专精（Software Engineer）/ 产品专精（Product Engineer）/ 运营专精（Full-Stack Engineer）</strong>，还进一步梳理了“优秀工程师的五项基本能力”和“成为资深工程师的三个要点”。那时，最大的议题还是思考自己该在每条路径上积累哪些能力。然而，读完那篇文章还不到两年，议题本身就彻底变了。</p>
<p>最近和工程师同事们聊天时，我能感觉到，大家的烦恼和过去几年听到的已经不太一样了。</p>
<ul>
<li>“公司引入 AI 之后，把设计稿丢给它，基本都能做出来。方便倒是方便……”</li>
<li>“招聘市场冷得厉害。”</li>
<li>“直接合并 AI 写的代码又有点怕，可要逐一检查，效率又会下降，挺让人纠结的。”</li>
</ul>
<p>我也经历过类似的阶段，现在仍然身处其中。一两年前，我还只把 AI 当作“好用的辅助工具”，如今却已经到了无法想象没有 AI 要怎么开发的程度（我写这篇文章时，也在请 Claude 帮忙做调研）。这篇文章算是裴辉东那篇文章的续篇。我想从自己的视角梳理一下：这段时间里，景象发生了怎样的变化；面对变化后的环境，前端工程师又该进一步培养哪些能力。</p>
<p>这次我也尽可能查找并核验了大量资料，但这个领域变化实在太快，文章发布时，其中部分内容可能已经过时，还请大家谅解。如果有值得反驳或讨论之处，欢迎随时留言。</p>
<h2 id="现在不是什么都能让-ai-做了吗"><a class="anchor" href="#现在不是什么都能让-ai-做了吗">“现在不是什么都能让 AI 做了吗？”</a></h2>
<p>首先，有一件事必须说清楚：“AI 什么都能做”这句话是真的吗？到什么程度是真的，又从哪里开始只是幻想？</p>
<p>2025 年 2 月，OpenAI 联合创始人、前特斯拉 AI 总监 <a href="https://x.com/karpathy/status/1886192184808149383" target="_blank" rel="noopener noreferrer">Andrej Karpathy</a> 在 Twitter 上写下了这样一句话。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>出现了一种我称之为“vibe coding”的新编程方式：你完全顺着感觉走，拥抱指数级增长，甚至忘记代码本身的存在。</p></div><div class="quote-original" lang="en"><p>There's a new kind of coding I call 'vibe coding', where you fully give in to the vibes, embrace exponentials, and forget that the code even exists.</p></div></blockquote>
<p>所谓 <strong>vibe coding</strong>，简单来说就是“把键盘交给 AI，自己只用自然语言描述想要的东西”的编程方式。没有架构文档，没有样板代码，也不用搜索分号。代码就这样跟着感觉跑起来。不到一年，这个词就成了英语开发者社区里的标准用语。</p>
<p>但整整一年后的 2026 年 2 月，同一位 Karpathy 却<a href="https://thenewstack.io/vibe-coding-is-passe/" target="_blank" rel="noopener noreferrer">往后退了一步</a>。他提议用 <strong>“agentic engineering”</strong> 取代 vibe coding。两者的区别很明确。</p>
<ul>
<li><strong>Vibe coding</strong>：描述自己想要什么，然后接受产出</li>
<li><strong>Agentic engineering</strong>：设计系统、明确约束，再用 AI 加速实现那些已经在脑中推理完成的方案</li>
</ul>
<p>一年前，“只要吩咐一下，它就全都能做出来”还是基本共识；如今，“设计该让 AI 做什么、怎么做的能力”本身，已经成为一种工程能力。这股趋势并不只来自某一个人的推文。同一时期，Google 工程师 <a href="https://addyosmani.com/" target="_blank" rel="noopener noreferrer">Addy Osmani</a> 出版了《<a href="https://www.amazon.com/Beyond-Vibe-Coding-AI-Era-Developer/dp/B0F6S5425Y" target="_blank" rel="noopener noreferrer">Beyond Vibe Coding: From Coder to AI-Era Developer</a>》，并明确指出：“AI 只是助手，不是可以自主信赖的程序员。你才是资深开发者，LLM 的存在是为了加速你的判断。”</p>
<h3 id="工具正在狂飙"><a class="anchor" href="#工具正在狂飙">工具正在狂飙</a></h3>
<p>工具阵营也顺应这股趋势快速演进。截至 2026 年 3 月，最常被提及的编程工具包括 Cursor、Claude Code、GitHub Copilot、Windsurf、v0 by Vercel、Bolt.new 和 Devin。</p>
<p>v0 的变化尤其具有象征意义。Vercel 使用了“<a href="https://venturebeat.com/infrastructure/vercel-rebuilt-v0-to-tackle-the-90-problem-connecting-ai-generated-code-to" target="_blank" rel="noopener noreferrer">90% problem</a>”这个说法，意思是现实开发中 90% 的工作都发生在现有代码库和基础设施之内。起初，只要能做好全新项目原型就够了；如今，v0 已经可以直接导入 GitHub 仓库进行开发、强制遵循设计系统，并自动读取部署环境变量。对于资深工程师提出的“AI 不就只会做好玩具式演示吗”这一质疑，工具阵营正在亲自作答。</p>
<p>大型科技公司的代码库最能体现这种变化。</p>
<p>Google 的 Sundar Pichai <a href="https://fortune.com/2024/10/30/googles-code-ai-sundar-pichai/" target="_blank" rel="noopener noreferrer">在 2024 年 10 月的第三季度财报电话会议上宣布，“超过 25% 的新代码由 AI 生成，再由工程师审核和批准”</a>，到 2025 年 4 月，这一比例已升至 30% 以上。Microsoft 的 Satya Nadella <a href="https://www.cnbc.com/2025/04/29/satya-nadella-says-as-much-as-30percent-of-microsoft-code-is-written-by-ai.html" target="_blank" rel="noopener noreferrer">在 2025 年 4 月的 LlamaCon 上透露，“我们的代码中，最多有 30% 是 AI 编写的”</a>。Meta 的内部目标甚至已经提高到“截至 2026 年上半年，让 65% 的工程师使用 AI 生成至少 75% 的提交内容”。</p>
<p>韩国国内的趋势也没有不同。<a href="https://toss.tech/article/toss-frontend-ai-docs" target="_blank" rel="noopener noreferrer">Toss</a> 为了让开发者不再需要自己找文档、提升开发者体验，搭建了基于 AI 的文档系统，并更进一步探讨了“<a href="https://toss.tech/article/removing_designers_in_ai_era" target="_blank" rel="noopener noreferrer">AI 时代，取消设计师岗位之后发生的事</a>”之类的话题。Daangn 每周二通过 <a href="https://medium.com/daangn" target="_blank" rel="noopener noreferrer">AI Show &#x26; Tell</a> 分享各团队的实验，还开始打出“超越工程师，成为构建者”的<a href="https://about.daangn.com/blog/archive/%EB%8B%B9%EA%B7%BC-%ED%95%B4%EC%BB%A4%ED%86%A4-%EC%97%94%EC%A7%80%EB%8B%88%EC%96%B4-%EC%B1%84%EC%9A%A9/" target="_blank" rel="noopener noreferrer">招聘口号</a>。Woowa Brothers 则通过“<a href="https://techblog.woowahan.com/22828/" target="_blank" rel="noopener noreferrer">在 AI 编写代码的时代，你还愿意成为开发者吗？</a>”等文章传递这样的信息：“开发者的本质不在代码，而在于定义和解决问题的能力。”</p>
<h3 id="但数字讲述的是另一个故事"><a class="anchor" href="#但数字讲述的是另一个故事">但数字讲述的是另一个故事</a></h3>
<p>只看到这里，很容易得出“现在只要吩咐一下，什么都能做”的结论。但真正查看数据后，故事却有些不同。</p>
<p>先看综合分析软件开发现状的 <a href="https://survey.stackoverflow.co/2025/ai" target="_blank" rel="noopener noreferrer"><strong>2025 Stack Overflow Developer Survey</strong></a> 中的数据。</p>
<ul>
<li>84% 的开发者表示正在使用或计划使用 AI 工具。（高于 2024 年的 76%）</li>
<li>51% 的专业开发者每天都使用 AI 工具。</li>
<li>但<strong>对 AI 工具的好感度（positive sentiment）反而下降了</strong>。2023 年和 2024 年都在 70% 以上，2025 年却降到了 60%。</li>
<li>从业 10 年以上的资深开发者对 AI 输出的信任度最低。</li>
</ul>
<p>总结起来就是：<strong>“大家确实都在用，但越用越不放心。”</strong></p>
<p>非营利研究机构 <a href="https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/" target="_blank" rel="noopener noreferrer"><strong>METR</strong></a> 在 2025 年开展的一项实验，更鲜明地展现了认知与现实之间的落差。这是一项对照实验：16 名熟练的开源开发者平均拥有 5 年经验和 1,500 次提交，研究者让他们完成 246 项任务，并随机决定是否允许使用 AI。结果如下。</p>
<ul>
<li>开始前，开发者预测“使用 AI 会快 24%”。</li>
<li>完成任务后，他们仍自我评价“感觉大约快了 20%”。</li>
<li>但实际测量结果却是：<strong>慢了 19%</strong>。</li>
</ul>
<p>研究人员指出的原因很有意思。AI 生成代码的接受率低于 44%；即使代码被拒绝，审查和测试它也要花时间；就连被接受的代码，也需要大量时间来审核和修改。明明变慢了，却误以为自己变快了——这种落差正是资深开发者对 AI 日益怀疑的原因之一。</p>
<p>此外，“AI 编写的代码质量”本身也并不理想。来看 <a href="https://www.veracode.com/blog/genai-code-security-report/" target="_blank" rel="noopener noreferrer"><strong>Veracode</strong></a> 让 100 多个 AI 模型编写代码的实验。</p>
<ul>
<li>AI 生成代码中有 <strong>45% 包含 OWASP Top 10 安全漏洞</strong>。</li>
<li><strong>XSS（跨站脚本）防护失败率为 86%</strong>。</li>
<li>日志注入（Log Injection）防护失败率为 88%。</li>
<li>另一项研究报告称，AI 代码的漏洞密度是人类代码的 <strong>2.7 倍</strong>。</li>
</ul>
<p>尤其是与前端直接相关的 XSS 防护失败率高达 86%，值得更严肃地看待。直接合并 AI 写出的表单输入框究竟意味着什么，这个数字已经说明得很清楚。（有过前端安全审计经验的开发者，哪怕亲手写 <code>dangerouslySetInnerHTML</code> 时也会觉得别扭、心里不安；AI 悄悄塞进去的就更让人害怕了。）</p>
<p>质量方面也释放出相似的信号。<a href="https://www.gitclear.com/ai_assistant_code_quality_2025_research" target="_blank" rel="noopener noreferrer"><strong>GitClear</strong></a> 分析了 2020 至 2024 年间 2.11 亿行代码变更，结果如下。</p>
<ul>
<li>编写后两周内被回滚（reverted）的代码比例（Code Churn）：2020 年 5.5% → 2024 年 <strong>7.9%</strong></li>
<li>重构所占比例：2021 年 25% → 2024 年<strong>低于 10%</strong></li>
<li>复制粘贴（克隆）比例：2021 年 8.3% → 2024 年 <strong>12.3%</strong>（2025 年更是增长到四倍）</li>
</ul>
<p>这并不难解读。快速产出代码的能力增强了，但写出值得再次打磨的代码的能力却下降了。分析《财富》50 强企业的 <a href="https://www.softwareseni.com/ai-generated-code-security-risks-why-vulnerabilities-increase-2-74x-and-how-to-prevent-them/" target="_blank" rel="noopener noreferrer">Apiiro 数据</a>给出的结果更为强烈：借助 AI 开发的开发者，提交数量是同事的 3～4 倍，却制造出 10 倍的安全问题。权限提升路径（privilege escalation）暴增 322%，架构设计缺陷则暴增 153%。</p>
<h2 id="ai-取代了什么又没能取代什么"><a class="anchor" href="#ai-取代了什么又没能取代什么">AI 取代了什么，又没能取代什么</a></h2>
<p>工具在狂飙，数据却很微妙。那么 AI 究竟取代了什么，又有哪些事情尚未取代？只有把这条界线划清楚，我们才知道该把时间投入在哪里。</p>
<p>被取代的，是开发者亲手输入代码的那部分工作。编写样板代码和重复代码的场景减少了；只要有设计稿，几分钟内就能产出符合约定水准的页面；搜索语法和 API 所需的时间，以及学习曲线，也都大幅缩短。简而言之，AI <strong>拉平了“生产速度”</strong>。</p>
<p>但在“判断”这一领域，它还没能取代人。（准确地说，更接近于“尚未达到我们的期待”。虽然每个人运用 AI 的能力存在差距，但这里以平均使用体验为准展开讨论。）</p>
<p>首先遇到的难题，是<strong>将需求转化为规格</strong>。把模糊的业务需求转化成明确的边界情况和状态机，仍然更需要人类深度参与。<strong>理解系统层面的影响</strong>也是如此：这个组件会如何影响 bundle、依赖项能否 tree-shaking、数据获取模式会怎样影响 <a href="https://web.dev/articles/vitals" target="_blank" rel="noopener noreferrer">Core Web Vitals</a> 中的 <a href="https://web.dev/articles/inp" target="_blank" rel="noopener noreferrer">INP（Interaction to Next Paint）</a>得分——面对这类问题，即便 AI 给出了看似合理的答案，最终仍要由人再看一遍才能放心。</p>
<p>如前文所述，45% 的 OWASP 漏洞意味着<strong>安全与风险评估</strong>也不能忽视；除此之外，还有<strong>维护设计系统与一致性</strong>，即检查新组件是否与现有系统的 token、无障碍规范和交互模式保持一致；以及<strong>理解客户与市场语境</strong>，即判断为什么需要这项功能、它应该放进哪条用户流程。这些领域同样如此。</p>
<p>最后，借用 <a href="https://yceffort.kr/2026/02/frontend-engineering-in-ai-era" target="_blank" rel="noopener noreferrer">yceffort</a> 文章中的说法，“系统复杂度与团队对该系统理解程度之间的差距”就是<strong>认知债务（Cognitive Debt）</strong>。AI 引入后，这条差距反而扩大得更快，因此缩小差距本身依然是人的职责。</p>
<blockquote>
<p>消失的不是开发者，而是开发者过去所做工作的形式。瓶颈已从“创造的速度”转移到“决策的速度”。</p>
</blockquote>
<p>同样地，Toss 的《<a href="https://toss.tech/article/will-ai-replace-developers" target="_blank" rel="noopener noreferrer">开发者会被 AI 取代吗？</a>》给出了更沉重的判断。文章的核心是：AI 并非取代所有人力，而是在移除学徒阶梯（apprenticeship ladder）。等到如今的资深工程师在 10～20 年后退休时，能够设计复杂系统的下一代人才将会不足。这已不只是“我们公司明年该怎么招聘”的问题，而像是一颗埋在整个行业里的延时炸弹。（我认为这是在这个令人忧虑的时期写得非常好的一篇文章。）</p>
<p>AI 做出的“能运行的第一个版本”占 70%。走到“可以真正提供给用户使用的版本”，剩下的 30% 属于人。而补足这 30% 的能力并非一朝一夕可以形成。这正是学徒阶梯问题的本质：如果编写样板代码和简单组件、让自己“亲手沾上代码”的时间消失了，能够补足那 30% 的人也会一同消失。</p>
<p>裴辉东的原文将<strong>写出好代码、最大化当前价值（在快速发布与长期可维护性之间取得平衡）、基于数据决策、帮助同事有效决策、持续学习</strong>列为“优秀工程师的五项基本能力”。这五项在 AI 时代依然有效，但其中最后一项正处在最危险的位置。学习本身没有消失，改变的是学习对象。过去我们学习“这个工具怎么用”，现在则应该把时间花在理解“整个系统是如何运转的”上。更可怕的是 <a href="https://evan-moon.github.io/2026/04/18/developers-who-stopped-growing-in-ai-era/" target="_blank" rel="noopener noreferrer">Evan Moon 所指出的</a>：“当 AI 代替人编写代码时，大脑的认知负荷会骤然降低。”认知负荷降低听起来是件好事，但危险在于，这种负荷恰恰是学习的材料。<strong>越轻松，越难成长。</strong></p>
<p>这里自然会出现一个问题：那么，裴辉东文章中的三条路径（Web 专精/产品专精/运营专精）如今已经没有意义了吗？</p>
<p>我的看法不同。这些路径本身依然有效，只是每条路径都顺应 AI 时代向前演进了一个阶段。下面就来梳理各条路径的景象发生了怎样的变化。</p>
<h2 id="从生产者到验证者"><a class="anchor" href="#从生产者到验证者">从生产者到“验证者”</a></h2>
<p>在裴辉东的原文中，Web 专精路径被归入 <strong>Software Engineer</strong>。其核心包括“深入理解并运用互联网、Web 浏览器和 HTML/CSS/JS”，了解 Web 生态工具的优缺点并具备故障排查经验，以及对新技术保持敏锐。通往资深岗位的方向则包括 <strong>Web 生态工具厂商工程师 / 前端教育者 / 复杂产品组织的技术负责人</strong>。简单说，他们是“深入钻研浏览器与 HTML/CSS/JS 工作原理的人”。直到一两年前，他们最大的武器还是“能比任何人都更准确地写代码”。</p>
<p>AI 时代，他们的价值发生了怎样的变化？如果只看写代码的速度，AI 已经追了上来。但“<strong>准确评估 AI 所写代码的能力</strong>”，反而几乎成了他们独有的优势。</p>
<ul>
<li>使用 AI 的普通人：已经按我的需求实现了，运行也正常。</li>
<li>使用 AI 的开发者：虽然能运行，但这个依赖项可能引发某些问题；这种模式按这样的方式改进才更符合约定。再把相关部分检查一遍吧。</li>
</ul>
<p>前文提到的 Veracode 研究中，有 XSS 防护失败率 86%、日志注入防护失败率 88% 这两项数据。能够发现并解决这些问题的人，正是像我们这样的专业人士。他们会自然演变为负责 AI 产出质量保证（QA）的资深角色。</p>
<p>此外，专业人士的领域中还新增了一个全新的主题：<strong>生成式 UI（Generative UI</strong>）与 <strong>AI 界面设计</strong>。比如，以流式方式呈现 LLM 回答的聊天 UI、可在中途停止的 abort 控件、Markdown/代码块的渐进式渲染、以内联方式展示工具调用结果的用户体验，以及运用 <a href="https://sdk.vercel.ai/" target="_blank" rel="noopener noreferrer">Vercel AI SDK</a> 或 <a href="https://modelcontextprotocol.io/" target="_blank" rel="noopener noreferrer">MCP（Model Context Protocol）</a>进行助手集成。在这个领域，“既准确理解 Web 工作原理，又理解 LLM 的运行特性，并能加以应用和利用的人”正迎来爆发式需求。</p>
<h2 id="自然而然地演进为-product-engineer"><a class="anchor" href="#自然而然地演进为-product-engineer">自然而然地演进为 Product Engineer</a></h2>
<p>产品专精是受益最大的一条路径。对市场和客户理解深入、经常与外部利益相关者沟通的人，一旦加上 AI，就拥有了强大得多的武器。这条路径的另一项特点，是其资深发展方向中也包括<strong>增长工程师、顾问 / 转型为 PM、PO、CPO</strong>等向其他职能的扩展。</p>
<p>一个有趣的变化是，这条路径的名称开始成为全球标准。原文其实已经将其称为“Product Engineer”，但我读到那篇文章时，这个表达对我来说还有些陌生。一年后的今天，它已经普及到<a href="https://leerob.com/product-engineers" target="_blank" rel="noopener noreferrer">Vercel 将职位描述中的“Fullstack Engineer”统一改为“Product Engineer”的程度</a>。</p>
<p>Lee Robinson 将 Product Engineer 的核心素质归纳为三项。</p>
<ul>
<li><strong>重视迭代（Iteration）</strong>：快速完成部署 → 反馈 → 调整的循环。</li>
<li><strong>以客户为中心</strong>：直接与客户交谈并改进产品。</li>
<li><strong>务实</strong>：“所有技术选择都只是手段。”对产品目标没有贡献的工具要果断舍弃。</li>
</ul>
<p>这里有一个陷阱：如果产品专精工程师只被理解为“做得快的人”，就会很危险。AI 出现后，这种风险更大了，因为“快速塞进功能”如今任何职能的人都可以借助 AI 工具完成。Product Engineer 的差异化优势在于“准确定义客户问题，并用最小的解决方案快速验证”，而不在于“手快”。</p>
<p>在这股趋势下，<strong>Design Engineer</strong> 开始升级为正式岗位。Vercel 正在把设计工程师作为<a href="https://cjroth.com/blog/2026-02-18-building-an-elite-engineering-culture" target="_blank" rel="noopener noreferrer">年薪 20 万美元以上的正式发展路径</a>进行招聘，Linear 和 Stripe 也在朝类似方向发展。这个岗位直接消除了前端与设计之间的交接。AI 能快速把图画出来，因此，同时处理“该画什么 + 画出的结果是否符合一致的设计系统”的能力变得更加稀缺。</p>
<h2 id="ai-编排者"><a class="anchor" href="#ai-编排者">AI 编排者</a></h2>
<p>运营专精是变化最剧烈的一条路径。裴辉东的原文将其归为 <strong>Full-Stack Engineer</strong>，定义是“高度关注项目结构、集成、测试和部署，能亲自处理简单的 API 与基础设施，填补组织空白并改善流程的人”。过去一两年间，<strong>运营 AI 智能体本身的职责</strong>又叠加在这条路径之上，使其边界迅速扩张。</p>
<p>在梳理<a href="https://beyond.addy.ie/2026-trends/" target="_blank" rel="noopener noreferrer">2026 年趋势</a>时，Addy Osmani 将“<strong>编排编程智能体（Orchestrating Coding Agents</strong>）”列为核心概念。它意味着不再只是让一个 AI 做事，而是设计并运营一个让多个 AI 智能体同时协作的系统。在同样的脉络下，他提出了名为“<a href="https://github.com/addyosmani/agent-skills" target="_blank" rel="noopener noreferrer">agent-skills</a>”的框架，并有人主张将专业工作流、质量门槛和行业最佳实践直接编码进智能体的行为逻辑中。</p>
<p>汇总相关资料后，我认为运营路径工程师接下来需要掌握的新关键词如下。</p>
<ul>
<li><strong>MCP（Model Context Protocol）</strong>：Anthropic 提出的 LLM 与外部工具连接标准</li>
<li><strong>AI 治理</strong>：管理谁能在什么上下文中使用 AI，以及密钥或机密信息是否会泄露</li>
<li><strong>智能体评估（Evaluation）</strong>：自动评测智能体产出的流水线</li>
<li><strong>AI 门禁</strong>：PR 合并前自动验证安全与质量，并为 AI 代码添加标签</li>
</ul>
<p>原文将<strong>大型组织的平台团队工程师 / 技术负责人 / 敏捷教练 / 技术项目经理（TPM）/ CTO</strong>等岗位列为运营路径的资深发展方向。这些方向如今依然有效，只是可以认为又增加了 <strong>“AI 开发基础设施负责人</strong>”、“<strong>开发者生产力（DevProd）工程师</strong>”等新岗位。</p>
<p>三条路径各自演进的同时，也有一些能力在所有路径中都变得更加重要。我原本想以五年后为基准来思考，但按照如今的发展速度，就连一年都显得太长。因此，先把视野缩短到“明年”左右，谈谈在我看来会变得更加重要的能力。</p>
<h2 id="五项能力"><a class="anchor" href="#五项能力">五项能力</a></h2>
<p><strong>第一项是编写规格（Specification）的能力。</strong> AI 时代，“编程的起点”不是键盘，而是<strong>规格</strong>。准确写清楚要让 AI 做什么的能力，已经比代码本身更重要。这里所说的规格，并不是什么宏大的 RFC 文档，而是像这样一些东西：用代码写下业务逻辑预期行为的<strong>测试</strong>；整理 UI 组件场景和视觉契约的 <strong>Storybook story</strong>；明确数据流契约的<strong>类型定义</strong>。归根结底，这是预先铺设一套能自动验证 AI 产出的标准。缺少这一步就开始让 AI 编程，问题会不断累积。</p>
<p><strong>第二项是验证与判断力。</strong> AI 会自信地生成看似合理、实则错误的代码。因此，我认为“快速而准确地审查 AI 代码的能力”本身就是核心。要能判断它是否漏掉了安全标头、输入净化、CSRF 令牌，无障碍能力（ARIA、键盘导航、焦点陷阱）是否仍然有效，以及渲染成本、内存、包体积等性能影响是否存在问题。不经审查就把 AI 垃圾代码扔进 PR，是工程师的失职。点击合并按钮的仍然是人，这份责任不能推给 AI。Stack Overflow 调查中，资深工程师对 AI 的信任度最低，很可能正是因为他们拥有发现这些细节的眼光。</p>
<p><strong>第三项是系统理解与架构思维。</strong> AI 擅长一次处理一个文件，对流程和关联也具备出色的理解能力。AI 能快速修复症状，但能力出众的开发者会寻找根本原因。培养这项能力的方法之一，是有意识地开展 Architecture Retrospective 等活动。代码变化得越快，团队就越需要主动提升对系统的理解，否则认知债务会迅速积累。</p>
<p><strong>第四项是 AI 编排能力。</strong> 运用 AI 本身的能力，也正在分化成独立的技能组合。这已经不只是“善于写提示词”，而是需要整体掌握：把工作拆分成小工单的能力；为同一项工作选择合适模型的能力；设计智能体评估与验证流水线的能力；以及智能体失败时的回滚策略。<a href="https://sourcegraph.com/blog/revenge-of-the-junior-developer" target="_blank" rel="noopener noreferrer">Steve Yegge</a> 将这一趋势归纳为<strong>六个阶段（traditional → completions → chat → coding agents → agent clusters → agent fleets）</strong>。</p>
<p><strong>第五项是 Context Engineering。</strong> 这是从 2025 年年中起，<a href="https://www.faros.ai/blog/context-engineering-for-developers" target="_blank" rel="noopener noreferrer">Karpathy 与 Shopify CEO Tobi Lütke 一同推动</a>的概念。简单说，就是“设计要让 AI 看到哪些上下文、以什么形式呈现、呈现多少的能力”。具体表现包括：把项目约定、架构原则和禁忌事项整理到 AI 随时能够触及的地方，即维护 <strong>CLAUDE.md / rules 文件</strong>；不把所有文件都塞进上下文，只筛选相关模块展示给 AI，即<strong>有意缩减上下文</strong>；将计划 → 实现 → 验证拆分到不同会话，避免上下文污染，即<strong>明确划分阶段</strong>；以及通过标准接口接入设计系统、API schema、监控数据等外部上下文，即<strong>通过 MCP 引入外部上下文</strong>。<a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents" target="_blank" rel="noopener noreferrer">Anthropic 官方文档</a>将其称为“the new prompt engineering”，并明确指出，单个提示词绝不可能容纳系统的架构知识、模式和隐性经验。换句话说，比起“一次写出好的提示词”，“设计一个让 AI 始终获得优质上下文的环境”已经重要得多。</p>
<p>读到这里，自然会产生一个问题：那么具体该怎么学习？我自己采用的方法大致有四种。</p>
<h2 id="学习方法"><a class="anchor" href="#学习方法">学习方法</a></h2>
<p>原文提到的“持续学习”依然有效，但学习时间的分配必须改变。</p>
<p>有些领域过去投入了很多时间，如今可以减少；相对地，也有些复杂领域过去因为太难或耗时太久而没有去做。后者包括编写测试规格、使用性能测量工具（<a href="https://developer.chrome.com/docs/lighthouse" target="_blank" rel="noopener noreferrer">Lighthouse</a>、<a href="https://www.webpagetest.org/" target="_blank" rel="noopener noreferrer">WebPageTest</a>、Chrome DevTools Performance）、无障碍（<a href="https://www.w3.org/WAI/standards-guidelines/wcag/" target="_blank" rel="noopener noreferrer">WCAG</a>）、安全（尤其是 <a href="https://owasp.org/www-project-top-ten/" target="_blank" rel="noopener noreferrer">OWASP Top 10</a>）等。另外，还有 Vercel AI SDK、LangChain.js、MCP、流式 UI 模式、智能体评估流水线等需要从头学习的新领域。<strong>重要的是认识到自己需要哪些能力，并合理分配时间。</strong></p>
<p>AI 生成的代码很容易变得庞大，一分钟就能产出几百行。因此，如果不主动管理 PR 大小和合并周期，代码审查本身就会崩溃。公司引入 AI 后，平均 PR 大小增加了 18%，每个 PR 的事故数量增加了 24%，变更失败率增加了 30%。结合前文的数据来看，如果写得太大、一次性合并，<strong>就很难掌握流程和体现意图，因此细分工作单元非常重要。</strong></p>
<p>这也直接关联到 Evan Moon 文章所指出的认知负荷降低问题。最好每天单独留出一两个小时，在不使用 AI 的情况下写代码。例如亲手画架构图，或逐行阅读自己不熟悉领域的代码。（我自己也会在每天午饭后容易犯困的时段，不用 AI 写一会儿代码。这段时间让我不至于离曾经熟悉的感觉越来越远。）</p>
<p>这并不只是为了“不忘记旧方法”。AI 代劳了多少时间，你自己的深度就少成长多少。验证与判断力、系统理解等能力，都是亲自碰壁时间的函数。</p>
<h2 id="所以我们要做的是"><a class="anchor" href="#所以我们要做的是">所以，我们要做的是</a></h2>
<p>虽然前面写了很长，但其实，AI 时代能够生存下来的前端工程师形象，与原文得出的结论并没有太大不同。原文列出的优秀资深工程师的三个要点如下。</p>
<ul>
<li>努力<strong>忠于基本功</strong>。（持续保持并加强五项基本能力）</li>
<li>即便不是名义上的领导者，也通过示范性行动自然地发挥影响力。</li>
<li>不满足于做好分配到手的工作，而是审视前后语境，创造更大的影响力。</li>
</ul>
<p>把这些应用到 AI 时代，就会变成下面这样。</p>
<ul>
<li>忠于 AI 生成的代码之外的<strong>基本功</strong>（Web、系统、业务领域）。</li>
<li>方向由自己决定，而不是交给 AI。即便不是名义上的负责人，也要判断“该往哪里走”。</li>
<li>不只把 AI 当作个人生产力工具，还要用它解决团队和系统的瓶颈。</li>
</ul>
<p>从 Open AI 权威专家 Andrej Karpathy 的文章来看，他如今强调的 <strong>“agentic engineering”</strong>，核心归根结底也是一样的：设计系统、明确约束，再用 AI 加速实现那些已经在脑中推理完成的方案。工具变了，但方向键仍然掌握在人手中。</p>
<p>原文最后传达的信息，同样是“不满足于做好分配到手的工作，而是审视前后语境，创造更大影响力的人”才会成为资深工程师。AI 时代只是“影响力”的定义发生了变化。有人会把 AI 一小时做出的页面，以“能运行就行”为由直接合并；也有人会再花 30 分钟，检查这个页面在无障碍、安全、性能和系统一致性方面究竟有多合理。一年后被认可为资深工程师的，会是后者。站在 70%（运行）与 30%（应用和运用）的边界上，选择站到 30% 那一侧的人，才能生存下来。</p>
<p>希望读到这篇文章的前端工程师，也能对“现在还该继续学什么？”这个问题找到自己的答案。没有人知道标准答案，但我相当确信：越是在 AI 编写代码的时代，越是能看到“代码之外的东西”的人，越能生存下去。希望一年后还能再次整理成文章，回顾这幅景象又发生了怎样的变化。就此收笔。<strong>（如果一年后再看，这篇文章显得过于理所当然或已经过时，那或许正说明我们应对得很好。）</strong></p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>前端</category>
            <category>职业发展</category>
            <category>AI</category>
        </item>
        <item>
            <title><![CDATA[抽象]]></title>
            <link>https://hooninedev.com/zh-CN/260201</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/260201</guid>
            <pubDate>Sun, 01 Feb 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想谈谈编程中的抽象，以及如何从抽象的角度写出好代码。 在从事前端开发的过程中，我无数次纠结过：“这段逻辑到底该拆到什么程度？”“这个组件应该按什么粒度拆分？”起初，我以为只要把共同部分提取出来就是抽象：把重复代码写成函数，提取相似组件的共同点再合并到一起。但几次经历告诉我，这样写出的代码随着时间推移，反而可能变成难以下手的怪物。于是，我开始重新思考抽象究竟是什么。 本文将整理我对抽象本质的...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想谈谈编程中的抽象，以及如何从抽象的角度写出好代码。</p>
<p>在从事前端开发的过程中，我无数次纠结过：“这段逻辑到底该拆到什么程度？”“这个组件应该按什么粒度拆分？”起初，我以为只要把共同部分提取出来就是抽象：把重复代码写成函数，提取相似组件的共同点再合并到一起。但几次经历告诉我，这样写出的代码随着时间推移，反而可能变成难以下手的怪物。于是，我开始重新思考抽象究竟是什么。</p>
<p>本文将整理我对抽象本质的思考，以及在前端开发中如何运用抽象才能写出好代码。</p>
<h2 id="抽象与抽象化"><a class="anchor" href="#抽象与抽象化">抽象与抽象化</a></h2>
<p>在进入正题之前，先来厘清“抽象”与“抽象化”在编程中究竟意味着什么。这两个词看起来相近，性质却颇为不同。</p>
<p><strong>抽象（Abstract）<strong>既是一种状态，也是一种属性。当我们说“这是抽象的”时，意味着具体细节已被省略，处于一种</strong>只保留核心概念的状态</strong>。Java 或 TypeScript 中带有 <code>abstract</code> 关键字的类或方法就是这个含义：具体实现尚未填充，只定义了本质形态，是一张尚未完成的设计图。</p>
<p><strong>抽象化（Abstraction）<strong>既是一个过程，也是一种行为。它指的是从复杂对象中只保留核心特征、去除不必要细节，使其简化的过程本身。重要的是，抽象化并非“笼统地归为一类”，而是</strong>在每个层级上准确界定职责的行为</strong>。</p>
<p>日常生活中，“抽象”常带有“模糊”的意味。但编程中的抽象恰恰相反。抽象的目的不是变得模糊，而是建立一个可以做到绝对准确的新语义层级。在给定上下文中保留相关信息、忘掉无关信息，正是抽象的本质。</p>
<p>因此，可以这样理解抽象与抽象化的区别：<strong>抽象是“只剩核心的状态”，抽象化是“只留下核心的过程”</strong>。我们设计代码时所做的，正是抽象化——从复杂实现中留下核心接口，并隐藏其余部分。</p>
<p>那么，编程为什么需要这样的抽象呢？</p>
<h2 id="为什么需要抽象"><a class="anchor" href="#为什么需要抽象">为什么需要抽象</a></h2>
<p>编程需要抽象的根本原因出乎意料地简单：<strong>为了构建更复杂的东西</strong>。当复杂元素太多时，我们很难全部记住并处理它们。因此，我们把复杂元素分组，形成简化后的抽象概念。</p>
<p>前端开发者每天使用的 React 就是如此。渲染一个组件时，内部会经历创建 Virtual DOM、协调（Reconciliation）、操作真实 DOM 等复杂过程。但我们无需在意这些，只要编写 JSX 即可，因为 React 已经把复杂过程抽象掉了。</p>
<p>看看下面的代码。使用 UserProfile 组件时，即使不了解内部发生的 VDOM 创建、差异比较等复杂过程，我们也完全可以构建和操作 UI。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">UserProfile</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"jihoon"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span></code></pre></figure>
<p>过去，亲自配置 Webpack 是前端开发者的日常；如今，Next.js、Vite 等框架已经把打包配置抽象掉了。于是，我们即使不了解打包器的内部工作方式，也能开发应用，并把这些时间用于<strong>关注业务逻辑、用户体验等更高层次的问题</strong>。（也正因如此，我认为抽象这一概念对前端开发者的职责非常重要。）</p>
<p>归根结底，抽象的核心价值在于：隐藏复杂性，使其看起来简单，并让每个人只专注于自己的领域。正因为如此，我们无需独自理解一切，也能构建日益庞大而复杂的软件。</p>
<p>既然抽象这么好，是不是做得越多越好？让我们思考一下，究竟应该抱着怎样的目的进行抽象。</p>
<h2 id="减少上下文"><a class="anchor" href="#减少上下文">减少上下文</a></h2>
<p>许多开发者把抽象理解为“提取共同部分”。这并没有错，但它只是进行抽象的一种手法，并不能说明抽象的本质。</p>
<p>在我看来，抽象的本质是“<strong>把代码阅读者需要掌握的上下文减少到适当层级</strong>”。</p>
<p>来看一个简单的例子。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Order</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "pending"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> total </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> orders</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Order</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { id: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"a"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"pending"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, amount: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">10000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { id: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"b"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, amount: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">5000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { id: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"c"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, amount: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">8000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">];</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> orders.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; i</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (orders[i].status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    total </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> orders[i].amount;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>阅读这段代码的开发者，必须理解循环的初始化、条件和递增方式，通过索引访问元素，检查条件并进行分支，还要把外部累加变量如何更新全部记在脑中。可这段代码真正想做的，不过是“<strong>计算已完成订单的总额</strong>”这一句话。为了理解这句话，读者却不得不同时承载四种不同的上下文。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> total</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> orders</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">order</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> order.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reduce</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">sum</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">order</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> sum </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> order.amount, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>借助 <code>filter</code> 和 <code>reduce</code> 这两层抽象，开发者只需跟随“只筛选已完成订单”和“累加金额”这两个意图。索引管理，以及累加变量的声明与更新，都从代码表面消失了。</p>
<p>还可以再进一步。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> total</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> sumCompletedOrders</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(orders);</span></span></code></pre></figure>
<p>现在，阅读代码的人甚至不必知道这项计算是通过遍历数组完成的。留下的只有“计算已完成订单总额”这一业务意图。读者可以关注代码计算的<strong>是什么（What）</strong>，而不是<strong>如何（How）计算</strong>。</p>
<p>从这个角度看，我们每天编写的 React 代码其实也是无数抽象的组合。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { css } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "@emotion/css"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { format } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "date-fns"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TodayHeader</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> now</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// Date 객체 생성이라는 복잡한 과정을 추상화</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> formatted</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> format</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(now, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"yyyy-MM-dd"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 날짜 포맷팅 로직을 추상화</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h1</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">css</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">        font-size: 1.8rem;</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">      `</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    ></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      {</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* CSS-in-JS 처리 과정을 추상화 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      Today is {formatted}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// React.createElement를 추상화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 그리고 위 모든 것을 다시 추상화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">TodayHeader</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span></code></pre></figure>
<p>试想，如果 <code>emotion</code>、<code>date-fns</code>、<code>react</code> 的内部代码全都铺开在这个组件文件里，会怎么样？我们将难以判断该从哪里读起，也难以分辨哪部分是业务逻辑，哪部分是库代码。正因为抽象适当地隐藏了各个领域的上下文，我们才能只关注“显示今天的日期”这一实质。</p>
<p>那么，在实际设计代码时，应该从什么方向着手抽象呢？</p>
<h2 id="抽象层级的高与低"><a class="anchor" href="#抽象层级的高与低">抽象层级的高与低</a></h2>
<p>谈到抽象，就绕不开<strong>抽象层级（Level of Abstraction</strong>）这个概念。代码的抽象层级“高”或“低”，究竟是什么意思？</p>
<p><strong>抽象层级低的代码</strong>更接近计算机执行的具体步骤，例如直接解析字符串、通过索引遍历数组、操作字节。它赤裸裸地展现了<strong>如何（How</strong>）运行。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 추상화 수준이 낮은 코드</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> response</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'/api/users'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> users</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> response.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> filteredUsers</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> users.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> user.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'active'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">filteredUsers.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">forEach</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> element</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> document.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getElementById</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`user-${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">id</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (element) element.style.display </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'block'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p><strong>抽象层级高的代码</strong>则使用业务领域或问题域的语言来表达，例如 <code>processPayment(order)</code>、<code>sendNotification(user, message)</code>、<code>validateUserInput(formData)</code>。高抽象层级的代码展现的是<strong>做什么（What）</strong>，而隐藏了具体做法。</p>
<p>Robert C. Martin 在 <strong>Clean Code</strong> 中把这一概念总结为“<strong>每个函数只处于一个抽象层级（One Level of Abstraction per Function</strong>）”原则。因为当高层级与低层级代码混在同一个函数中时，读者每读一行都要判断：“这是核心逻辑，还是实现细节？”</p>
<p>放到实际代码里，这个问题会更加清楚。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 추상화 수준이 뒤섞인 함수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">async</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> registerUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">email</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">password</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ✅ 높은 수준: 비즈니스 규칙 검증</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  validateUserInput</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name, email, password });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ensureEmailNotDuplicated</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(email);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ❌ 낮은 수준: 비밀번호 해싱 직접 구현</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> encoder</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TextEncoder</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> encoder.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">encode</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(password);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> hashBuffer</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> crypto.subtle.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">digest</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"SHA-256"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, data);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> hashedPassword</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Array.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Uint8Array</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(hashBuffer))</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">16</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">padStart</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"0"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">))</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">join</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">""</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ✅ 높은 수준: 사용자 저장</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> userRepository.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">save</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name, email, password: hashedPassword });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ❌ 낮은 수준: 이메일 전송 직접 구현</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> verifyToken</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> crypto.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">randomBytes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">32</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"hex"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> db.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">execute</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">    "INSERT INTO email_tokens (user_id, token, expires_at) VALUES (?, ?, ?)"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    [user.id, verifyToken, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(Date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 86_400_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> transporter.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">sendMail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    from: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"noreply@example.com"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    to: user.email,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    subject: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Welcome! Please verify your email"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    html: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`&#x3C;a href="/verify?token=${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">verifyToken</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}">Verify your account&#x3C;/a>`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ✅ 높은 수준: 환영 이메일 발송</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> sendWelcomeEmail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(user);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>阅读这个函数的人，本来正沿着“用户注册流程”这一基于业务规则的高层上下文前进，却突然被拽到哈希缓冲区操作、SQL 查询和邮件模板字符串这些底层上下文中，随后又跳回 <code>sendWelcomeEmail</code> 这一高层抽象。抽象层级这样忽高忽低，读者的思路也会随之上下跳跃。</p>
<p>将同一个函数改写为统一抽象层级后，会是这样。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 추상화 수준이 일관된 함수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">async</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> registerUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">email</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">password</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  validateUserInput</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name, email, password });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ensureEmailNotDuplicated</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(email);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> hashedPassword</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> hashPassword</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(password);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> userRepository.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">save</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name, email, password: hashedPassword });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> sendVerificationEmail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(user);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> sendWelcomeEmail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(user);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>每条语句都处于相同的抽象高度。邮件发送如何实现、密码哈希采用什么算法，分别由下层函数负责。阅读这个函数的人只需关注“用户注册的完整流程”这一种上下文。</p>
<p>Martin 也把它称为“<strong>降层规则（The Stepdown Rule</strong>）”。从上到下阅读代码时，应当像读新闻报道一样：顶部呈现全貌，越往下细节越多。</p>
<p>Kent Beck 也在 <strong>Smalltalk Best Practice Patterns</strong> 中以<strong>组合方法（Composed Method）模式</strong>提出了相同原则：一个方法只能由处于同一抽象层级的操作组成，每个步骤都应表示为一行方法调用。</p>
<p>这些讨论最终都指向同一个结论：<strong>一个函数只能在一个抽象层级上叙事</strong>。仅仅遵守这一点，代码可读性就会发生显著变化。</p>
<p>那么，抽象的方向应该如何确定？该从具体事物开始，还是从抽象事物开始？</p>
<h2 id="不要提取共同点而要思考部件的组合"><a class="anchor" href="#不要提取共同点而要思考部件的组合">不要提取共同点，而要思考部件的组合</a></h2>
<p>OOP 中有一条常见指导：“提取具体事物的共同点，以定义抽象事物。”这种做法本身没有错，但我认为，如果过度拘泥于此，就有可能设计出被当前需求束缚的结构。</p>
<p>举个例子。假设需求中有 A、B、C 三个按钮，它们都是蓝色圆角样式，唯一差别只是标签文字。只提取共同点来设计，可以写成下面这样。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> BlueRoundButton</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">label</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">label</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"blue round"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{label}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>当前需求得到了完美满足。然而几天后，产品经理说：</p>
<blockquote>
<p>“请让 B 按钮的颜色可以修改。”</p>
</blockquote>
<p>这一刻，<code>BlueRoundButton</code> 这个名字就开始显得别扭。确实可以添加颜色 prop，但从“蓝色圆角按钮”这一具体共同点出发的设计，本来就很容易受需求变更影响。（这还算好的。现实里还会不断冒出按钮形状、按钮尺寸等无数需求。）</p>
<p>这类情况反复发生后，我们自然会意识到：<strong>从具体需求中提取共同点的方式，往往会让抽象后的产物也只反映当前需求</strong>。</p>
<p>所以我更偏好相反的方向：不是从具体事物中提取抽象，而是<strong>先思考抽象部件，再把它们组装成具体事物</strong>。</p>
<p>假设要制作一个 Toast 通知组件。最初的需求很简单：“保存完成后，请在底部显示一条简短提示。”如果按照提取共同点的方式处理，会变成这样。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 현재 요구사항의 공통 특성에서 출발한 설계</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ToastProps</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  message</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  hasAction</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  actionLabel</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  onAction</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> void</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Toast</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">hasAction</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">actionLabel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">onAction</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ToastProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"toast"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">span</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{message}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">span</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    {hasAction </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{onAction}>{actionLabel}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>当前需求被完美覆盖。可几天后，产品经理提出：“请根据成功或警告状态，在左侧放置图标。”于是增加了 <code>hasIcon</code>、<code>iconName</code>。紧接着又来了“还需要带上传进度条的 Toast”。于是再加一个 <code>progress</code> prop。这个过程重复几次后，<code>Toast</code> 会拥有十多个 props，开发者还必须记住<strong>这种组合可用、那种组合不可用</strong>之类的隐藏规则。（而且这些规则通常连注释都不会留下。）</p>
<p>归根结底，从“<strong>Toast 就应该长这样”这一当下的具体形象</strong>出发的设计，很难应对变化。</p>
<p>如果改用部件组合的方式，情况就不同了。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 부품을 조립하는 설계</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Toast</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PropsWithChildren</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"toast"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{children}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Toast.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Icon</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "check"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "warn"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "info"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Toast.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Message</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PropsWithChildren</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">span</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{children}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">span</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Toast.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Action</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  children,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  onClick,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PropsWithChildren</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;{ </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> void</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }>) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{onClick}>{children}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Toast.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Progress</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">value</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 필요한 부품만 골라 조립한다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>저장되었습니다&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Icon</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"check"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>업로드 완료&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Action</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{undo}>실행 취소&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Action</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Progress</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.4</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>파일 전송 중...&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span></code></pre></figure>
<p>“Toast 是一个用来容纳内容的轻量容器”这一不变本质，与“里面装什么”这一容易变化的具体内容被分离开来。现在，无需触碰 <code>Toast</code> 内部，就可以任意添加新部件，或把现有部件排列成新的组合。prop 组合的有效性规则也消失了，只需<strong>放入想放的内容</strong>即可。</p>
<p>当然，经验丰富的开发者可能会说：“一开始就用 IoC（控制反转）来设计不就行了吗？”确实如此。但之所以能够做出这样的判断，是因为过去经历了无数次踩坑，逐渐形成了对“哪里容易发生变化”的直觉。</p>
<p>如果这份直觉尚不充分，从“这个功能由哪些部件组成，各个部件应该如何组合”这个问题出发，会更容易做出对变化开放的设计。</p>
<p>读到这里，自然会产生一个问题：那么，该依据什么标准划分部件，又该如何对外表达它们呢？</p>
<h2 id="做好抽象的三个要点"><a class="anchor" href="#做好抽象的三个要点">做好抽象的三个要点</a></h2>
<h3 id="思考如何表达"><a class="anchor" href="#思考如何表达">思考如何表达</a></h3>
<p>抽象模块最重要的品质，是无需查看源代码也能推断其行为。Kent Beck 把它称为“<strong>揭示意图的命名（Intention-Revealing Name）”模式</strong>，并指出，如果无法取出简洁的名字，就应该重新审视抽象本身。</p>
<p>我们为此可以使用的工具主要有两个：<strong>名称</strong>与<strong>类型</strong>。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 도대체 뭘 하는 건지 알 수 없는 함수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> calculate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">price</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">rate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 이름과 타입만으로 동작을 유추할 수 있는 함수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> calculateDiscountedPrice</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">originalPrice</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">discountRate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>只看 <code>calculateDiscountedPrice</code> 这个名字，就能知道它接收原价与折扣率，并计算折后价格；接收 <code>number</code> 并返回 <code>number</code> 的类型信息进一步支撑了这种理解。至于内部采用了什么计算逻辑，我们不必知道。</p>
<p>相反，<code>calculate(price: number, rate: number): number</code> 没有说明计算对象，因此无法预期结果。最终只能查看源代码后才能使用，抽象的优势也就丧失了。</p>
<p>这里值得注意的是，命名方式本身就反映了抽象层级。编程中的函数名大多采用<strong>动词 + 名词</strong>的组合，而选择什么动词，会显露出函数处于哪个抽象层级。</p>
<blockquote>
<p>不过，抽象层级并非由动词单独决定。与它搭配的名词（领域上下文）才决定最终层级。</p>
</blockquote>
<p>在抽象层级<strong>较低</strong>的一侧，常用的动词包括 <code>parse</code>、<code>encode</code>、<code>decode</code>、<code>serialize</code>、<code>read</code>、<code>write</code>、<code>push</code>、<code>pop</code>、<code>convert</code>、<code>transform</code>。这些词暗示数据的物理转换或对数据结构的直接操作。</p>
<p>中间层级常出现 <code>get</code>、<code>save</code>、<code>load</code>、<code>validate</code> 等动词。它们仍属于技术操作，但已经在一定程度上揭示了行为意图。</p>
<p>在抽象层级<strong>较高</strong>的一侧，则会使用 <code>register</code>、<code>refund</code>、<code>confirm</code>、<code>cancel</code>、<code>submit</code> 等动词。这些词属于业务领域语言，完全不体现内部发生了什么技术步骤，只表达<strong>用户行为或业务流程</strong>。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 낮은 수준: 기술적 동작이 드러남</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> parseJSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">text</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> object</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> encodeBase64</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Uint8Array</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 중간 수준: 의도가 드러나되 기술적 맥락이 남아있음</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getUserById</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">User</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> validateEmail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">email</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 높은 수준: 비즈니스 의도만 드러남</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> registerUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">form</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RegistrationForm</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">User</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> refundPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">orderId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OrderId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Money</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Refund</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span></code></pre></figure>
<p>Robert C. Martin 在 <strong>Clean Code</strong> 中对此说过：“<strong>长而具有描述性的名字，胜过短而晦涩的名字。</strong>”他还提出了“<strong>每个概念只使用一个词</strong>”的原则。假如在同一上下文的操作中混用 <code>fetch</code>、<code>retrieve</code>、<code>get</code>，读者就会困惑：“这三者是不同操作吗？”</p>
<p>这一原则同样适用于 React 组件与 hook 的命名。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />           </span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SearchInput</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />      </span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SubmitOrderButton</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span></code></pre></figure>
<p>组件名称的具体程度会随抽象层级而变化。Button 在较低层级作为通用 UI 基础组件使用，而 SubmitOrderButton 则在较高层级清楚揭示了业务意图。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> handleSubmit</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FormData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> registerUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(data);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Form</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onSubmit</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{handleSubmit} />           </span></span></code></pre></figure>
<p><code>on*</code> 是组件对外暴露的 prop 名称，使用组件的一方通过它声明“要响应哪种事件”。<code>handle*</code> 则是实际传递给该 prop 的实现函数名。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useAuth</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();                  </span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">items</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setItems</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useCartItems</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">toggle</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useModal</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();   </span></span></code></pre></figure>
<p>自定义 hook 使用 <code>use</code> 前缀，以遵循 React 的规则，并让组件可以使用 hook 提供的状态或行为。</p>
<blockquote>
<p>运营 <a href="https://blog.codinghorror.com/" target="_blank" rel="noopener noreferrer">Coding Horror</a> 的 Jeff Atwood 曾指出 <code>Manager</code> 这一后缀的问题。<code>UrlManager</code> 这个名字完全看不出它是在池化、验证还是生成 URL。<code>UrlBuilder</code>、<code>UrlValidator</code>、<code>UrlPool</code> 这类揭示具体职责的名称要好得多。名称模糊，可能正说明模块本身的职责就很模糊。</p>
</blockquote>
<p>归根结底，好的名称应当<strong>让读者立刻知道这段代码处于哪个抽象层级</strong>。</p>
<h3 id="有意识地设计输入自由度"><a class="anchor" href="#有意识地设计输入自由度">有意识地设计输入自由度</a></h3>
<p>设计抽象模块时，经常会遇到一个问题：“功能要开放到什么程度？”这个决定会极大影响使用模块的开发者体验。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 기능이 닫힌 컴포넌트</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Button</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> React</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">ReactNode</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{children}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 기능이 완전히 열린 컴포넌트</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Button</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">props</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ComponentProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"button"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">props} />;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>第一个按钮只能接收 <code>children</code>，无法设置 <code>onClick</code>、<code>type</code> 或 <code>disabled</code>。但使用者不必做任何选择。</p>
<p>第二个按钮可以接收 <code>button</code> 元素的所有属性，自由度很高，但使用者必须从几十个 prop 中思考该用哪一个。我把这种情况称为“<strong>组件强迫开发者思考</strong>”。</p>
<p>这没有标准答案，只需根据模块的目的与用户找到适当程度。若是设计系统的基础按钮，限制 Props 以保持一致性也许更好；若是通用工具组件，灵活开放或许更合适。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 지나치게 닫힌 인터페이스 — 다양한 상황에 대응 불가</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{handleSubmit}>제출&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// onClick 외의 이벤트, className, disabled 등을 전달할 방법이 없다</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 지나치게 열린 인터페이스 — 의도가 사라짐</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">anyProps} /></span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 무엇을 전달해야 하는지 사용자가 직접 파악해야 한다</span></span></code></pre></figure>
<p>抽象的开放范围应由用户是谁来决定。对于需要理解内部实现并精细控制的用户，低层级接口更合适。反之，如果向不需要了解细节的用户提供过度开放的接口，只会增加困惑；而如果过度限制需要应对多种场景的用户输入，则会直接堵死使用方式。</p>
<h3 id="将抽象维持在适当粒度"><a class="anchor" href="#将抽象维持在适当粒度">将抽象维持在适当粒度</a></h3>
<p>抽象的粒度，也就是“应该把多大范围捆绑成一个模块”，同样是一个重要问题。</p>
<p>前端中常见的反模式之一，是过度提取 Custom Hook。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 이 훅은 단 하나의 컴포넌트에서만 사용된다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useUserProfileData</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">user</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">loading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(setUser)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">finally</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, []);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { user, loading };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>如果把只在一个组件中使用的逻辑硬拆成 hook，阅读代码的人反而要在两个文件之间来回切换才能掌握上下文。抽象非但没有减少上下文，反而增加了它。</p>
<p>反过来，在一个 hook 中塞入太多内容也有问题。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 관련 없는 관심사가 하나의 훅에 뒤섞여 있다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useEverything</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> auth</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useAuth</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> theme</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useTheme</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> analytics</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useAnalytics</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> toast</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useToast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { auth, theme, analytics, toast };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>这种“God Hook”很难测试，修改其中一项，还可能影响不相关的部分。</p>
<p>判断抽象粒度是否合适的标准是：“<strong>这次拆分真的减少了代码阅读者的上下文吗</strong>？”如果拆分后上下文反而变得分散、更难把握，就说明还没到进行这次抽象的时候。</p>
<h2 id="警惕过早抽象"><a class="anchor" href="#警惕过早抽象">警惕过早抽象</a></h2>
<p>读到这里，也许还会留下一个问题：“那到底什么时候该抽象？”我的看法是：<strong>基本前提应该是，不要贸然抽象。</strong></p>
<p>只要还没出现明确的抽象信号，保持代码原样的下限，就高于先做出错误抽象、日后再把它拆开的下限。错误抽象的形成过程大致如下。</p>
<ol>
<li>在代码 A 和代码 B 中发现相似模式。</li>
<li>想着“既然有 DRY 原则，就提成公共函数吧！”于是进行抽象。</li>
<li>在代码 C 中也发现相似模式，于是使用同一个函数，并为稍有不同的行为添加一个参数。</li>
<li>随着代码 D、E 也开始使用它，条件语句与参数不断增加。</li>
<li>现在这个函数到处都在使用，却变成了谁也不敢修改的代码。</li>
</ol>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 처음에는 단순했던 함수가...</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatUserName</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> User</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">firstName</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">} ${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">lastName</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 요구사항이 추가될 때마다 매개변수가 늘어나고...</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatUserName</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> User</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  includeMiddleName</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  format</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "full"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "short"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "initials"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  locale</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  honorific</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (format </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "initials"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (includeMiddleName </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> user.middleName) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (honorific </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> locale </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "ko"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...끝없는 분기</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>如果陷入这种局面，解决办法很明确：把抽象后的代码重新内联到各个使用处，删除每个使用处不需要的代码，然后在清理后的状态下，等真正的共同点显现时再重新抽象。<strong>“最快的前进方式，是先退回去。”</strong></p>
<p>那么，什么时候该进行抽象？我感受到的<strong>抽象信号</strong>大致有以下几种。</p>
<ul>
<li><strong>一致性正在被破坏</strong>：明明是同一段逻辑，却在某些组件中内联，在另一些组件中拆成单独函数；相同的计算逻辑散落各处。</li>
<li><strong>内部结构被不必要地暴露给外部</strong>：调用者不得不逐一处理它根本不需要知道的实现细节。</li>
<li><strong>自身行为不断暴露</strong>：模块无法隐藏内部步骤，使用方必须原样跟随这些步骤。</li>
</ul>
<p>问题在于，发现这些信号通常并不困难，但在实践中，人们很容易忽略它们，转而专注于满足更“重要”的需求。被进度追赶或埋头实现功能时，我们会觉得“反正先跑起来，以后再整理”，但那个“以后”很少真正到来。</p>
<p>还有一点同样重要，那就是<strong>保持一致的抽象标准</strong>。如果代码库中的同类逻辑，有些地方内联，有些地方写成函数，还有些地方拆成自定义 hook，新来的代码阅读者就会困惑：“这些差异是刻意设计的吗？”无论抽象与否，团队内部的标准都应保持一致。</p>
<p>Joel Spolsky 在 2002 年提出的“<strong>抽象泄漏定律（The Law of Leaky Abstractions</strong>）”也值得结合这一上下文记住。抽象试图隐藏复杂实现，但实现细节最终仍会泄漏（leak）到外部。也就是说，设计原本声称使用者无需了解内部实现，实际使用时却可能只有了解它才能正确操作。</p>
<p>TCP 把不稳定的网络抽象成稳定连接，但网线断开时，这层抽象就会破裂。React 以声明式方式抽象 UI 更新，但为了优化重新渲染，最终还是需要理解内部行为。完美的抽象并不存在，因此做抽象时，还必须思考：<strong>“当这层抽象破裂时，用户能否应对？”</strong></p>
<p>归根结底，<strong>“抽象会节省我们的工作时间，却不会节省学习时间。”</strong></p>
<h2 id="抽象是一种内化的能力"><a class="anchor" href="#抽象是一种内化的能力">抽象是一种内化的能力</a></h2>
<p>我曾和同事讨论过抽象，当时有一个观点令我印象深刻：发现抽象信号，并在恰当时机以恰当层级进行拆分，归根结底<strong>靠的是直觉</strong>。</p>
<p>当然，前面提到的原则——统一抽象层级、起好名字、设计输入自由度——无疑都很重要。但真正开始写代码时，如果逐条想起这些原则，再逐一斟酌“要不要把这个拆出去”，反而可能打断节奏。就像比赛中出刺拳时，如果刻意想着肘部角度，反而会错过时机；写代码时，抽象也应该源于自然直觉，而非每次都有意识地判断。</p>
<p>一路写代码时，有时会突然产生排斥感：“这段逻辑好像不该放在这里。”“这个组件好像知道得太多了。”这种感觉就是抽象信号。能够自然地发现并回应它，就是所谓的内化。</p>
<p>不过，这份直觉并非一朝一夕就能形成。只有学习大量模式、阅读各种代码，并亲自踩过坑，才会自然涌现“<strong>这个似乎应该拆分</strong>”的感觉。日后同事问“为什么把它拆开？”时，如果能够自然回答“因为它属于 X，所以才拆分”，就说明它已经内化了。</p>
<p>任何领域似乎都一样。试图靠背诵来做好一件事，反而会让判断变得困难。最终应该只把握大方向，让细节自然补足。而这种自然，终究来自平时积累的各种模式与经验。</p>
<h2 id="结语"><a class="anchor" href="#结语">结语</a></h2>
<p>编程中的抽象，是隐藏复杂事物、让其看起来简单，并让代码阅读者只关注必要上下文的行为。</p>
<p>最后，再回顾一下做好抽象需要记住的要点。</p>
<ul>
<li>基本前提是不要过早抽象，等明确的信号出现后再拆分。</li>
<li>一个函数只在<strong>一个抽象层级</strong>上叙事。</li>
<li>通过名称与类型充分<strong>表达</strong>行为，让人无需查看源代码即可使用。</li>
<li>根据模块目的与用户，<strong>有意识地设计</strong>输入自由度。</li>
<li>与其在错误抽象上继续叠加，不如拥有<strong>拆开并重新开始的勇气</strong>。</li>
<li>还要<strong>内化各种模式</strong>，让这一切无需刻意思考也能自然发生。</li>
</ul>
<p>当然，我在本文中的观点并非标准答案。适当的抽象层级会因业务情况、团队构成与项目性质而异。但有一点始终不变：抽象的最终目的，是<strong>写出便于人理解的代码</strong>。</p>
<p>也希望读到这篇文章的各位，能在自己的代码库中问一句：“这层抽象真的减少了上下文吗？”仅仅这一个问题，或许就会稍微改变你看待代码的方式。</p>
<h2 id="参考资料"><a class="anchor" href="#参考资料">参考资料</a></h2>
<p>本文从多份官方文档与既有文章中获得了许多启发。下面一并列出直接引用内容的出处，以及帮助我建立思考框架的文章。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://evan-moon.github.io/2023/01/15/what-is-abstract/" target="_blank" rel="noopener noreferrer">Evan Moon, 抽象，以及抽象化</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/learn/reusing-logic-with-custom-hooks" target="_blank" rel="noopener noreferrer">React, 使用自定义 Hook 复用逻辑</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>前端</category>
            <category>设计</category>
            <category>抽象</category>
        </item>
        <item>
            <title><![CDATA[queryKey]]></title>
            <link>https://hooninedev.com/zh-CN/260104</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/260104</guid>
            <pubDate>Sun, 04 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊一聊 TanStack Query 的 queryKey。 本文写给正在使用 TanStack Query、纠结于把 queryKey 保留为内联数组、集中到常量对象或 query key factory，还是迁移到 v5 queryOptions 的前端开发者。读完之后，你会知道每种管理方式是为了解决什么问题而出现的，并获得判断当前代码库适合哪个阶段的标准。 我在实际项目中使用 Ta...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊一聊 <strong>TanStack Query 的 queryKey</strong>。</p>
<p>本文写给正在使用 TanStack Query、纠结于把 queryKey 保留为内联数组、集中到常量对象或 query key factory，还是迁移到 v5 <code>queryOptions</code> 的前端开发者。读完之后，你会知道每种管理方式是为了解决什么问题而出现的，并获得判断当前代码库适合哪个阶段的标准。</p>
<p>我在实际项目中使用 TanStack Query 时，曾多次彻底重构 <strong>queryKey 的管理方式</strong>。最初，我只是在组件里内联写上 <code>['user', userId]</code> 这样的数组；后来每次做缓存失效都要在不同地方重复写相同的键，开始频繁出现拼写错误，于是又把它们迁移到 <code>QUERY_KEYS</code> 这样的常量对象中。再后来读了 TkDodo 的文章，转向 query key factory 模式；过了很久，又引入了 <code>@lukemorales/query-key-factory</code>；等到 v5 发布，又用 <code>queryOptions</code> 重构了一遍。</p>
<h2 id="没有-querykey-的时代"><a class="anchor" href="#没有-querykey-的时代">没有 queryKey 的时代</a></h2>
<p>在进入正题之前，先回顾一件事。如今我们使用 <code>TanStack Query</code>、<code>SWR</code> 之类的库已经习以为常，但在这些库出现之前，异步数据是怎么处理的？</p>
<p>最常见的方式，大概是把 <code>useState</code>、<code>useEffect</code>、<code>fetch</code>、<code>axios</code> 等组合起来。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> UserProfile</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">user</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">User</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">loading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> cancelled </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`/api/users/${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">userId</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">res</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> res.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">())</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">cancelled) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(data);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      })</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">catch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">err</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">cancelled) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(err);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      })</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">finally</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">cancelled) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      cancelled </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, [userId]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>这段代码的问题很明显：页面上只要有两个查看同一 <code>userId</code> 的组件，<strong>相同的请求就会发送两次。</strong> 原因是没有缓存。用户跳转到其他页面后再回来，也会从头重新获取数据。我们无法判断数据是 1 秒前还是 1 小时前获取的，因此也很难模拟“先展示缓存值，再在后台更新”这样的行为。（当然可以自己引入缓存系统来实现，但我认为它的维护成本相当高。）</p>
<p>为了解决这个问题，后来出现了 Redux + redux-thunk（或 redux-saga）的组合。把数据获取逻辑抽到 thunk 中，再把结果存进状态仓库，其他组件就能复用相同的数据。但每次都要定义 action 类型、编写 reducer，并手动管理加载、成功和失败状态。仅仅获取一份数据，就要写大量样板代码。（我正是在这个时期进入职场的，当时一直困惑：“为什么取一份数据要新建这么多个文件？”）</p>
<p>上述流程的本质归根结底是：<strong>“只有能够识别这个请求究竟是什么请求，才能避免重复发送相同请求。”</strong> 而标识“这是什么请求”的东西，正是 queryKey。</p>
<p>SWR 和 React Query（现 TanStack Query）正面解决了这个问题：“异步请求必须有标识符，相同标识符共享缓存。”就凭这一条简单原则，前面所有的样板代码都消失了。</p>
<h2 id="querykey-的本质"><a class="anchor" href="#querykey-的本质">queryKey 的本质</a></h2>
<p>那么，queryKey 究竟是什么？TanStack Query 官方文档是这样定义的。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>从根本上说，TanStack Query 根据 query key 管理查询缓存。query key 的顶层必须是数组……只要 query key 可序列化，并且<strong>对查询数据而言是唯一的</strong>，就可以使用。</p></div><div class="quote-original" lang="en"><p>At its core, TanStack Query manages query caching for you based on query keys. Query keys have to be an Array at the top level... As long as the query key is serializable, and <strong>unique to the query's data</strong>, you can use it.</p></div></blockquote>
<p>核心有两点：<strong>必须可序列化，并且必须对该数据唯一。</strong> 相同的键代表相同的数据，不同的数据必须拥有不同的键。这条简单规则决定了整个缓存系统的行为。</p>
<p>还有一点同样重要：<strong>queryKey 同时也扮演依赖数组的角色。</strong> 就像 React 的 <code>useEffect</code> 会在依赖变化时重新执行副作用一样，queryKey 变化后，TanStack Query 会自动获取新数据。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>当 <code>userId</code> 为 <code>'A'</code> 和 <code>'B'</code> 时，queryKey 彼此不同。不同就意味着缓存未命中，进而触发数据获取，而且这一切都是自动的。得益于这种简洁性，我们无需亲自编写“userId 变了，所以要重新获取”的逻辑。</p>
<p>那么，TanStack Query 如何把每次渲染都新建的数组认作同一个键？它不比较数组的引用，而是把用 <code>hashKey</code> 序列化 queryKey 得到的字符串（<code>queryHash</code>）作为缓存的键。这个函数在用 <code>JSON.stringify</code> 序列化的同时对普通对象的键进行排序，因此对象内部的键顺序不会影响哈希，而数组元素的顺序会影响。值为 <code>undefined</code> 的属性会在序列化过程中消失，所以 <code>{ a: 1, b: undefined }</code> 和 <code>{ a: 1 }</code> 会成为同一个键。结合实现代码梳理这一行为的内容，另外整理在 <a href="/251230" target="_blank" rel="noopener noreferrer">queryKey 比较原理</a> 中。</p>
<h2 id="querykey-编写规则"><a class="anchor" href="#querykey-编写规则">queryKey 编写规则</a></h2>
<p>理解了 queryKey 既是缓存的标识符又是依赖数组之后，编写规则也就顺理成章了。官方文档推荐的规则可以归纳如下。</p>
<h3 id="始终用数组"><a class="anchor" href="#始终用数组">始终用数组</a></h3>
<p>传入字符串也能工作（内部会转换成数组），但为了保持一致，最好从一开始就使用数组。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 비권장</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, queryFn });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 권장</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], queryFn });</span></span></code></pre></figure>
<h3 id="放入-queryfn-用到的所有变量"><a class="anchor" href="#放入-queryfn-用到的所有变量">放入 queryFn 用到的所有变量</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 잘못된 예: userId가 쿼리키에 없다</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 올바른 예</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>思路和 <code>useEffect</code> 的依赖完全一样：函数内部使用的所有变量，都必须进入键（也就是依赖）中。违反这条规则，就可能出现用户已经切换，界面上却仍显示前一个用户数据的隐蔽缺陷。</p>
<h3 id="从-generic-到-specific-排列"><a class="anchor" href="#从-generic-到-specific-排列">从 generic 到 specific 排列</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 좋다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { filter: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'detail'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, todoId]</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 안 좋다 (순서가 뒤집혀 있음)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[{ filter: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]</span></span></code></pre></figure>
<p>这种顺序之所以重要，是因为<strong>缓存失效（invalidation）</strong>。TanStack Query 的 <code>invalidateQueries</code> 默认采用 <strong>prefix matching</strong>。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 모든 todos 관련 쿼리 무효화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// → ['todos', 'list', ...], ['todos', 'detail', ...] 모두 매치된다</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// list 쿼리만 무효화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// → ['todos', 'list', ...]만 매치된다</span></span></code></pre></figure>
<p>把键设计成树状结构后，从“重新获取这个领域的所有数据”到“只重新获取这个确切条目”，都可以用一行代码表达。（初看时可能觉得没什么特别，但只要经历过一次键设计不当、失效范围与预期不符，就会切身体会到它的价值。）</p>
<h2 id="querykey-管理方式的演进"><a class="anchor" href="#querykey-管理方式的演进">queryKey 管理方式的演进</a></h2>
<p>到这里，我们讨论了 queryKey 的工作原理和使用方法。接下来正式进入另一个问题：<strong>queryKey 的管理方式是怎样一步步变化的？</strong></p>
<p>下面按时间顺序整理我在实际项目中经历过的阶段。</p>
<h3 id="内联数组"><a class="anchor" href="#内联数组">内联数组</a></h3>
<p>这是最简单的形式：在组件内部组合固定字符串和 props 值。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> UserProfile</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PostList</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filter</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PostFilter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'posts'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, filter],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchPosts</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filter),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>项目刚起步时，这样已经足够。</p>
<p>问题会随着代码库扩大而出现。在修改用户信息的 mutation 中需要做缓存失效时，每次都要搜索“用户相关的 query key 到底是什么来着？”有的地方写成 <code>['user', userId]</code>，另一些地方却写成 <code>['users', userId]</code>（复数）。它们是完全不同的缓存槽，因此失效只会作用于其中一边。</p>
<h3 id="常量对象"><a class="anchor" href="#常量对象">常量对象</a></h3>
<p>为了避免拼写错误，把 query key 集中到常量中。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// queryKeys.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> QUERY_KEYS</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  USER: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  POSTS: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'posts'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  COMMENTS: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'comments'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 사용처</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QUERY_KEYS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">USER</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>拼写错误消失了，但组装键的责任依然落在使用方。有人把它写成 <code>[QUERY_KEYS.USER, userId]</code>，有人写成 <code>[QUERY_KEYS.USER, userId, 'detail']</code>，还有人写成 <code>['user', 'detail', userId]</code>。到了这个阶段，还得额外记住究竟哪一种才符合约定。</p>
<h3 id="query-key-factory"><a class="anchor" href="#query-key-factory">Query Key Factory</a></h3>
<p>这一模式在 TkDodo 的 <a href="https://tkdodo.eu/blog/effective-react-query-keys" target="_blank" rel="noopener noreferrer">Effective React Query Keys</a> 一文中得到了具体化：为每个领域定义一个创建键的对象，再用函数表达层级结构。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// features/todos/queries.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> todoKeys</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  all: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoKeys.all, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  list</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filters</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoKeys.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), { filters }] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  details</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoKeys.all, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'detail'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoKeys.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">details</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), id] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 사용</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoKeys.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">), queryFn: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoKeys.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">list</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">), queryFn: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 무효화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoKeys.all });        </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 전체</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoKeys.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() });    </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 모든 리스트</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoKeys.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) });  </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 특정 항목</span></span></code></pre></figure>
<p>这个模式的强大之处在于，<strong>层级结构会明确地体现在代码中</strong>。<code>todoKeys.all</code> 指向所有 todos 相关查询，<code>todoKeys.lists()</code> 指向所有列表型查询，<code>todoKeys.detail(1)</code> 则指向某个具体条目。只用一行代码，就能准确表达失效范围。</p>
<p>另一个优点是 <strong>co-location（共置）</strong>。TkDodo 不建议把键全部集中到一个全局文件中，而是建议在功能目录内放置 <code>queries.ts</code>，将键和 hook 一起放在其中。</p>
<pre><code>src/
└── features/
    └── todos/
        ├── index.tsx
        └── queries.ts   # 키와 훅을 모두 여기에
</code></pre>
<p>这样一来，就形成了一个简单的心智模型：“要修改 todos，只看 todos 文件夹就够了。”这正是“把共同变化的内容放在一起”这一原则的忠实实现。</p>
<h3 id="lukemoralesquery-key-factory"><a class="anchor" href="#lukemoralesquery-key-factory">@lukemorales/query-key-factory</a></h3>
<p>如果每次都手写第三种模式，样板代码会逐渐堆积。而当我们想合并管理多个领域的键时，也会需要一个标准化接口。<a href="https://github.com/lukemorales/query-key-factory" target="_blank" rel="noopener noreferrer">@lukemorales/query-key-factory</a> 正是把这一模式库化后的产物。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { createQueryKeys, mergeQueryKeys } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@lukemorales/query-key-factory'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> users</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'users'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  list</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filters</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> UserFilters</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [{ filters }],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getUsers</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filters),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> todos</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [id],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getTodo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> queries</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> mergeQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(users, todos);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 사용</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queries.users.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queries.todos.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 무효화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queries.users._def);            </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 모든 user 쿼리</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queries.users.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));   </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 특정 항목</span></span></code></pre></figure>
<p><code>createQueryKeys</code> 会自动添加前缀，<code>mergeQueryKeys</code> 可以合并多个领域。还可以通过约定好的 <code>_def</code> 属性访问整个领域的键。手写 factory 时每次都要加 <code>as const</code>、手动收窄类型的工作也随之消失。</p>
<p>这个库一度几乎被当作事实标准使用。（我自己也用了很长时间。）但 queryOptions 出现后，情况发生了变化。</p>
<h3 id="queryoptionsv5-官方"><a class="anchor" href="#queryoptionsv5-官方">queryOptions（v5 官方）</a></h3>
<p>TanStack Query v5 最重要的变化之一，就是引入 <code>queryOptions</code> API。从 v4 升级到 v5 后，所有 hook 的参数统一成了单个对象，而这一变化真正的目的，是让这个对象可以被抽取为<strong>可复用单元</strong>。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { queryOptions } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/react-query'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> userDetailOptions</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    staleTime: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">5</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 60</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 어디서나 사용 가능</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">userDetailOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useSuspenseQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">userDetailOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">prefetchQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">userDetailOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">userDetailOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).queryKey, newUser);</span></span></code></pre></figure>
<p>第一眼看到它时，可能会想：“这有什么不同？不就是把对象包进一个函数吗？”TkDodo 在 <a href="https://tkdodo.eu/blog/the-query-options-api" target="_blank" rel="noopener noreferrer">The Query Options API</a> 一文中也承认了这一点：在运行时，它确实只是把收到的对象原样返回。</p>
<p>真正高效的工作发生在<strong>类型系统内部</strong>。接下来继续展开。</p>
<h2 id="queryoptions-的-datatag"><a class="anchor" href="#queryoptions-的-datatag">queryOptions 的 DataTag</a></h2>
<p><code>queryOptions</code> 不只是一个普通辅助函数，因为它会<strong>把数据类型信息嵌入返回的 queryKey 中。</strong> 在 TanStack Query 内部，这套机制叫作 <code>DataTag</code>。</p>
<p>大致实现如下。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">declare</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> dataTagSymbol</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> unique</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> symbol</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">declare</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> dataTagErrorSymbol</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> unique</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> symbol</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> DataTag</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TValue</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TError</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> unknown</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  [dataTagSymbol]</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TValue</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  [dataTagErrorSymbol]</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>这是一个使用 <code>unique symbol</code> 的 <strong>branded type</strong>。它在运行时不会产生任何影响，只是一个标记；但对 TypeScript 来说，它携带着这样一条信息：“这个数组不是普通数组，而是与 <code>TValue</code> 类型数据相连的数组。”</p>
<p>这里使用 <code>unique symbol</code> 是有原因的。zenn 的 <a href="https://zenn.dev/tsuboi/articles/tanstack-query-options-unique-symbol?locale=en" target="_blank" rel="noopener noreferrer">Uncovering the unique symbol Behind DataTag</a> 一文把它比作“类型信息的专属停车位”。如果使用普通字符串键，就可能与其他库或用户代码中的键冲突；而<strong>每个 <code>unique symbol</code> 声明本身都会创建独一无二的类型</strong>，所以它不会与其他任何声明成为同一种类型，相当于一个绝不会冲突的标识符。</p>
<p>仅凭这一个机制，就能带来很大的差异。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// unknown</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">userDetailOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).queryKey); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// User | undefined</span></span></code></pre></figure>
<p><code>getQueryData</code> 和 <code>setQueryData</code> 虽然只接收一个 queryKey，但数据类型已经刻在 queryKey 里，因此返回类型可以自动推断。无需手动传入 generic；如果向 <code>setQueryData</code> 传入错误类型，编译器也会立即捕获。</p>
<p>当然，它也有局限。<code>getQueriesData</code> 这类一次获取多个查询的方法，返回结果是异构的元组数组，无法应用这种类型推断。另外，由于使用了 <code>unique symbol</code>，在单体仓库环境生成 <code>.d.ts</code> 时可能出现 TS4023 错误，可以通过显式导入 <code>dataTagSymbol</code> 规避。</p>
<p>梳理到这里，有一点已经十分明确：<strong>queryOptions 的类型推断完全依赖于 queryKey 和 queryFn 在同一处声明。</strong> 要把 queryFn 的返回类型刻进 queryKey，两者就必须在同一个地方定义。</p>
<p>这一点对 query key factory 的设计方向有着重要启示。上一代模式更注重把 queryKey 管理拆分成独立的抽象单元，而 v5 的建议恰好相反：<strong>重新把 queryKey 和 queryFn 绑定为一个单元。</strong> TkDodo 甚至表示，“拆开 queryKey 和 queryFn 是一个错误”。键归根结底是函数依赖的集合，两者本来就无法割裂。</p>
<h2 id="实践中的-queryoptions-组合模式"><a class="anchor" href="#实践中的-queryoptions-组合模式">实践中的 queryOptions 组合模式</a></h2>
<p>当 <code>queryOptions</code> 与 domain factory 结合时，它的真正价值才会体现出来。v5 官方文档推荐的形式如下。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { queryOptions } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/react-query'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> todoQueries</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  list</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filters</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TodoFilters</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      queryKey: [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), filters],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchTodos</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filters),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      staleTime: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">30</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  details</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'detail'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      queryKey: [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">details</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), id],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchTodo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      staleTime: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">5</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 60</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>下面逐一解释这个模式的优点。</p>
<h3 id="层级结构与类型推断"><a class="anchor" href="#层级结构与类型推断">层级结构与类型推断</a></h3>
<p><code>todoQueries.all()</code> 和 <code>todoQueries.lists()</code> 只返回数组，而 <code>todoQueries.detail(1)</code> 返回通过 <code>queryOptions</code> 创建、带有 data tag 的对象。做缓存失效时使用数组，调用查询时则使用 options 对象。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));                                </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 옵션 객체</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() }); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 배열</span></span></code></pre></figure>
<h3 id="按组件覆盖-options"><a class="anchor" href="#按组件覆盖-options">按组件覆盖 options</a></h3>
<p><code>queryOptions</code> 的结果归根结底是对象，因此可以在调用时组合一部分 options。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">title</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  ...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  select</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">todo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> todo.title,  </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 컴포넌트별로 다른 select 적용</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>这个模式尤其强大的一点是，<code>select</code> 的返回类型会被自动推断，<code>data</code> 的类型也随之收窄为 <code>string</code>。对组件来说，可以只选择自己需要的部分，同时把 domain 定义完整保留在同一处。</p>
<h3 id="越来越少的-usequery-自定义-hook"><a class="anchor" href="#越来越少的-usequery-自定义-hook">越来越少的 <code>useQuery</code> 自定义 hook</a></h3>
<p>在 v4 时代，常见模式是为每个 domain 创建自定义 hook。</p>
<p>这种方式的问题在于：<strong>一旦需要 prefetch，就必须再写一遍相同定义。</strong> <code>useTodoDetail</code> 是 hook，无法在组件外调用，因此在 router loader 或 event handler 中，还得重新写一遍 <code>queryClient.prefetchQuery({ queryKey: [...], queryFn: ... })</code>。</p>
<p>使用 <code>queryOptions</code> 后，这种重复就消失了。</p>
<p>同一份定义可以在任何地方工作。因此，TkDodo 建议“在 v5 中，与其创建 hook，不如定义 queryOptions”。hook 只在需要时作为一层薄封装，而 domain 定义即使没有 hook 也能自成一体。</p>
<h2 id="变更后的缓存失效"><a class="anchor" href="#变更后的缓存失效">变更后的缓存失效</a></h2>
<p>queryKey 的层级结构真正大放异彩的场景，是 mutation 之后的缓存失效。根据 TanStack Query 的 <a href="https://tanstack.com/query/v5/docs/framework/react/guides/query-invalidation" target="_blank" rel="noopener noreferrer">Query Invalidation</a> 文档，<code>invalidateQueries</code> 默认采用 <strong>prefix matching</strong>。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 모든 todos 관련 쿼리 (list, detail, lists 모두)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 모든 list만 (detail은 건드리지 않음)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 정확히 이 키만 (자식 키 매치 안 함)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).queryKey,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  exact: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 더 복잡한 조건은 predicate으로</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  predicate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">query</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    query.queryKey[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'todos'</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    (query.queryKey[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)?.version </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>如果键按层级设计，<strong>缓存失效的范围就会与代码语义一致。</strong> “更新全部 todos”用 <code>all()</code> 表达，“只更新列表”用 <code>lists()</code> 表达，“只更新这个条目”则用 <code>detail(id)</code> 表达。</p>
<p>如果键像 <code>['todoList']</code>、<code>['todoDetail', 1]</code> 那样扁平地散落各处，要让“整个 todos 领域”失效，就必须分别调用两行代码，或者另行创建并维护前缀常量。（每次新增领域键时，一旦忘记把它加进那个常量，就会发生缓存失效遗漏的问题。）</p>
<h2 id="在-queryfn-中重新取出-querykey"><a class="anchor" href="#在-queryfn-中重新取出-querykey">在 queryFn 中重新取出 queryKey</a></h2>
<p>最后还有一种模式值得介绍。<code>queryFn</code> 实际上会接收一个名为 <code>QueryFunctionContext</code> 的对象作为参数，其中原样包含调用时的 queryKey。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId, { include: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'profile'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">queryKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">id</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">options</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> queryKey;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id, options);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>这个模式为什么有用？根据 TkDodo 的 <a href="https://tkdodo.eu/blog/leveraging-the-query-function-context" target="_blank" rel="noopener noreferrer">Leveraging the Query Function Context</a>，它可以<strong>强制同步 queryKey 与 queryFn 的依赖关系</strong>。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> sortBy</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'name'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'users'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUsers</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ sortBy }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>这段代码存在风险，因为 queryFn 依赖外部变量；而且即便 <code>sortBy</code> 发生变化，缓存也不会更新，因为没有把这个依赖放进键。只要 <code>queryFn</code> 仍从外部闭包取值，这类失误就随时可能发生。</p>
<p>解决方案很简单：不要让 <code>queryFn</code> 依赖外部变量。<strong>如果所有依赖都从 queryKey 中取出</strong>，那么没有放进 queryKey 的变量，从一开始就无法在函数内部使用。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'users'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { sortBy }] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">queryKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: [, { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">sortBy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }] }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUsers</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ sortBy }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>采用这种写法后，出现新依赖时，如果不把它加入 queryKey，函数内部就根本无法使用。编译器会直接提示：“键里没有这个属性。”也就是说，我们把键与函数的同步从编码约定交给了<strong>类型系统</strong>。</p>
<h2 id="应当拆分到什么程度"><a class="anchor" href="#应当拆分到什么程度">应当拆分到什么程度</a></h2>
<p>读到这里，可能会有一个疑问：“那么，所有查询都应该抽成 <code>queryOptions</code> 吗？”</p>
<p>我的回答一如既往：<strong>“视情况而定。”</strong></p>
<p>需要记住的是，<strong>抽象并不总是好事</strong>。对于只使用一次的查询，如果也硬要抽进领域工厂，只会让读代码的人不得不在两个文件间来回切换。queryKey 管理模式的演进并不意味着“始终都要使用更精细的工具”，而应该理解为：<strong>“在需要时，可以选择逐级采用更复杂的方案。”</strong></p>
<h2 id="总结"><a class="anchor" href="#总结">总结</a></h2>
<p>总而言之，queryKey 是 <strong>TanStack Query 识别并缓存异步数据最根本的单元</strong>。这个小数组中浓缩了缓存槽标识符、依赖数组、缓存失效范围；到了 v5，甚至还包含数据类型信息。正因为如此多的责任都聚集在这一点上，queryKey 的编写和管理方式会直接影响整个代码库的认知负担。</p>
<p>每一个阶段，都是对当时某位开发者所遇到真实问题的回答。因此，正确的顺序不是“现在已经是 v5，所以一律只用 <code>queryOptions</code>”，而是先看：<strong>“我的代码库现在正面临哪一阶段的问题？”</strong> 对一个内联数组已经足够的项目引入 domain factory，本身就可能是过度设计。</p>
<p>也希望读到这里的各位，能抽时间检查一下自己的项目：queryKey 如何散落在整个代码库中，缓存失效以什么方式进行，以及当前结构是否与团队规模和 domain 复杂度相匹配。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/guides/query-keys" target="_blank" rel="noopener noreferrer">TanStack Query, Query Keys</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/v5/docs/framework/react/guides/query-options" target="_blank" rel="noopener noreferrer">TanStack Query, Query Options</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/v5/docs/framework/react/typescript" target="_blank" rel="noopener noreferrer">TanStack Query, TypeScript</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://tanstack.com/blog/announcing-tanstack-query-v5" target="_blank" rel="noopener noreferrer">TanStack, Announcing TanStack Query v5</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>前端</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>queryKey</category>
        </item>
        <item>
            <title><![CDATA[queryKey 比较原理]]></title>
            <link>https://hooninedev.com/zh-CN/251230</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/251230</guid>
            <pubDate>Tue, 30 Dec 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊一聊 TanStack Query 如何判断两个 queryKey 是同一个键。 本文写给曾经好奇这些问题的 TanStack Query 用户：每次渲染都会新建为数组的 queryKey，为什么不会每次都缓存未命中；对象的键顺序或 undefined 值会不会影响缓存。读完后，你会知道把两个键视为相同的规则，以及这条规则对缓存的影响。先说结论：TanStack Query 把用 ha...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊一聊 <strong>TanStack Query 如何判断两个 queryKey 是同一个键</strong>。</p>
<p>本文写给曾经好奇这些问题的 TanStack Query 用户：每次渲染都会新建为数组的 queryKey，为什么不会每次都缓存未命中；对象的键顺序或 <code>undefined</code> 值会不会影响缓存。读完后，你会知道把两个键视为相同的规则，以及这条规则对缓存的影响。先说结论：TanStack Query 把用 <code>hashKey</code> 序列化 queryKey 得到的字符串作为缓存的键，在这个过程中对象的键顺序会被忽略，而数组元素的顺序会原样保留。</p>
<p>queryKey 是 TanStack Query 管理查询缓存时所依据的数组。相同的键意味着相同的数据；当 <code>['user', userId]</code> 因为其中的 <code>userId</code> 变化而改变时，就会缓存未命中并重新 fetch。</p>
<h2 id="querycache-内部"><a class="anchor" href="#querycache-内部">QueryCache 内部</a></h2>
<p>根据 TkDodo 的 <a href="https://tkdodo.eu/blog/inside-react-query" target="_blank" rel="noopener noreferrer">Inside React Query</a>，<code>QueryCache</code> 归根结底只是<strong>保存在内存中的一个数据结构</strong>。在 v5 的<a href="https://github.com/TanStack/query/blob/%40tanstack%2Fquery-core%405.104.1/packages/query-core/src/queryCache.ts" target="_blank" rel="noopener noreferrer">官方实现</a>中，这个数据结构不是普通对象，而是 <code>Map&#x3C;string, Query></code>。字段类型是 <code>QueryStore</code>，构造函数中赋值为 <code>new Map&#x3C;string, Query>()</code>。存储和查询都以 <code>queryHash</code> 为键。键是 queryKey 的序列化形式（<code>queryHash</code>），值是 <code>Query</code> 类的实例。本文的代码和运行结果以 <code>@tanstack/query-core</code> 5.104.1 为准。</p>
<p>与旧版本使用的普通对象不同，<code>Map</code> 不会与从原型继承的键冲突。</p>
<p>每次调用 <code>useQuery</code> 时发生的事情很简单：<strong>把 queryKey 转换成哈希值，再用这个哈希值在 Map 中查找。</strong> 如果存在，就取出缓存的 <code>Query</code> 实例；如果不存在，就创建新的实例并 <code>set</code> 进去。</p>
<p>这里又自然会产生一个疑问：<strong>为什么一定要把 queryKey 序列化成字符串？</strong> 直接像 <code>Map&#x3C;QueryKey, Query></code> 那样把数组本身当作键不行吗？</p>
<p>答案藏在 JavaScript 的相等性模型里。原生 <code>Map</code> 使用<strong>引用相等</strong>（reference equality）比较键。即便内容相同，只要是内存中不同的对象，就会被视为不同的键。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> m</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">m.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'alice'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">m.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// undefined. 새로 만든 배열은 다른 참조다</span></span></code></pre></figure>
<p>而在 React 组件中，<code>useQuery({ queryKey: ['user', userId] })</code> 会在<strong>每次渲染时创建新的数组实例。</strong> 第一次和第二次渲染得到的 queryKey 数组，即使内容相同，也是内存中彼此独立的对象。如果缓存依赖引用相等，那么查看同一数据的组件每次渲染都会缓存未命中。</p>
<p>解决引用相等问题的方法很简单：<strong>把引用相等转换为结构相等（structural equality）</strong>。只根据 queryKey 的内容生成确定性的字符串，再用这个字符串作为 Map 的键。这样就恢复了我们想要的“内容相同即键相同”的语义。<code>JSON.stringify</code> 只是完成这种转换最简单的工具。</p>
<h2 id="hashkey-的键排序"><a class="anchor" href="#hashkey-的键排序">hashKey 的键排序</a></h2>
<p>生成哈希值的函数是 <code>hashKey</code>。<a href="https://github.com/TanStack/query/blob/%40tanstack%2Fquery-core%405.104.1/packages/query-core/src/utils.ts#L284-L295" target="_blank" rel="noopener noreferrer"><code>packages/query-core/src/utils.ts</code></a> 中的官方实现如下。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">queryKey</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> QueryKey</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> MutationKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">val</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    isPlainObject</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(val)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Object.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">keys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(val)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">sort</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reduce</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">result</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">key</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">            result[key] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> val[key]</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">            return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> result</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          }, {} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> val,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>它在 <code>JSON.stringify</code> 中加入 <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#the_replacer_parameter" target="_blank" rel="noopener noreferrer">replacer callback</a>，先将<strong>普通对象的键按字典序排序</strong>后再序列化。严格来说，这是 <code>sort()</code> 默认比较所用的 UTF-16 码元顺序，所以大写键会排在小写键前面。</p>
<p>这种排序之所以至关重要，是因为字符串序列化还必须满足一个更强的条件：<strong>语义相同的输入，必须始终转换为相同的字符串。</strong> 但普通的 <code>JSON.stringify</code> 会保留键的原始顺序。<code>{ a: 1, b: 2 }</code> 和 <code>{ b: 2, a: 1 }</code> 在语义上是同一个对象，却会序列化为不同的字符串，最终落入两个不同的缓存槽。这样一来，相同的数据又会被请求两次。</p>
<p>稳定避免这一问题的技术叫作 <strong>canonical form（规范形式）</strong>：强制语义相同的输入始终对应唯一的一种表示。<code>hashKey</code> 的 replacer 对普通对象的键进行排序，就是出于这个原因。无论输入顺序如何，都让输出保持一致，使语义相同的对象总是变成同一个字符串。反方向并不保证，这一点后面会讲到。</p>
<p>不对数组排序，也是同一原则的另一面。数组是一种顺序本身承载语义的数据结构，一旦排序就会丢失信息。对象的键顺序是偶然的，数组的元素顺序则是有意的。<code>hashKey</code> 对二者作了区分。维护者 TkDodo 在 <a href="https://tkdodo.eu/blog/effective-react-query-keys" target="_blank" rel="noopener noreferrer">Effective React Query Keys</a> 中建议按从最通用到最具体的顺序组织 queryKey，也是因为数组顺序承载语义。他给出的理由是失效：前半部分相同的键可以用一个 <code>['todos']</code> 一次性失效。这种比较不是由哈希完成的，而是由后面会看到的 prefix 匹配负责。</p>
<p>键排序只作用于<strong>普通对象</strong>。同一文件中的 <code>isPlainObject</code> 会检查 <code>Object.prototype.toString</code> 的结果是否为 <code>[object Object]</code>，以及原型是否为 <code>Object.prototype</code>（或 <code>null</code>），以区分<strong>纯对象字面量</strong>和<strong>类实例</strong>。因此，<code>{ foo: 1 }</code> 这样的字面量会被排序，而通过 <code>class User { ... }</code> 创建的实例不会排序，直接进入下一步。如果把类实例直接放进 queryKey，由于键不会排序，它会按字段赋值的顺序序列化，即使值相同也可能得到不同的哈希值。</p>
<p>从使用者的角度看，结果有两个。</p>
<h3 id="对象的键顺序无关紧要"><a class="anchor" href="#对象的键顺序无关紧要">对象的键顺序无关紧要</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }], queryFn });</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }], queryFn });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 두 쿼리는 같은 캐시 슬롯을 공유한다</span></span></code></pre></figure>
<p>如果没有键排序，每次使用对象字面量时都得记住键的顺序。</p>
<h3 id="数组的元素顺序很重要"><a class="anchor" href="#数组的元素顺序很重要">数组的元素顺序很重要</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status, page], queryFn });</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, page, status], queryFn });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 두 쿼리는 다른 캐시이다</span></span></code></pre></figure>
<h2 id="序列化会改变的值"><a class="anchor" href="#序列化会改变的值">序列化会改变的值</a></h2>
<p>另外还应知道，<code>undefined</code> 值会在序列化过程中消失。<code>{ a: 1, b: undefined }</code> 和 <code>{ a: 1 }</code> 会生成相同的 hash。（我以前不知道这一点，还犯过“既然显式写了 undefined，那肯定是不同的 cache！”这样的错误。）</p>
<p>在数组中则不同。数组元素中的 <code>undefined</code> 不会消失，而是变成 <code>null</code>。所以 <code>['user', undefined]</code> 和 <code>['user', null]</code> 是同一个键，而与 <code>['user']</code> 是不同的键。<code>['user', userId]</code> 中 <code>userId</code> 仍是 <code>undefined</code> 的情况就属于这一种。即使用 <code>enabled: false</code> 阻止了 fetch，缓存中依然会创建 <code>["user",null]</code> 这个槽位。我用相同的选项创建了 <code>useQuery</code> 内部使用的 <code>QueryObserver</code> 来确认这一点。</p>
<p>不只是 <code>undefined</code>。<code>hashKey</code> 建立在 <code>JSON.stringify</code> 之上，所以 JSON 无法表示的值大多会在没有任何错误的情况下变成别的值。下面的代码于 2026-10-08 在 <code>@tanstack/query-core</code> 5.104.1、Node v24.16.0 上运行，注释就是实际输出。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { hashKey, QueryClient } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/query-core'</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">undefined</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["user",null]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["user",null]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["user"]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'f'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">cb</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["f",{}]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'m'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'a'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]])])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["m",{}]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'d'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'2025-12-30T00:00:00Z'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["d","2025-12-30T00:00:00.000Z"]</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> queryClient</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> QueryClient</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'m'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'a'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]])], </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'mapA'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'m'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'b'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]])])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// mapA</span></span></code></pre></figure>
<p>用同样的方式测试其他值，结果整理如下。</p>
<table>
<thead>
<tr>
<th>放进键里的值</th>
<th>序列化结果</th>
<th>被视为同一个键的对象</th>
</tr>
</thead>
<tbody>
<tr>
<td>作为数组元素的 <code>undefined</code>、<code>NaN</code>、<code>Infinity</code>、函数</td>
<td><code>null</code></td>
<td>在该位置放入 <code>null</code> 的键</td>
</tr>
<tr>
<td>作为对象属性的 <code>undefined</code>、函数</td>
<td>属性消失</td>
<td>没有该属性的键</td>
</tr>
<tr>
<td><code>Map</code>、<code>Set</code></td>
<td><code>{}</code></td>
<td>不论内容如何，所有 <code>Map</code>、<code>Set</code> 和空对象</td>
</tr>
<tr>
<td><code>Date</code></td>
<td>ISO 字符串</td>
<td>相同的 ISO 字符串</td>
</tr>
<tr>
<td><code>BigInt</code></td>
<td>抛出 <code>TypeError</code></td>
<td>无</td>
</tr>
<tr>
<td>循环引用</td>
<td>循环只经过普通对象时抛出 <code>RangeError</code>，经过数组或类实例时抛出 <code>TypeError</code></td>
<td>无</td>
</tr>
</tbody>
</table>
<p>最危险的是 <code>Map</code> 和 <code>Set</code>。在上面的代码中，以 <code>new Map([['a', 1]])</code> 为键存入的数据，用 <code>new Map([['b', 2]])</code> 查出来了。因为没有错误，也就没有任何线索提示页面上画的是别的数据。</p>
<p>会以错误形式暴露的只有 <code>BigInt</code> 和循环引用两种。循环引用的错误取决于形成循环的是什么。循环只经过普通对象时以 <code>RangeError: Maximum call stack size exceeded</code> 结束，而像 <code>arr.push(arr)</code> 这样只要经过一个数组或类实例，就以 <code>TypeError: Converting circular structure to JSON</code> 结束。由于 replacer 对每个普通对象都返回一个新对象，<code>JSON.stringify</code> 的循环检测始终遇不到同一个对象；而数组和类实例会被 replacer 原样返回，因此会被循环检测捕获。相反，<code>Date</code> 会通过 <code>toJSON</code> 变成 ISO 字符串，所以在缓存查询中同一时刻就得到同一个键，相对安全。</p>
<p>我曾把 <code>Date</code> 直接放进键里，然后困惑了很久：“明明是同一时刻，为什么缓存会刷新？”指向同一时刻的 <code>Date</code> 即使是不同实例，也会变成相同的 ISO 字符串，因此生成相同的 hash。如果每次都得到不同的键，那么即使看起来是同一时刻，时间本身其实也不同。在渲染过程中创建 <code>new Date()</code>，每次渲染都会放入精确到毫秒的不同时间，每次都会成为新的键。</p>
<p>因此，queryKey 中最好只放字符串、数字、布尔值、<code>null</code>，以及由它们组成的数组和普通对象。</p>
<h2 id="querykeyhashfn"><a class="anchor" href="#querykeyhashfn">queryKeyHashFn</a></h2>
<p>这项约束有一个逃生口。TanStack Query 通过 <code>queryKeyHashFn</code> 选项，允许<strong>替换 hash 函数本身</strong>。内部的 <code>hashQueryKeyByOptions(queryKey, options)</code> 会判断：选项中有 <code>queryKeyHashFn</code> 就调用它，没有就调用默认的 <code>hashKey</code>。</p>
<p>替换意味着整体取代 <code>hashKey</code>。前面看到的键排序也会一起消失，所以如果需要排序，就得自己实现。这个选项有用的场景，是像 <code>BigInt</code> 这样默认序列化会抛错的值。下面的代码同样在相同环境中运行。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { QueryClient } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/query-core'</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> bigintSafeHash</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">queryKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">v</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> v </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'bigint'</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> v.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> v))</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> queryClient</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> QueryClient</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  defaultOptions: { queries: { queryKeyHashFn: bigintSafeHash } },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">})</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'order'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">9007199254740993</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'ok'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'order'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">9007199254740993</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ok</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'order'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'9007199254740993'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ok</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }], </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'A'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// undefined</span></span></code></pre></figure>
<p>第二个输出表示，<code>BigInt</code> 和装着同一个数字的字符串会变成同一个键。最后一个输出是失去键排序的结果：只有键顺序不同的对象不再被视为同一个键。</p>
<p>注册的位置也会改变结果。像上面那样通过 <code>QueryClient</code> 的 <code>defaultOptions</code> 或 <code>setQueryDefaults</code> 注册时，<code>setQueryData</code> 和 <code>getQueryData</code> 也会使用这个函数。这是因为这两个 API 在计算 hash 之前会通过 <code>defaultQueryOptions</code> 合并默认选项。反之，如果只写在 <code>useQuery</code> 调用上，命令式 API 会使用默认的 <code>hashKey</code>，同一个键会在缓存中分成两个槽位。在 v3.2.0 的 beta 阶段，连全局默认值都不会应用到 <code>setQueryData</code>，<a href="https://github.com/TanStack/query/issues/1343" target="_blank" rel="noopener noreferrer">Issue #1343</a> 的报告者确认了这一问题已在 v3.2.0-beta.30 修复。</p>
<p>因此在实践中，与其使用逃生口，不如<strong>在创建 queryKey 时就把值转换成可序列化的形式</strong>，这样要安全得多。自己写 hash 函数，就得同时照顾键排序和注册位置。</p>
<h2 id="过滤器的键比较"><a class="anchor" href="#过滤器的键比较">过滤器的键比较</a></h2>
<p>判断两个键相同的方式还有一种。到目前为止所说的“同一个键”指的是 hash 字符串相同，而 hash 只用于缓存查找和 <code>exact: true</code> 过滤器。<code>invalidateQueries</code>、<code>findAll</code> 这类过滤器默认用 <code>partialMatchKey</code> 判断，它递归比较的是原始 queryKey 的结构，而不是 hash 字符串。数组从头开始比对，对象只看过滤器一侧写出的键。下面的代码同样在相同环境中运行。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { partialMatchKey } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/query-core'</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> queryKey</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">partialMatchKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// true</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">partialMatchKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// true</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">partialMatchKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, [{ status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// false</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">partialMatchKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todo'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// false</span></span></code></pre></figure>
<p>由于这种匹配不经过 hash，替换 <code>queryKeyHashFn</code> 也不会改变它。在像前面的例子那样注册了不排序 hash 函数的 <code>QueryClient</code> 中，只有键顺序不同的对象用 <code>exact: true</code> 找不到，但用 prefix 匹配仍能找到。</p>
<p>因此，哈希相同的键在过滤器中并不保证也相同。用 <code>['user', undefined]</code> 创建的查询不会被 <code>['user', null]</code> 过滤器的 prefix 匹配命中，包含 <code>NaN</code> 的键连自身都匹配不上。反过来，如果把 <code>Date</code> 或 <code>Map</code> 放进过滤器，由于它们没有可枚举的属性可供比较，会与同一位置上的任何 <code>Date</code> 或对象匹配。</p>
<h2 id="总结"><a class="anchor" href="#总结">总结</a></h2>
<p>总而言之，TanStack Query 并不比较 queryKey 数组的引用。<code>hashKey</code> 在用 <code>JSON.stringify</code> 序列化的同时对普通对象的键进行排序，得到的字符串（<code>queryHash</code>）被用作 <code>Map</code> 的键。因此，对象的键顺序不会影响缓存，数组元素的顺序会影响，值为 <code>undefined</code> 的属性在哈希中等同于不存在。JSON 无法表示的值大多会在没有错误的情况下变成别的值，使不同的键悄无声息地变成同一个键。可以用 <code>queryKeyHashFn</code> 替换 hash 函数，但这样也会一并丢掉键排序，所以在创建键的时候就把值转换成可序列化的值会更安全。失效这类过滤器默认不经过 hash，而是从头比对 queryKey 的结构，所以不要指望哈希相同的键在过滤器中也相同。归根结底，缓存查找通过 <code>hashKey</code> 比较键的内容，过滤器比较键的前半部分，所以只要 queryKey 里只放序列化后含义不变的简单值，两种比较就都会按预期工作。</p>
<p>这个判断标准如何延伸到 queryKey 的编写与管理，也就是从内联数组经过 query key factory 走到 <code>queryOptions</code> 的过程，会在 <a href="/260104" target="_blank" rel="noopener noreferrer">queryKey</a> 中讨论。</p>
<p>希望各位读者下次往 queryKey 里放对象或 <code>Map</code> 时，也能想一想这个值会被序列化成什么样的字符串。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/guides/query-keys" target="_blank" rel="noopener noreferrer">TanStack Query, Query Keys</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/reference/classes/QueryClient" target="_blank" rel="noopener noreferrer">TanStack Query, QueryClient</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>前端</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>queryKey</category>
        </item>
        <item>
            <title><![CDATA[ErrorBoundary 放在哪里]]></title>
            <link>https://hooninedev.com/zh-CN/251203</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/251203</guid>
            <pubDate>Wed, 03 Dec 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊聊 ErrorBoundary 要放几个、放在哪里。本文写给用 React Router 和 TanStack Query 搭页面、总在纠结失败该由路由的 ErrorBoundary、react-error-boundary 还是 useQuery 的 isError 来接的前端开发者。读完之后，你会得到决定 ErrorBoundary 放在哪里的标准，以及让每个位置的 fallbac...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊 <strong><code>ErrorBoundary</code> 要放几个、放在哪里</strong>。本文写给用 React Router 和 TanStack Query 搭页面、总在纠结失败该由路由的 <code>ErrorBoundary</code>、<code>react-error-boundary</code> 还是 <code>useQuery</code> 的 <code>isError</code> 来接的前端开发者。读完之后，你会得到决定 <code>ErrorBoundary</code> 放在哪里的标准，以及让每个位置的 fallback 和重试条件对得上的方法。</p>
<p>先把结论写在前面：<code>ErrorBoundary</code> 的数量不由口味决定，而由两件事决定。<strong>是什么在抛出</strong>，以及<strong>它死掉时屏幕上必须留下什么</strong>。这篇文章从后者开始，前者在需要的地方再讲。</p>
<h2 id="可以让出的范围"><a class="anchor" href="#可以让出的范围">可以让出的范围</a></h2>
<p>问 <code>ErrorBoundary</code> 该放在哪里，人们通常想的是包住哪个组件。这个问题给不出答案。能包的组件总是有好几个，选哪一个代码都照样跑。</p>
<p>得换个问法。<strong>这个死了的话，屏幕上必须留下什么。</strong></p>
<p>这个问题在每个位置的答案都不一样。构成屏幕骨架的数据没有了，那块屏幕就立不住。名字和状态都没有，只孤零零放着一个操作按钮，没有意义。反过来，一个次要列表失败就把整屏遮住，用户会把本来能好好看到的其余部分全部丢掉。难以撤销的操作又不同，必须在按下去的那个位置告知失败这件事。</p>
<p><strong><code>ErrorBoundary</code> 做的事不是捕获失败，而是决定 fallback 被画出来的范围。</strong> fallback 就是失败时替代原来屏幕画出来的东西。包了多少就消失多少。所以 <code>ErrorBoundary</code> 的位置不由你想捕获什么决定，而由<strong>可以让出的范围</strong>决定。</p>
<p>这个范围分成四级。</p>
<table>
<thead>
<tr>
<th>层的名称</th>
<th>接收什么</th>
<th>用什么撤回</th>
</tr>
</thead>
<tbody>
<tr>
<td>路由</td>
<td>loader 抛出的东西，下面没人接住的东西</td>
<td><code>revalidate()</code></td>
</tr>
<tr>
<td>屏幕</td>
<td>渲染过程中抛出的东西</td>
<td><code>resetErrorBoundary()</code></td>
</tr>
<tr>
<td>区域</td>
<td>在它内部抛出的东西</td>
<td><code>resetErrorBoundary()</code></td>
</tr>
<tr>
<td>组件内部</td>
<td><code>useQuery</code> 的失败，mutation 的失败</td>
<td><code>refetch()</code>、toast</td>
</tr>
</tbody>
</table>
<p><picture><source type="image/webp" srcset="/content/251203/1.720w.webp 720w, /content/251203/1.1440w.webp 1440w, /content/251203/1.1800w.webp 1800w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251203/1.png" alt="嵌套的矩形：路由 ErrorBoundary 里面有屏幕 ErrorBoundary，屏幕 ErrorBoundary 里面并排放着区域 ErrorBoundary 和组件内部。左侧有箭头进来：loader 抛出的东西进入路由 ErrorBoundary，渲染过程中抛出的东西进入屏幕 ErrorBoundary，那个区域的失败进入区域 ErrorBoundary，不抛出的东西进入组件内部。从组件内部向上的箭头上打着红叉，并标注为不会往上走" style="max-width:min(720px, 100%);height:auto;" width="1800" height="1000" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>名字有四个，种类只有两种。<strong>只有路由 <code>ErrorBoundary</code> 属于路由器，其余两个是放在树里的 <code>ErrorBoundary</code>。</strong> 屏幕 <code>ErrorBoundary</code> 和区域 <code>ErrorBoundary</code> 是同一个组件，只是挂的位置不同。最下面的组件内部不是 <code>ErrorBoundary</code>，而是组件自己画的分支。</p>
<p>下面几节要看的是，这张表的每一行为什么都删不掉。</p>
<h2 id="删不掉的三层"><a class="anchor" href="#删不掉的三层">删不掉的三层</a></h2>
<p>装上 <code>react-error-boundary</code> 之后，路由器那一侧看起来像是可以删。名字一样，做的事看上去也一样。但是任何一个都不该删，理由有三个。这里数的三个是上表里的路由、屏幕、区域。组件内部不算在内，因为它不是 <code>ErrorBoundary</code>。</p>
<h3 id="在-errorboundary-之外的-loader"><a class="anchor" href="#在-errorboundary-之外的-loader">在 ErrorBoundary 之外的 loader</a></h3>
<p><code>ErrorBoundary</code> 是用 <code>getDerivedStateFromError</code> 和 <code>componentDidCatch</code> 造出来的类，所以只有<strong>在 React 树内被捕获的东西</strong>才会到它这里。loader 是在渲染开始之前、在树外运行的函数。它在那里抛出的东西不经过 React，所以包得再多也看不见。从多个位置抛出同一个错误、确认落点的过程，整理在<a href="/251117" target="_blank" rel="noopener noreferrer">错误的传播</a>里。</p>
<p>所以只要有一条路由用了 loader，<strong>路由 <code>ErrorBoundary</code> 就删不掉。</strong> 一删，那个失败就没有地方可去了。</p>
<h3 id="revalidate-解不开的缓存"><a class="anchor" href="#revalidate-解不开的缓存">revalidate 解不开的缓存</a></h3>
<p>反方向也被堵住了。路由 <code>ErrorBoundary</code> 的恢复手段是 <code>revalidate()</code>，而它只是重新跑一遍 loader，并不碰查询缓存。</p>
<p>设想一条没有 loader 的路由。屏幕里的 <code>useSuspenseQuery</code> 失败了，最后路由 <code>ErrorBoundary</code> 也确实会接住。因为路由器把路由树包在了自己的一个类 <code>RenderErrorBoundary</code> 里，而那个类同样有 <code>getDerivedStateFromError</code>。可是在那个 <code>ErrorBoundary</code> 上按重试也没用，<strong>既没有可以重跑的 loader，缓存里扎着的错误也还在。</strong> 接是接住了，却没有撤回的手段。</p>
<p><strong>接收和撤回是两件不同的事。</strong> 布置 <code>ErrorBoundary</code> 时不把这两件事一起看，就会做出一个接得住但谁也解不开的 fallback。</p>
<h3 id="收窄范围的下层-errorboundary"><a class="anchor" href="#收窄范围的下层-errorboundary">收窄范围的下层 ErrorBoundary</a></h3>
<p>第三个不是被堵住，而是丢得太多。</p>
<p>失败的时候，路由器会挑一个 <code>ErrorBoundary</code>。挑法就在源码里。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> findNearestBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">matches</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">routeId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> eligibleMatches </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> routeId </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> matches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">slice</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, matches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">findIndex</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.route.id </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> routeId) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">matches];</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> eligibleMatches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reverse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">().</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">find</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.route.hasErrorBoundary </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> matches[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>它从后往前扫匹配到的路由，挑出第一个带 <code>ErrorBoundary</code> 的路由，没有就送到最前面那个。嵌套路由的子路由没有 <code>ErrorBoundary</code> 的话，这个位置由<strong>父路由</strong>接手，父路由也没有就由根接手。</p>
<p>根接手的话，整块屏幕都会消失。失败的只是里面的一个区域，页头和导航却一起没了。给子路由挂上 <code>ErrorBoundary</code>，fallback 就只画在 <code>&#x3C;Outlet /></code> 的位置上。</p>
<p><strong>所以在下面再放一个 <code>ErrorBoundary</code> 不是重复。</strong> 它不是把同一个失败捕获两次，而是<strong>改变擦掉的范围</strong>。看上去像是三个叠在一起的布置，实际上是让不同的东西以不同的范围被接住。</p>
<h2 id="在区域-errorboundary-和组件内部之间"><a class="anchor" href="#在区域-errorboundary-和组件内部之间">在区域 ErrorBoundary 和组件内部之间</a></h2>
<p>把层分成四个之后，还剩最后一个岔口。对于一个没有它屏幕也立得住的区域，选择是<strong>把它抬到 <code>ErrorBoundary</code> 上，还是就地接住</strong>。</p>
<p>无论哪一种，丢掉的都只是那个区域，其余的都保得住。分出高下的不是丢掉的范围，而是<strong>在那个位置改画什么</strong>。</p>
<p>抬到 <code>ErrorBoundary</code> 上，代码会变少。在里面调用 <code>useSuspenseQuery</code>，那个组件里既没有 <code>isPending</code> 也没有 <code>isError</code>。等待由外面的 <code>Suspense</code> 接，失败由外面的 <code>ErrorBoundary</code> 接。组件只画有数据的情况。</p>
<p>下面的 <code>QueryAsyncBoundary</code> 是把那个 <code>Suspense</code> 和 <code>ErrorBoundary</code> 合在一起的组件。它在 <code>QueryErrorResetBoundary</code> 里放了 <code>ErrorBoundary</code>，把 <code>onReset</code> 接到 <code>reset</code> 上，再用 <code>Suspense</code> 包住里面。名字里加上 <code>Query</code> 是有意的。叫 <code>AsyncBoundary</code> 的话，读起来像是任何异步都能用，其实不是，因为里面装着 <code>QueryErrorResetBoundary</code>。出于同样的理由，我没有给 <code>pendingFallback</code> 设默认值。有了默认值，只看调用处那一行就不知道垫在下面的是什么。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">section</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>댓글&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QueryAsyncBoundary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> pendingFallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{&#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>불러오는 중&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">CommentList</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> postId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{postId} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QueryAsyncBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">section</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>代价是那个区域整块变成 fallback。如果是列表这种整块消失也没什么可留的位置，那就没有损失。</p>
<p>就地接住则相反。调用 <code>useQuery</code>，自己把四种状态（等待、失败、空结果、结果）画出来。分支变多，换来的是可以放<strong>贴合那个位置的文案和动作</strong>。只想在失败的那一行上加一个小小的重试按钮，就走这条路。抬到 <code>ErrorBoundary</code> 上，那一行的样子就由 fallback 组件来定，而那个 fallback 通常是和屏幕 <code>ErrorBoundary</code> 共用的，对一行的失败来说太重了。</p>
<p>标准可以这样整理。<strong>区域整块消失也无所谓，<code>ErrorBoundary</code> 更好；需要贴合那个位置的文案或动作，<code>useQuery</code> 更好。</strong> <code>ErrorBoundary</code> 帮你把分支从组件里拿掉，但同时也把决定画面的自由一起拿走了。</p>
<p>如果决定不抬到 <code>ErrorBoundary</code> 上，<strong>四种状态就都得处理。</strong></p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isPending</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">refetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isRefetching</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">commentsOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(postId))</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isPending) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>불러오는 중&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isError) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> role</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"alert"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>댓글을 불러오지 못했어요&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> refetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">disabled</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{isRefetching}>재시도&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (data.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>아직 댓글이 없어요&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>漏掉 <code>isError</code>，失败就会悄悄流进空状态。失败的查询的 <code>data</code> 是 <code>undefined</code>，而 <code>data == null || data.length === 0</code> 这样的检查并不区分那个 <code>undefined</code> 和空数组。屏幕上显示还没有评论，而服务器在那一刻正返回 500。</p>
<p>先把 <code>isError</code> 滤掉，下面的 <code>data</code> 就收窄成数组，<code>== null</code> 的检查也就没有了。</p>
<h2 id="每一层不同的-fallback"><a class="anchor" href="#每一层不同的-fallback">每一层不同的 fallback</a></h2>
<p>层已经放好，接下来要定每一层在屏幕上画什么。路由 <code>ErrorBoundary</code> 和放在树里的 <code>ErrorBoundary</code> 在这里又分了开来，而<strong>选择的标准不是口味，而是那一层怎样拿到错误</strong>。</p>
<h3 id="自己去读的路由-errorboundary"><a class="anchor" href="#自己去读的路由-errorboundary">自己去读的路由 ErrorBoundary</a></h3>
<p>路由 <code>ErrorBoundary</code> 不需要把错误传给 fallback。组件用 <code>useRouteError()</code> 直接读，恢复手段也自己取。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RootErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useRouteError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">revalidate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useRevalidator</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorFallback</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{error} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onRetry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{revalidate} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">retrying</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{state </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'loading'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>所以路由上只要插进组件就行。子路由的 <code>ErrorBoundary</code> 也是同样的形状，用同样的 hook。把两者分开的不是代码，而是<strong>挂在哪条路由上</strong>。挂在哪条路由上，就等于 fallback 被画出来的范围。</p>
<h3 id="往下传的-errorboundary"><a class="anchor" href="#往下传的-errorboundary">往下传的 ErrorBoundary</a></h3>
<p><code>react-error-boundary</code> 的 <code>ErrorBoundary</code> 则相反。错误和 reset 函数都在它自己手里，所以必须往 fallback 那边传。因此有三个 prop，而类型定义把这三个绑成了互斥的。</p>
<p><strong><code>fallback</code></strong> 原样接收一个做好的元素，写法是 <code>fallback={&#x3C;p role="alert">댓글을 불러오지 못했어요&#x3C;/p>}</code>。错误和 reset 函数都不给。只有在消息固定、也没有办法重试的时候才用得上。</p>
<p><strong><code>FallbackComponent</code></strong> 接收一个组件，把 <code>error</code> 和 <code>resetErrorBoundary</code> 作为 props 传过去。多个 <code>ErrorBoundary</code> 共用同一个 fallback 时很干净，但 props 的名字固定为 <code>resetErrorBoundary</code>，所以接收的一方必须知道这个名字。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CommentsFallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">resetErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FallbackProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorFallback</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{error} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onRetry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{resetErrorBoundary} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onReset</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{reset} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">FallbackComponent</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{CommentsFallback}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">CommentList</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> postId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{postId} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>代码里的 <code>reset</code> 是 TanStack Query 的 <code>QueryErrorResetBoundary</code> 传下来的函数。它解开停留在错误状态的查询，让重试真正重新请求；为什么需要它，在<a href="/251128" target="_blank" rel="noopener noreferrer">重试按钮为什么不起作用</a>中顺着源码讲。</p>
<p><code>ErrorFallback</code> 接收的是 <code>onRetry</code>，名字对不上。于是就多出一个只负责搬名字的组件。</p>
<p><strong><code>fallbackRender</code></strong> 接收一个函数，就地渲染。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  onReset</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{reset}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  fallbackRender</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">resetErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorFallback</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{error} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onRetry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{resetErrorBoundary} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>笔者选了这个。理由是<strong>可以就地把名字换掉</strong>。上面 <code>resetErrorBoundary</code> 变成了 <code>onRetry</code>。多亏如此，<code>ErrorFallback</code> 成了只认识 <code>error</code> 和 <code>onRetry</code> 的组件，恢复手段是 <code>revalidate</code> 的路由 <code>ErrorBoundary</code>，和恢复手段是 <code>resetErrorBoundary</code> 的 <code>ErrorBoundary</code>，<strong>用的是同一个 fallback。</strong></p>
<p><strong>两层的画面不会走样，这就是这个选择换来的东西。</strong> 把层分成四个，用户看到的失败画面也有变成四种的风险，而换一次名字就把它们变成了一个。</p>
<h2 id="不该挂重试的失败"><a class="anchor" href="#不该挂重试的失败">不该挂重试的失败</a></h2>
<p>在 fallback 上挂了重试按钮，也不是所有失败都能解开，因为 <code>ErrorBoundary</code> 只撤回自己的状态。查询的错误要把 <code>QueryErrorResetBoundary</code> 的 <code>reset</code> 接到 <code>onReset</code> 上才会重新请求；读服务器用 200 返回的意外值时出的渲染错误，要先在 <code>queryFn</code> 里检查、变成查询的错误才能解开；<code>React.lazy</code> 的分块加载失败只有刷新才能解开。用源码核对这三种情况的过程，另外整理在前面链接过的「重试按钮为什么不起作用」中。能恢复的失败和不能恢复的失败就这样分开了，所以按钮也得分开。</p>
<p>给所有失败都显示同一个按钮，等于<strong>在引导用户去做他做不到的事</strong>。在 404 上按重试，回来的还是同一个 404。因为没有权限而拿到的 403 也一样。分块加载失败则如上所述，压根就不起作用。</p>
<p>在共用的 fallback 里分一次就够了。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> describe</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> unknown</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isChunkLoadError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { title: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'새 버전이 배포됐어요'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'새로 고침하면 이어서 볼 수 있어요.'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, action: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'reload'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isHttpError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> error.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isRouteErrorResponse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> error.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> undefined</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 404</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { title: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'찾을 수 없어요'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'주소가 바뀌었거나 삭제된 항목이에요.'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, action: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> undefined</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 400</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 500</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { title: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'요청을 처리할 수 없어요'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'입력한 내용을 다시 확인해 주세요.'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, action: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { title: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'불러오지 못했어요'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'잠시 후 다시 시도해 주세요.'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, action: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'retry'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>isRouteErrorResponse</code> 会在这里出现是有原因的。路由 <code>ErrorBoundary</code> 和放在树里的 <code>ErrorBoundary</code> 共用 fallback，所以这个函数<strong>两种错误都要接</strong>。从路由器抛出的 <code>Response</code> 里读状态码的方法，和从自己造的 HTTP 错误里读的方法不一样，所以两个都看。</p>
<p>一开始不必做一大堆分支。只要把<strong>再按一次结果会不一样的失败和不会不一样的失败</strong>分开就够了。</p>
<h2 id="errorboundary-看到失败的时刻"><a class="anchor" href="#errorboundary-看到失败的时刻">ErrorBoundary 看到失败的时刻</a></h2>
<p><code>ErrorBoundary</code> 都放好了，还剩下一件事。<strong><code>ErrorBoundary</code> 是什么时候看到的。</strong></p>
<p>层是自上而下定的，执行的顺序却相反。重试全部耗尽之后，<code>ErrorBoundary</code> 才会看到点什么。所以重试的条件是 <strong><code>ErrorBoundary</code> 设计的一部分</strong>。</p>
<p>默认值在不同位置不一样。两边用的都是同一个 <code>createRetryer</code>，传进去的值却不同。查询什么都不传，所以拿到 <code>retryer.js</code> 里的默认值。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> retry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> config.retry </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isServer</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 3</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>mutation 则在 <code>mutation.js</code> 里直接放了 0。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">retry</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.options.retry </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span></code></pre></figure>
<p><strong>mutation 是 0，意思是请求一旦以错误结束，那个操作马上就失败。</strong> 难以撤销的操作不重试是个安全的默认值，不过如果那个操作是幂等的，多送一次对用户更好。</p>
<p>两边都把同样的条件写明白更好。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> MAX_RETRY</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 2</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> retryOnServerError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">failureCount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> unknown</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (failureCount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> MAX_RETRY</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isHttpError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> error.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 500</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>failureCount</code> 从 0 开始，<strong>第一次失败时是拿 0 来问的。</strong> 所以 <code>MAX_RETRY</code> 是 2 的话，总共送三次然后停下。这是重试的次数，不是尝试的次数。</p>
<p><strong>把 4xx 排除掉是关键。</strong> 请求本身就是错的，送多少次回来的答案都一样。但排除它的理由不只是浪费，默认的延迟还是指数增长的。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> defaultRetryDelay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">failureCount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">	return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">min</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1e3</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 2</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> **</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> failureCount, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3e4</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>第一次重试是 1 秒，下一次是 2 秒，所以只重试两次就过去了 3 秒。不把 4xx 滤掉，就等于<strong>把一个修不好的失败藏了 3 秒</strong>。</p>
<p>渲染错误和分块失败没有这段时间。不发请求就没有重试可言，抛出的那一刻 <code>ErrorBoundary</code> 就看到了。<strong>同一个 fallback 在有些失败上 3 秒后才出现、在有些失败上立刻出现，原因就在这里。</strong></p>
<p>打开重试之前有一个前提要确认。那个请求是<strong>幂等的吗。</strong> 同一个请求送两次，结果必须一样。服务器不保证这一点的话，重试就是一个制造 bug 的功能。</p>
<h2 id="结尾"><a class="anchor" href="#结尾">结尾</a></h2>
<p>这篇文章定了在每个接收失败的位置放什么。内容整理如下。</p>
<ul>
<li><code>ErrorBoundary</code> 的位置由可以让出的范围决定。问的不是要包住什么，而是这个死了之后必须留下什么。</li>
<li>三层 <code>ErrorBoundary</code> 一个也删不掉。loader 抛出的东西树里的 <code>ErrorBoundary</code> 接不到，<code>revalidate</code> 解不开查询缓存，而在下面再放一个是在收窄范围。</li>
<li>区域整块丢掉也无所谓就用 <code>ErrorBoundary</code>，需要那个位置专属的文案就用 <code>useQuery</code>。选了后者，四种状态就都得处理。</li>
<li>用 <code>fallbackRender</code> 把名字换掉，层不同 fallback 也能统一成一个。</li>
<li>重试要把抛出一方的状态解开才起作用。查询是 <code>reset</code>，渲染错误抬到 <code>queryFn</code> 里，分块是刷新。</li>
<li><code>ErrorBoundary</code> 看到失败的时刻由重试条件决定。不把 4xx 滤掉，就会把修不好的失败藏上几秒。</li>
</ul>
<p>有人会问，一个 <code>ErrorBoundary</code> 加一个 toast 不就够了吗。对只有两三屏的产品来说这话说得通，而且事实上<strong>层的数量由产品决定。</strong> 屏幕只有一张，要让出的也只有一张，范围的差别就看不出来。屏幕里独立的区域越多，那个差别就越大。</p>
<p>不过有些东西与数量无关地留了下来。树里放多少个 <code>ErrorBoundary</code> 都好，<code>loader</code> 抛出的东西不会去那里，而重试不把抛出一方的状态解开就不起作用。<strong>能减少的是层，不是这些事实。</strong></p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/reference/QueryErrorResetBoundary" target="_blank" rel="noopener noreferrer">TanStack Query, QueryErrorResetBoundary</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/guides/suspense" target="_blank" rel="noopener noreferrer">TanStack Query, Suspense</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://reactrouter.com/how-to/error-boundary" target="_blank" rel="noopener noreferrer">React Router, Error Boundaries</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/bvaughn/react-error-boundary" target="_blank" rel="noopener noreferrer">bvaughn/react-error-boundary</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://tkdodo.eu/blog/react-query-error-handling" target="_blank" rel="noopener noreferrer">TkDodo, React Query Error Handling</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://tkdodo.eu/blog/mastering-mutations-in-react-query" target="_blank" rel="noopener noreferrer">TkDodo, Mastering Mutations in React Query</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>前端</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>错误处理</category>
        </item>
        <item>
            <title><![CDATA[重试按钮为什么不起作用]]></title>
            <link>https://hooninedev.com/zh-CN/251128</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/251128</guid>
            <pubDate>Fri, 28 Nov 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊聊 ErrorBoundary 的重试按钮为什么不起作用。 本文写给在 react-error-boundary 的 fallback 上挂了重试按钮，却怎么按都回到同一个画面的前端开发者，整理了失败状态留下来的三种情况，以及每种情况该怎么解开。简单地说，ErrorBoundary 只撤回自己的状态，造成失败的状态还原样留在抛出的一方。 例子采用 TanStack Query 与 re...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊 <strong><code>ErrorBoundary</code> 的重试按钮为什么不起作用</strong>。</p>
<p>本文写给在 <code>react-error-boundary</code> 的 fallback 上挂了重试按钮，却怎么按都回到同一个画面的前端开发者，整理了失败状态留下来的三种情况，以及每种情况该怎么解开。简单地说，<code>ErrorBoundary</code> 只撤回自己的状态，造成失败的状态还原样留在抛出的一方。</p>
<p>例子采用 TanStack Query 与 <code>react-error-boundary</code> 一起使用的配置。库的行为是打开已安装的源码确认的，引用的代码与 <code>@tanstack/react-query</code> 5.104.1、<code>react-error-boundary</code> 6.1.6、React 19.2.3 的构建文件结构一致（2026-10-08 比对）。缩进和换行有几处为便于阅读做了调整。</p>
<h2 id="重试不起作用的三种情况"><a class="anchor" href="#重试不起作用的三种情况">重试不起作用的三种情况</a></h2>
<p>原因有三个，解开的位置也各不相同。共同点只有一个。<strong><code>ErrorBoundary</code> 只撤回自己的状态。</strong> 抛出的一方手里握着的状态，必须由抛出的一方来解。</p>
<h3 id="reset-能解的查询错误"><a class="anchor" href="#reset-能解的查询错误">reset 能解的查询错误</a></h3>
<p><code>resetErrorBoundary()</code> 做的事只是把 <code>ErrorBoundary</code> 的内部状态（<code>didCatch</code>）恢复原样。children 重新挂载后，查询 hook 会重新读取缓存。可是那个查询在缓存里<strong>以错误状态扎着</strong>。于是 hook 在渲染中立刻抛出同一个错误，<code>ErrorBoundary</code> 又画出 fallback。</p>
<p>为什么不重新请求而是用旧的错误，源码里也有。<code>errorBoundaryUtils.js</code> 是这样关掉重新挂载时的重新请求的。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (options.suspense </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> throwOnError) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">errorResetBoundary.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isReset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()) options.retryOnMount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>得先读外层的守卫。<strong>这个条件只作用在会抛出的查询上。</strong> 开了 <code>suspense</code> 或者开了 <code>throwOnError</code> 的查询，如果在 <code>isReset()</code> 为假时挂载，<code>retryOnMount</code> 就会变成 <code>false</code>，重新请求被关掉。不抛出的 <code>useQuery</code> 不在此列，重新挂载后就照常重新请求。</p>
<p>不过，关掉 <code>retryOnMount</code> 只对缓存里从来没有过数据的查询起作用，也就是首次加载就失败的查询。有过数据的查询失败时 <code>isInvalidated</code> 会变为真，被当作 stale 处理，重新挂载时会通过 <code>refetchOnMount</code> 重新请求。本文说的查询错误是首次加载的失败。</p>
<p>但这并不意味着重新请求会永远关着。fallback 显示期间，没有组件在看那个查询，于是它成了非活跃查询，按 <a href="https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults" target="_blank" rel="noopener noreferrer">TanStack Query 的默认值</a>，5 分钟后会从缓存中删除。在那之后再按，缓存里没有错误，就会从头重新请求。所以漏接 <code>onReset</code> 的代码，隔很久再按也会像是能用。如果复现时好时坏，先看按下的时刻是在 <code>gcTime</code> 之前还是之后。</p>
<p><picture><source type="image/webp" srcset="/content/251128/1.720w.webp 720w, /content/251128/1.1440w.webp 1440w, /content/251128/1.2080w.webp 2080w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251128/1.png" alt="上面是没有接上 onReset 时的流程，重试点击、ErrorBoundary 解除、重新挂载、再次抛出缓存里的错误依次相连，最后一格有一条红色箭头折回第一格，标注为同一个 fallback。下面是接上 onReset 时的流程，重试点击、onReset 与 reset() 调用、ErrorBoundary 解除与重新挂载、重新请求，用蓝色箭头朝一个方向依次相连" style="max-width:min(720px, 100%);height:auto;" width="2080" height="800" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p><strong><code>retryOnMount</code> 只会在抬到 <code>ErrorBoundary</code> 上的查询里被关掉，所以 <code>ErrorBoundary</code> 和查询必须一起解。</strong> 立起 <code>isReset()</code> 所读标志的是 <code>QueryErrorResetBoundary</code>。打开源码，状态只是一个布尔值。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">	isReset </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">},</span></span></code></pre></figure>
<p>如果标志只立起来却没人放下，之后的错误也不会关掉 <code>retryOnMount</code>，每次都会重新请求。负责放下的是查询的 hook。重新挂载的 hook 在渲染中读取标志，不会关掉 <code>retryOnMount</code>。看同一个标志的 <code>getHasError</code> 也不会抛出缓存里的错误。然后在挂到屏幕上之后，在 effect 里放下标志。这是同一个 <code>errorBoundaryUtils.js</code> 里的函数。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useClearResetErrorBoundary</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">errorResetBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">	React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">		errorResetBoundary.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">clearReset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">	}, [errorResetBoundary]);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>所以一个 boolean 就足以表达只重试这一次。把一次重试按顺序写出来是这样的。</p>
<ol>
<li><code>reset()</code> 立起 <code>isReset</code> 标志。</li>
<li>重新挂载的查询 hook 在渲染中看到标志，不会关掉 <code>retryOnMount</code>。</li>
<li>标志立着时，hook 看到的是请求中的状态而不是缓存里的错误，于是重新请求（<code>useSuspenseQuery</code> 会在渲染中发出请求并 suspend）。</li>
<li>请求成功、挂到屏幕上后由 effect 里的 <code>clearReset()</code> 放下标志，再次失败时由那次请求的 <code>catch</code> 放下。</li>
<li>标志放下后，之后的错误会再次关掉 <code>retryOnMount</code>。</li>
</ol>
<p>把这个 <code>reset</code> 接到 <code>ErrorBoundary</code> 的 <code>onReset</code> 上就行。TanStack Query 的文档和源码注释也把这样连接的代码放成了例子。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> QueryAsyncBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">pendingFallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Props</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QueryErrorResetBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      {({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">          onReset</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{reset}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">          fallbackRender</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">resetErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">            &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorFallback</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{error} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onRetry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{resetErrorBoundary} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          )}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        ></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{pendingFallback}>{children}&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      )}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QueryErrorResetBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>react-error-boundary</code> 的 <code>resetErrorBoundary</code> 是这样写的。这是打包后的文件，名字缩成了一个字母，但结构照样读得出来。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">resetErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">e) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">didCatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">t</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.state;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  t </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.props.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onReset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">?.({ args: e, reason: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"imperative-api"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }), </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(d));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>只有 <code>didCatch</code> 为真时，它才调用 <code>onReset</code>，并把状态恢复为初始值 <code>d</code>（<code>didCatch: false</code>）。<code>setState</code> 会在处理函数结束后才渲染，所以 children 重新挂载时，<code>reset()</code> 立起的标志已经立着了。因此只要给 <code>onReset</code> 接上 <code>reset</code> 就行。</p>
<p>即使不用 <code>QueryErrorResetBoundary</code> 包裹，从 <code>useQueryErrorResetBoundary()</code> 取出 <code>reset</code> 接到 <code>onReset</code> 上，重试也能生效。没有外层边界时，这个 hook 会返回模块级的全局默认值。代价是，正如 <a href="https://tanstack.com/query/latest/docs/framework/react/guides/suspense" target="_blank" rel="noopener noreferrer">Suspense 指南</a>所写，重置会作用于全局，整个应用共享同一个 <code>isReset</code> 标志。</p>
<h3 id="reset-解不开的渲染错误"><a class="anchor" href="#reset-解不开的渲染错误">reset 解不开的渲染错误</a></h3>
<p>第二种是服务器用 200 返回了与预期不同的形状，读它的渲染抛出 <code>TypeError</code> 的情况。同一个 <code>ErrorBoundary</code> 接住了，<code>onReset</code> 也接上了，可重试就是不起作用。</p>
<p><code>reset</code> 能解的是<strong>处于错误状态的查询</strong>。可是这个查询成功了。服务器给了 200，缓存里把那个值当作正常数据存着。出错的是读了那个值的渲染。所以 <code>reset</code> 没有可解的东西，重新挂载的组件在渲染中读到缓存里同一个值，又在同一行抛出。因为在挂到屏幕上之前就抛出了，连订阅查询的机会都没有。这就是把 <code>staleTime</code> 设为 0 也不会重新请求的原因。这个查询过了 <code>gcTime</code> 也会从缓存中删除并重新请求，但只要服务器给的是同样的值，就会在同一行再次抛出。</p>
<p>要改的位置是 <strong><code>queryFn</code></strong>。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> res</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`/api/posts/${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">postId</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}/comments`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> res.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Array.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isArray</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(data.comments)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TypeError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'comments 가 배열이 아니다'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.comments</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">},</span></span></code></pre></figure>
<p><code>fetch</code> 响应的 <code>json()</code> 返回的是 <code>Promise&#x3C;any></code>，所以之后加上的类型是声明而不是检查，确认服务器所给值的运行时检查得自己放。<strong>把那个检查抬到 <code>queryFn</code> 里</strong>，同样的失败就变成了<strong>查询的错误</strong>。它以错误状态留在缓存里，<code>reset</code> 把它解开，重试就重新请求。</p>
<p>想着反正 <code>ErrorBoundary</code> 会接住就把运行时检查往后拖，就会做出一个接得住却撤不回的 fallback。</p>
<h3 id="只有刷新能解的-lazy"><a class="anchor" href="#只有刷新能解的-lazy">只有刷新能解的 lazy</a></h3>
<p>第三种是分块加载失败。这一次 <code>reset</code> 和 <code>queryFn</code> 都不相干。握着状态的是 <code>lazy</code> 本身。</p>
<p>React 的 <code>lazyInitializer</code> 把拒绝记在 <code>payload</code> 里，之后每次都抛出同一个东西。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">throw</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> payload._result;</span></span></code></pre></figure>
<p>它不会再 <code>import()</code> 一次。<code>lazy()</code> 的调用在模块顶层只发生过一次，那个 <code>payload</code> 在应用活着的期间就一直保持原样。解开 <code>ErrorBoundary</code> 重新挂载，同样的错误还是会来。</p>
<p>那么新建一个 <code>lazy</code>，再调用一次 <code>import()</code> 行不行？到目前为止是浏览器挡住了。模块映射记住了失败的结果，不会重新获取同一个 URL。改变这一行为的 <a href="https://github.com/whatwg/html/pull/10327" target="_blank" rel="noopener noreferrer">HTML 规范变更</a>已于 2026-07-15 合并。2026-10-08 确认的各引擎状态如下。Firefox <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=2055211" target="_blank" rel="noopener noreferrer">在 155 中加入</a>，于 2026-09-01 发布。WebKit 于 2026-08-19 <a href="https://bugs.webkit.org/show_bug.cgi?id=319492" target="_blank" rel="noopener noreferrer">进入 main</a>，该 bug 记录中没有写明包含这一变更的 Safari 稳定版版本。Chrome 在 <a href="https://chromestatus.com/feature/5214647044145152" target="_blank" rel="noopener noreferrer">chromestatus</a> 上仍是 Proposed。所以在 Chrome 里，新的 <code>import()</code> 也会返回同样的失败。</p>
<p>这说的是像 Vite 那样用浏览器原生 <code>import()</code> 获取分块的构建。webpack 运行时用 script 标签获取分块，并会清除失败分块的记录，所以用新的 <code>lazy</code> 再调用一次就会重新请求。</p>
<p>无论哪种情况，已经创建的 <code>lazy</code> 都不会再试一次，所以这种失败的基本恢复方式是把页面重新取一遍。即使浏览器以后会重新获取，也不是都能解开。分块加载失败可能来自网络中断，也可能如 <a href="https://vite.dev/guide/build#load-error-handling" target="_blank" rel="noopener noreferrer">Vite 文档</a>所说，是新的部署删掉了旧分块。被删掉的分块再请求一次也还是不存在，所以那种情况下的恢复依然是刷新。既然没法断定唯一的原因，fallback 的文案与其断言新版本已发布，不如建议用户刷新。</p>
<p>总结起来，查询的错误用 <code>reset</code> 解，渲染错误在 <code>queryFn</code> 里预先变成查询的错误，分块失败用刷新解。</p>
<h2 id="结尾"><a class="anchor" href="#结尾">结尾</a></h2>
<p>如果重试按钮不起作用，希望你在看按钮或 <code>ErrorBoundary</code> 之前，先确认 <strong>是什么抛出的、那个状态留在哪里</strong>。是查询缓存、读了服务器所给值的渲染，还是 <code>lazy</code>，解开的地方各不相同。</p>
<p><code>ErrorBoundary</code> 在页面上放在哪里、放几个，以及哪些失败根本不该挂重试按钮，在<a href="/251203" target="_blank" rel="noopener noreferrer">ErrorBoundary 放在哪里</a>中讨论。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/reference/QueryErrorResetBoundary" target="_blank" rel="noopener noreferrer">TanStack Query, QueryErrorResetBoundary</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/bvaughn/react-error-boundary" target="_blank" rel="noopener noreferrer">bvaughn/react-error-boundary</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>前端</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>错误处理</category>
        </item>
        <item>
            <title><![CDATA[错误的传播]]></title>
            <link>https://hooninedev.com/zh-CN/251117</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/251117</guid>
            <pubDate>Mon, 17 Nov 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊聊前端里的错误能往上走到哪里。 画 ErrorBoundary 这件事很容易。在路由上放一个，每个页面都用 ErrorBoundary 包起来，图画完看不到空白。难的是知道那个 ErrorBoundary 实际接住了什么。 标题里的「传播」指的是抛出的错误往上走到哪个位置、由谁接住。抛出的位置和接住的位置并不总是同一个，所以要分开数。 于是我决定数一遍。把同一个 new Error('...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊<strong>前端里的错误能往上走到哪里</strong>。</p>
<p>画 <code>ErrorBoundary</code> 这件事很容易。在路由上放一个，每个页面都用 <code>ErrorBoundary</code> 包起来，图画完看不到空白。难的是知道那个 <code>ErrorBoundary</code> <strong>实际接住了什么</strong>。</p>
<p>标题里的「传播」指的是<strong>抛出的错误往上走到哪个位置、由谁接住</strong>。抛出的位置和接住的位置并不总是同一个，所以要分开数。</p>
<p>于是我决定数一遍。把同一个 <code>new Error('boom')</code> 只换位置抛七次，看它是到达 <code>ErrorBoundary</code>，还是直接越过 <code>ErrorBoundary</code> 去了别处。</p>
<p>下面的小组件就是那个实验。在左边选一个抛出的位置，虚线框里就会真的抛出那个错误。虚线框就是 <code>ErrorBoundary</code>，<code>ErrorBoundary</code> 接住的时候里面会变成 fallback。没被接住的看起来像什么都没发生，所以组件同时在 <code>window</code> 上监听 <code>error</code> 和 <code>unhandledrejection</code>，把它去了哪里写在下面。</p>
<div class="interactive-widget" data-widget="error-propagation"></div>
<p>七个里只有四个到达 <code>ErrorBoundary</code>。剩下三个明明是在 <code>ErrorBoundary</code> 内部抛出的，却照样穿过 <code>ErrorBoundary</code> 跑到了全局。</p>
<p>这篇文章按错误的种类逐个确认这个分叉是怎么产生的。编译期就结束的、渲染与生命周期里出现的、来自服务端数据的、页面切换时出现的、外部库抛出的，以及事件与异步里出现的。要查的是它们各自传播到哪里。<strong>接住的位置要分成几层、怎么恢复，这里不讨论</strong>。不了解传播路径就先画层，那些层会变成空盒子，所以顺序是这样安排的。</p>
<p>确认用了两种方式。库的行为不靠记忆，而是打开本地安装的源码来读；能靠抛出来确认的，就在上面的组件里真的跑一遍。</p>
<h2 id="errorboundary"><a class="anchor" href="#errorboundary">ErrorBoundary</a></h2>
<p>先把 <code>ErrorBoundary</code> 本身说准确。<code>ErrorBoundary</code> 就是<strong>类组件的两个生命周期方法</strong>，只是换了个名字来叫而已。</p>
<p><code>react-error-boundary</code> 里也只有两个实现。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">static </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getDerivedStateFromError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(e) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">componentDidCatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(e, t) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span></code></pre></figure>
<p>React 官方文档把两个方法运行的时机分开了。<code>getDerivedStateFromError</code> 在<strong>渲染阶段</strong>运行，生成用来画 fallback 的状态；<code>componentDidCatch</code> 在<strong>提交阶段</strong>运行，负责日志这类副作用。不管哪一个，它们只接住<strong>React 在树里接住并交过来的东西</strong>。</p>
<p>所以 <code>ErrorBoundary</code> 接住什么，定义只有一条。<strong>是不是从 React 能接住的位置抛出来的</strong>。</p>
<p>React 文档也把接不住的那一侧写下来了。下面四种就是。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>事件处理器、服务端渲染、<code>ErrorBoundary</code> 自身抛出的、异步代码(比如 <code>setTimeout</code> 或 <code>requestAnimationFrame</code> 的回调)。有一个例外，就是 <code>useTransition</code> 这个 Hook 返回的 <code>startTransition</code> 函数。在那个 transition 函数里抛出的错误，Error Boundary 会接住。</p></div><div class="quote-original" lang="en"><p>Event handlers, Server side rendering, Errors thrown in the error boundary itself (rather than its children), Asynchronous code (e.g. <code>setTimeout</code> or <code>requestAnimationFrame</code> callbacks); an exception is the usage of the <code>startTransition</code> function returned by the <code>useTransition</code> Hook. Errors thrown inside the transition function are caught by error boundaries</p></div></blockquote>
<p>上面组件的结果和这句话完全吻合。七个位置分成了两个到达点。</p>
<p><picture><source type="image/webp" srcset="/content/251117/1.720w.webp 720w, /content/251117/1.1440w.webp 1440w, /content/251117/1.1760w.webp 1760w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251117/1.png" alt="左边纵向排着七个抛出的位置，右边有两个到达点。渲染中、useEffect 里、startTransition 里、lazy 的 import 被拒绝这四个用蓝色箭头指向 ErrorBoundary，onClick 处理器里、setTimeout 回调里、Promise 被拒绝这三个用灰色箭头指向 window" style="max-width:min(720px, 100%);height:auto;" width="1760" height="940" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p><code>startTransition</code> 之所以是例外，是因为它内部的工作会经过 React 的调度器。React 亲手包住了那次执行，所以能接住并送回树里。同样的道理，<code>setTimeout</code> 接不住。那个回调运行的时候，React 已经不在那里了。</p>
<p>先要知道一件事。<strong>函数组件做不出 <code>ErrorBoundary</code></strong>。</p>
<p>React 遇到抛出的错误时，会从那个位置朝父级往上走，寻找接住它的边界。负责这次遍历的 <code>throwException</code> 看的是每个 fiber 上挂的 <code>tag</code>，而它会停下来的值只有类组件和根两个。函数组件的 <code>tag</code> 不是这两个，所以查找就这么走过去了。<strong>它当不了边界，不是因为没有 API，而是因为查找压根就不往里看</strong>。</p>
<p><picture><source type="image/webp" srcset="/content/251117/2.720w.webp 720w, /content/251117/2.1440w.webp 1440w, /content/251117/2.2016w.webp 2016w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251117/2.png" alt="横向排着四个方框。从左边起，函数组件 Child 和 FnBoundary 是 tag 0，类组件 ErrorBoundary 是 tag 1，根 HostRoot 是 tag 3。从 Child 出发的箭头经过 FnBoundary 在 ErrorBoundary 停下，继续通向 HostRoot 的线是虚线" style="max-width:min(720px, 100%);height:auto;" width="2016" height="424" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>那个 <code>tag</code> 由有没有继承 <code>React.Component</code> 决定。所以把 <code>getDerivedStateFromError</code> 当作 <code>static</code> 挂到函数上也没有用。我挂上去跑了一遍，那个函数一次都没被调用，错误找不到边界一路走到根，React 把整棵树从画面上撤掉了。</p>
<p>所以装 <code>react-error-boundary</code> 并不是在补一个不存在的功能。6.1.6 的 <code>ErrorBoundary</code> 同样是继承 <code>Component</code> 的类，原样带着那两个方法。库做的事情是把那个类只写一次然后藏起来。</p>
<h2 id="编译期就结束的东西"><a class="anchor" href="#编译期就结束的东西">编译期就结束的东西</a></h2>
<p>既然在数种类，就从最靠前的开始整理。类型错误。</p>
<p>只有它到不了用户手里。读了不存在的属性，或者参数类型对不上，构建就会卡住，卡住的代码不会发布。所以没有传播路径可追。<strong>类型错误在这篇文章里缺席，不是因为不重要，而是因为它在运行时根本不存在</strong>。</p>
<p>问题在后面。类型检查到底保证到<strong>哪里</strong>。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">async</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getComments</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">postId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Comment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> res</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`/api/posts/${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">postId</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}/comments`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> res.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.comments</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>返回类型写着 <code>Promise&#x3C;Comment[]></code>，所以调用这个函数的所有代码都相信自己拿到的是数组。可是 <code>Response</code> 的 <code>json()</code> 在 TypeScript 5.9.3 的 <code>lib.dom.d.ts</code> 里是这么声明的。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(): </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Promise</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">any</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>是 <code>any</code>。类型检查从这里断掉。服务端给的值编译器从来没看过，后面补上的类型参数或返回类型标注是<strong>声明而不是检查</strong>。没有人会替你插入在运行时核对这个声明的代码。</p>
<p>所以服务端用 <code>200</code> 返回 <code>{ commits: null }</code> 的时候，读 <code>commits.length</code> 的那一行会在渲染中抛出 <code>TypeError</code>。HTTP 是成功的，类型也通过了，画面却坏了。</p>
<p><strong>类型结束的位置，就是该放运行时检查的位置</strong>。把这个检查放在哪里，决定了同一个失败走哪条路。在渲染里读着读着炸了，它就变成渲染错误去 <code>ErrorBoundary</code>；在接收数据的位置先检查再抛，它就变成那次请求的失败。下面的<strong>渲染与生命周期</strong>一节会讲这部分。</p>
<h2 id="渲染与生命周期"><a class="anchor" href="#渲染与生命周期">渲染与生命周期</a></h2>
<p>在 React 树里抛出的东西，传播很简单。<strong>最近的 <code>ErrorBoundary</code> 接住它</strong>。</p>
<p>渲染中的异常属于这一类。上面的 <code>commits.length</code> 是一个，对以为是数组的值调用 <code>map</code> 也是一个。这一类除了抛出没别的可做，所以任何时候都会到达 <code>ErrorBoundary</code>。</p>
<p>在 <code>useEffect</code> 里抛出的也能接住。effect 是提交之后 React 直接执行的，所以能把那次执行包住。不过在 effect <strong>里面调用的异步回调</strong>就不一样了。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'boom'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)          </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ErrorBoundary 가 받는다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [])</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  setTimeout</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'boom'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)        </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ErrorBoundary 를 지나친다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [])</span></span></code></pre></figure>
<p>两段代码都在同一个 <code>useEffect</code> 里，传播路径却不同。<strong>判断标准不是在不在 <code>ErrorBoundary</code> 里面，而是 React 有没有握着那次执行</strong>。</p>
<p>这一类再记住一件事就够了。React 19 的开发模式控制台会给接住的错误加上组件名。跑组件的时候，渲染、effect 和 transition 都是 <code>The above error occurred in the &#x3C;Thrower> component</code>，只有 <code>lazy</code> 是 <code>occurred in one of your React components</code>。<code>lazy</code> 在被拒绝的时刻还没有组件，写不出名字。只能看调用栈找位置的时候，这个差别就是线索。</p>
<h2 id="服务端数据"><a class="anchor" href="#服务端数据">服务端数据</a></h2>
<p>从这里开始是做前端时必须处理的麻烦部分。原因是服务端的失败<strong>不会自动变成错误</strong>。</p>
<h3 id="fetch-不会因为服务端错误自己-reject"><a class="anchor" href="#fetch-不会因为服务端错误自己-reject">fetch 不会因为服务端错误自己 reject</a></h3>
<p>MDN 把这一点写得很清楚。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p><code>fetch()</code> 的 promise 只有在请求本身失败时才会被拒绝，比如 URL 格式不正确或者发生了网络错误。服务端用表示错误的 HTTP 状态码(<code>404</code>、<code>504</code> 等)响应时，它不会被拒绝。</p></div><div class="quote-original" lang="en"><p>A <code>fetch()</code> promise only rejects when the request fails, for example, because of a badly-formed request URL or a network error. A <code>fetch()</code> promise does not reject if the server responds with HTTP status codes that indicate errors (<code>404</code>, <code>504</code>, etc.).</p></div></blockquote>
<p>也就是说只用 <code>fetch</code> 的话，<code>500</code> 的响应是一个<strong>成功的 Promise</strong>。因为没有抛出，<code>ErrorBoundary</code> 不知道，数据请求库也不知道。TanStack Query 的文档也点了这一条。要判定一次查询失败，<code>queryFn</code> 必须抛出或者返回被拒绝的 Promise，而 <code>axios</code> 会自己抛，<code>fetch</code> 不会。</p>
<p>所以把服务端的失败变成错误，是<strong>得自己动手做的事</strong>。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> response</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'/todos/'</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> todoId)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">response.ok) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'Network response was not ok'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>没有这三行，这一节剩下的内容全都没有意义。没被抛出的东西哪里也不会传播。</p>
<h3 id="一个失败分岔出的五条路"><a class="anchor" href="#一个失败分岔出的五条路">一个失败分岔出的五条路</a></h3>
<p>失败成为失败之后，下一个分叉就开始了。同一个 <code>500</code> 会因为<strong>是怎么调用的</strong>而散到五个地方。这里的基准是没动过任何选项的默认值。</p>
<p><strong><code>useQuery</code> 不会抛出错误</strong>。打开 <code>useQuery.js</code> 会发现 <code>throwOnError</code> 这个字符串压根就不存在。抛不抛是 <code>query-core</code> 的 <code>shouldThrowError</code> 决定的。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> shouldThrowError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">throwOnError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">params</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">	if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> throwOnError </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "function"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> throwOnError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">params);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">	return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">throwOnError;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>没有值的话就是 <code>!!undefined</code>，所以是 <code>false</code>。于是失败只进到 <code>query.error</code> 里，组件正常渲染。外面的 <code>ErrorBoundary</code> 到最后都不知道轮到过自己。</p>
<p><strong><code>useSuspenseQuery</code> 会抛，但不是每次都抛</strong>。这个 hook 展开选项之后会覆盖 <code>throwOnError</code>。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useBaseQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  ...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">options,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  enabled: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  suspense: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  throwOnError: defaultThrowOnError,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  placeholderData: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">void</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, QueryObserver, queryClient);</span></span></code></pre></figure>
<p>覆盖写在 <code>...options</code> 后面，所以<strong>使用者传进来的 <code>throwOnError</code> 会被忽略</strong>。而占据那个位置的默认判定，在 <code>suspense.js</code> 里只有一行。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> defaultThrowOnError</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">query</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> query.state.data </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> void</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p><strong>只要有可以展示的缓存就不抛</strong>。实际工作中这个分支分岔的位置是后台的重新请求。第一次进来的用户缓存是空的，所以失败会去 <code>ErrorBoundary</code>，看到 fallback。已经在页面上的用户切到别的标签页再回来，重新请求跑起来并且失败了，这时缓存里还有旧数据，所以不抛。画面照样显示着旧值，自己不会变。</p>
<p>画面不坏通常是好的行为。只是<strong>画面也不会告诉你</strong>这件事是一起来的，知道了再选和不知道就挨着，是两回事。</p>
<p><strong>mutation 返回的两个函数都不会去 <code>ErrorBoundary</code></strong>。原因各不相同。打开 <code>useMutation.js</code>，其中一边直接把拒绝吞掉了。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">observer.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">mutate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(args[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], args[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">catch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(noop);</span></span></code></pre></figure>
<p>这就是 <code>mutate</code>。同一个文件里 <code>mutateAsync</code> 把 <code>result.mutate</code> 原样导出，而那个 <code>result.mutate</code> 是 <code>mutationObserver.js</code> 用 <code>mutate: this.mutate</code> 放上去的，所以最后和上面那行包住的是同一个函数。<strong>只有一边经过 <code>.catch(noop)</code></strong>。那个拒绝是在 <code>await</code> 的位置炸开，不是在渲染中抛出，所以这一边同样和 <code>ErrorBoundary</code> 无关。</p>
<p><strong>这并不是说 mutation 就永远和 <code>ErrorBoundary</code> 无关</strong>。hook 的函数体里还有一个开关。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (result.error </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> shouldThrowError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(observer.options.throwOnError, [result.error])) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">throw</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> result.error;</span></span></code></pre></figure>
<p>给了 <code>throwOnError</code>，这一行就会<strong>在渲染中</strong>抛出，那时候它会去 <code>ErrorBoundary</code>。拿回来的两个函数够不到 <code>ErrorBoundary</code>，和 hook 不抛，是两回事。</p>
<p><picture><source type="image/webp" srcset="/content/251117/3.720w.webp 720w, /content/251117/3.1440w.webp 1440w, /content/251117/3.2080w.webp 2080w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251117/3.png" alt="左边服务端的一次 500 伸出五条箭头，分别指向 useQuery、useSuspenseQuery、打开了 throwOnError 的 hook、mutate、mutateAsync，它们又各自指向 query.error、ErrorBoundary、ErrorBoundary、mutation.error、调用处的 catch。中间的两个 ErrorBoundary 用虚线框在一起，标注为 ErrorBoundary 接住的那两个" style="max-width:min(720px, 100%);height:auto;" width="2080" height="800" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>整理一下，同一个 <code>500</code> 的到达点有五个。<code>query.error</code> 字段、<code>mutation.error</code> 字段、调用处的 <code>catch</code>，以及去 <code>ErrorBoundary</code> 的两种情况。去 <code>ErrorBoundary</code> 的这两个，是 <code>useSuspenseQuery</code> 在没有缓存时失败，以及打开了 <code>throwOnError</code>。<strong>决定终点的不是失败的种类，而是调用的方式</strong>。</p>
<h2 id="页面切换"><a class="anchor" href="#页面切换">页面切换</a></h2>
<p>换页面时出现的失败分成两种。分开的标准是<strong>在 React 树里面还是外面</strong>。</p>
<h3 id="loader-在树的外面运行"><a class="anchor" href="#loader-在树的外面运行">loader 在树的外面运行</a></h3>
<p>路由的 <code>loader</code> 是渲染开始之前执行的函数。它不是 React 组件，所以 <code>getDerivedStateFromError</code> 和 <code>componentDidCatch</code> 都够不到。不管用 <code>react-error-boundary</code> 包多少层，那个 <code>ErrorBoundary</code> 都看不到 loader 的失败。</p>
<p>取而代之，路由自己另有一套 <code>ErrorBoundary</code> 体系。React Router 的文档是这么写的。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>route module 会自动接住你代码里的错误，并渲染最近的 <code>ErrorBoundary</code>。</p></div><div class="quote-original" lang="en"><p>route modules will automatically catch errors in your code and render the closest <code>ErrorBoundary</code>.</p></div></blockquote>
<p>怎么挑最近的那一个，答案在源码里。<code>findNearestBoundary</code> 是这么挑的。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> findNearestBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">matches</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">routeId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> eligibleMatches </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> routeId </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> matches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">slice</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, matches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">findIndex</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.route.id </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> routeId) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">matches];</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> eligibleMatches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reverse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">().</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">find</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.route.hasErrorBoundary </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> matches[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>它从后往前扫匹配到的路由，挑出第一个带 <code>ErrorBoundary</code> 的路由，没有的话就送到最前面的路由。<strong>所以在下层路由再放一个 <code>ErrorBoundary</code> 不是重复劳动，而是在收窄 fallback 被画出来的范围</strong>。</p>
<p>读的一侧也不一样。路由的 <code>ErrorBoundary</code> 不通过 props 拿错误，而是用 <code>useRouteError()</code> 直接取出来。是不是带着状态码，用 <code>isRouteErrorResponse(error)</code> 来分。这两个工具在 React 的 <code>ErrorBoundary</code> 上是没有的。</p>
<h3 id="lazy-的拒绝在树的里面"><a class="anchor" href="#lazy-的拒绝在树的里面">lazy 的拒绝在树的里面</a></h3>
<p>同样是页面切换，晚一点才拿到代码的那一侧正好相反。<code>lazy(() => import('./Tab'))</code> 的 <code>import()</code> 被拒绝时，React 会接住它并抛给<strong>最近的 <code>ErrorBoundary</code></strong>。组件里第四个按钮走的就是这条路。</p>
<p>发布上线之后旧的分块文件就没了，而发布前就打开着的页面手里还拿着旧地址。在那个状态下要那段代码，<code>import()</code> 会被拒绝，Chrome 抛出 <code>TypeError: Failed to fetch dynamically imported module</code>。</p>
<p>这里还要再加一条。<strong><code>lazy</code> 会记住拒绝</strong>。React 的 <code>lazyInitializer</code> 把结果记在 <code>payload</code> 上，被拒绝的话就改状态并把原因存起来。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">payload._status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">payload._result </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> error;</span></span></code></pre></figure>
<p>在那之后，每次渲染这个组件都会走到最后那个分支。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">throw</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> payload._result;</span></span></code></pre></figure>
<p>不会再 <code>import()</code> 一次。<code>lazy()</code> 的调用在模块顶层只发生过一次，那个 <code>payload</code> 在应用活着期间一直不变。<strong>把 <code>ErrorBoundary</code> 复位重新挂载，回来的还是同一个错误</strong>。这个失败只能靠重新加载页面来恢复，原因就在这里。</p>
<p>同样是页面切换，loader 的失败由路由接住，<code>lazy</code> 的失败由 React 接住。<strong>在决定 <code>ErrorBoundary</code> 放在哪里之前不把这两个分开，其中一个就会无处可去</strong>。</p>
<h2 id="errorboundary-之外的全局处理器"><a class="anchor" href="#errorboundary-之外的全局处理器">ErrorBoundary 之外的全局处理器</a></h2>
<p><code>ErrorBoundary</code> 没接住的那三个去了哪里。去了<strong>浏览器的全局处理器</strong>。</p>
<p>从事件处理器和 <code>setTimeout</code> 回调里抛出的东西，最后会到 <code>window</code> 的 <code>error</code> 事件。Promise 的拒绝去的是另一个事件。MDN 的定义是这样的。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p><code>unhandledrejection</code> 事件会在一个没有拒绝处理器的 JavaScript Promise 被拒绝时，发送到脚本的全局作用域。通常是 <code>window</code>，也可能是 <code>Worker</code>。</p></div><div class="quote-original" lang="en"><p>The <code>unhandledrejection</code> event is sent to the global scope of a script when a JavaScript Promise that has no rejection handler is rejected; typically, this is the <code>window</code>, but may also be a <code>Worker</code>.</p></div></blockquote>
<p>这两个是浏览器最后接住的地方。错误监控工具接住浏览器错误的位置，大多也在这里。</p>
<p>React 19 在树这一侧也多加了一个接住的位置，就是 <code>createRoot</code> 的选项。官方文档把三个分成这样。</p>
<table>
<thead>
<tr>
<th>选项</th>
<th>什么时候调用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>onCaughtError</code></td>
<td>React 在 Error Boundary 内部接住错误时</td>
</tr>
<tr>
<td><code>onUncaughtError</code></td>
<td>错误被抛出但 Error Boundary 没能接住时</td>
</tr>
<tr>
<td><code>onRecoverableError</code></td>
<td>React 自己恢复过来时</td>
</tr>
</tbody>
</table>
<p>接住和修好是两回事。<strong>全局处理器接住了，并不等于画面恢复了</strong>。就算 <code>onClick</code> 里抛出的错误被 <code>window</code> 接住并送到监控工具，那一刻用户的画面上看到的也只是按钮没有反应。上报和恢复是不同的工作。</p>
<h2 id="结语"><a class="anchor" href="#结语">结语</a></h2>
<p>把前端的错误处理当成一份工具清单来背，窟窿会一直留着。<code>ErrorBoundary</code>、<code>throwOnError</code>、<code>useRouteError</code>、<code>lazy</code>、<code>unhandledrejection</code> 全都知道了也一样。不是因为不知道工具，而是因为<strong>没数过什么会去哪里</strong>。</p>
<p>这篇文章讲过的内容整理如下。</p>
<ul>
<li><code>ErrorBoundary</code> 只接住 React 接住并交过来的东西。事件处理器和异步回调不在那个位置上。</li>
<li>类型检查在响应处结束。从 <code>json()</code> 返回 <code>any</code> 的那一点开始，是声明而不是检查。</li>
<li>服务端的失败不会自己变成错误。<code>fetch</code> 不会因为 <code>500</code> 而 reject，所以抛出这件事得自己做。</li>
<li>抛出之后，调用方式决定终点。同一个失败会去字段，也会去调用处，也会去 <code>ErrorBoundary</code>。</li>
<li>页面切换分成两边。loader 在树外面所以由路由接住，<code>lazy</code> 在树里面所以由 React 接住。</li>
<li><code>ErrorBoundary</code> 外面还有全局处理器。接是接得住，画面却不会恢复。</li>
</ul>
<p>所以画 <code>ErrorBoundary</code> 之前要做的，不是挑一个组件来包。而是<strong>把这个页面可能失败的位置全写出来，标出每一个属于上面六种里的哪一种</strong>。没被标到的位置就是窟窿。</p>
<p>不妨看看现在你的页面上可能失败的位置有几个，其中几个能到达 <code>ErrorBoundary</code>，到不了的那些又去了哪里。</p>
<p>每个到达点用什么来接，在<a href="/251203" target="_blank" rel="noopener noreferrer">ErrorBoundary 放在哪里</a>中讲。要分成几层，一个失败要怎么处理它占掉的画面范围。要让 fallback 上的重试按钮真的能重试还得一起解开什么，另外放在<a href="/251128" target="_blank" rel="noopener noreferrer">重试按钮为什么不起作用</a>里讲。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary" target="_blank" rel="noopener noreferrer">React，Component 的 Error Boundary</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/reference/react-dom/client/createRoot" target="_blank" rel="noopener noreferrer">React，createRoot 的错误回调</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/reference/react/lazy" target="_blank" rel="noopener noreferrer">React, lazy</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://reactrouter.com/how-to/error-boundary" target="_blank" rel="noopener noreferrer">React Router, Error Boundaries</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/guides/query-functions" target="_blank" rel="noopener noreferrer">TanStack Query, Query Functions</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/unhandledrejection_event" target="_blank" rel="noopener noreferrer">MDN, unhandledrejection</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch" target="_blank" rel="noopener noreferrer">MDN, fetch</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://axios.rest/pages/advanced/error-handling" target="_blank" rel="noopener noreferrer">axios, Error handling</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/bvaughn/react-error-boundary" target="_blank" rel="noopener noreferrer">bvaughn/react-error-boundary</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>前端</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>错误处理</category>
        </item>
        <item>
            <title><![CDATA[彻底掌握 React Fiber]]></title>
            <link>https://hooninedev.com/zh-CN/250520</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/250520</guid>
            <pubDate>Tue, 20 May 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊聊堪称 React 心脏的 Fiber 架构。 本文写给这样的前端开发者：知道“把工作拆成单元来处理”这一句定义，却很难说明 React 内部究竟如何实现它。读完之后，你可以以 React 源码为依据，说明 Fiber 为什么出现、Fiber 节点长什么样，以及双缓冲、Lane 和 Work Loop 如何配合，让 Concurrent Features 成为可能。 笔者刚接触 Rea...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊堪称 React 心脏的 <strong>Fiber 架构</strong>。</p>
<p>本文写给这样的前端开发者：知道“把工作拆成单元来处理”这一句定义，却很难说明 React 内部究竟如何实现它。读完之后，你可以以 React 源码为依据，说明 Fiber 为什么出现、Fiber 节点长什么样，以及双缓冲、Lane 和 Work Loop 如何配合，让 Concurrent Features 成为可能。</p>
<p>笔者刚接触 React 时，只把 <strong>“Fiber”</strong> 当作面试中的高频问题。背下“把 React 的渲染工作拆分成工作单元来处理”这一句话，就以为那是全部。但真正开始阅读 React 源码后，我才意识到 Fiber 并非一个简单概念，而是一套掌管 React 渲染<strong>一切环节</strong>的运行时架构。</p>
<h2 id="fiber-为什么会出现"><a class="anchor" href="#fiber-为什么会出现">Fiber 为什么会出现？</a></h2>
<p>要回答这个问题，首先需要理解 Fiber 之前的世界，也就是 React 15 及更早版本使用的 <strong>Stack Reconciler</strong> 存在哪些问题。</p>
<p>顾名思义，Stack Reconciler 是一种基于<strong>递归（recursive）调用</strong>的协调引擎。它从上到下递归遍历组件树，一旦开始渲染，就必须处理完整棵树后才能停下来。这就像打电话时，在对方说完之前绝对不能挂断一样。（试想对方开始了长达三小时的人生咨询，而你中途不能挂电话。太可怕了。）</p>
<p>具体来说，Stack Reconciler 存在以下局限。</p>
<ul>
<li><strong>渲染过程中无法中断</strong>：必须一次处理完整棵树，因此在复杂 UI 中，主线程会被占用数十到数百毫秒</li>
<li><strong>没有优先级概念</strong>：无论用户点击按钮，还是后台数据更新，所有更新都以相同方式处理</li>
<li><strong>难以应对动画/手势</strong>：要保持 60fps，每帧内的全部工作需要在约 16ms 内完成，而递归渲染无法保证这一点</li>
<li><strong>发生错误时整个应用中断</strong>：组件树中任意位置发生错误，都可能导致整个应用停止运行</li>
</ul>
<p>为了克服这些局限，React 团队开始探索一种新的执行模型：将工作<strong>拆分</strong>、为工作<strong>设置优先级</strong>，并能在必要时<strong>中断和恢复</strong>。最终的成果正是 <strong>React Fiber</strong>。</p>
<p>Andrew Clark 撰写的 <a href="https://github.com/acdlite/react-fiber-architecture" target="_blank" rel="noopener noreferrer">react-fiber-architecture</a> 文档凝聚了这一设计的核心思想，也是理解 Fiber 最重要的参考资料。（他似乎在写完这篇文档后不久便加入了 React 团队。）</p>
<h2 id="stack-vs-fiber"><a class="anchor" href="#stack-vs-fiber">Stack vs Fiber</a></h2>
<p>那么，Stack Reconciler 与 Fiber Reconciler 在代码层面究竟有何不同？</p>
<h3 id="基于递归的-stack-reconciler"><a class="anchor" href="#基于递归的-stack-reconciler">基于递归的 Stack Reconciler</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="jsx" data-theme="github-dark github-light"><code data-language="jsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> renderComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">component</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> element</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> component.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">render</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  element.props.children.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">forEach</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">child</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> renderComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(child)); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 재귀 호출</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>Stack 方式在遇到子组件时，会像这样<strong>立即进入递归调用</strong>。这种方式的问题在于，它直接依赖 JavaScript 的调用栈（call stack）。随着递归调用变深，调用栈中会不断堆积栈帧；在所有栈帧都退出之前，浏览器主线程无法执行其他工作。</p>
<p>简单来说，在调用栈清空之前，浏览器会陷入<strong>一动也不能动</strong>的状态。</p>
<video width="640" height="480" controls>
  <source src="/content/250520/stack.mov" type="video/mp4">
</video>
<p>从上面的视频可以看到，Stack Reconciler 渲染期间，主线程会被完全阻塞。</p>
<h3 id="基于迭代的-fiber-reconciler"><a class="anchor" href="#基于迭代的-fiber-reconciler">基于迭代的 Fiber Reconciler</a></h3>
<p>Fiber 用<strong>迭代（iterative loop）<strong>取代了递归。它没有使用调用栈，而是在内存中实现了自己的</strong>虚拟栈</strong>。每个 Fiber 节点就是一个“栈帧”；由于这些节点以 JavaScript 对象的形式存在于堆内存中，因此工作可以随时中断，并在之后继续。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="jsx" data-theme="github-dark github-light"><code data-language="jsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> performWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">deadline</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (nextUnitOfWork </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> deadline.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">timeRemaining</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 5</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    nextUnitOfWork </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> performUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(nextUnitOfWork);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  requestIdleCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(performWork); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 나눠서 실행</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>上面的代码展示了 Fiber 早期的概念模型。关键在于，<code>while</code> 循环每次只处理一个工作单元（unit of work）；时间不足时就退出循环，把控制权交还给浏览器。</p>
<p>不过，这个示例只是为了说明概念才使用了 <code>requestIdleCallback</code>，实际的 React 并不使用它。这个 API 只在浏览器自行决定的空闲期内才会被调用，笔者认为，在主线程繁忙的页面上 React 的工作可能会被一再推迟。因此 React 的 Scheduler 包用 <code>MessageChannel</code> 调度下一个 macrotask 来继续工作，并在间隙把控制权交还给主线程。为什么不用 <code>setTimeout</code> 而选择 <code>MessageChannel</code>，另外整理在<a href="/250515" target="_blank" rel="noopener noreferrer">React 为什么使用 MessageChannel</a>中。</p>
<video width="640" height="480" controls>
  <source src="/content/250520/fiber.mov" type="video/mp4">
</video>
<p>采用 Fiber 后，即使在渲染过程中，也能立即响应用户事件（按钮点击、输入等）。因为工作被拆成小块执行，浏览器终于有了喘息的空间。</p>
<p>如果想亲自体验两者的区别，可以点击 <strong><a href="https://animated-lollipop-2b6cbb.netlify.app/" target="_blank" rel="noopener noreferrer">这里</a></strong>。你可以直观看到 Stack Reconciler 和 Fiber Reconciler 的行为差异。</p>
<p>这正是 Andrew Clark 在文档中强调的 Fiber 核心目标。</p>
<ul>
<li><strong>可以暂停工作，并在之后回到该工作继续执行</strong></li>
<li><strong>可以为不同类型的工作设置优先级</strong></li>
<li><strong>可以复用之前已完成的工作</strong></li>
<li><strong>可以中止已经不再需要的工作</strong></li>
</ul>
<h2 id="fiber-node-的内部结构"><a class="anchor" href="#fiber-node-的内部结构">Fiber Node 的内部结构</a></h2>
<p>读到这里，很自然会产生一个问题：“那么 Fiber 节点内部究竟长什么样？”</p>
<p>React 团队并未另外提供介绍 Fiber 内部实现的官方文档。不过，通过 Andrew Clark 的 react-fiber-architecture 文档和实际 React 源码（<code>ReactFiber.js</code>），我们仍然可以理解它的结构。</p>
<p>笔者想把 Fiber 节点比作一张<strong>工作指令单（Work Order）</strong>。在工厂组装产品时，每张工作指令单上都会写明“这个部件是什么类型”“使用什么材料”“下一步需要执行什么工作”“优先级如何”。Fiber 节点也是如此。</p>
<h3 id="reactelement-与-fibernode"><a class="anchor" href="#reactelement-与-fibernode">ReactElement 与 FiberNode</a></h3>
<p>要理解 Fiber，首先需要区分 <strong>ReactElement</strong> 和 <strong>FiberNode</strong>。两者经常被混淆，但实际上完全不同。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ReactElement — React.createElement()가 반환하는 가벼운 객체</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ReactElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  type</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Function</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 문자열(HTML 태그) 또는 함수(컴포넌트)</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  props</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    [</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">key</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">    children</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ReactElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  key</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  ref</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  _owner</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FiberNode</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>ReactElement 只是一张 UI 的<strong>设计图</strong>。它只是一个“请使用这些 props 渲染这样的组件”的请求，并不包含实际的渲染逻辑或状态。</p>
<p>相较之下，<strong>FiberNode</strong> 是 React 根据这张设计图在内部创建的<strong>运行时工作单元</strong>。ReactElement 中没有的 <code>tag</code>、<code>stateNode</code>、<code>child/sibling/return</code>、<code>memoizedState</code>、<code>updateQueue</code>、<code>lanes</code> 等字段，都存在于 FiberNode 中。</p>
<p>React 根据 ReactElement 的 <code>type</code> 创建 FiberNode 时，会确定 <strong>tag</strong> 的值。</p>
<ul>
<li>如果 <code>type</code> 是函数，且存在 <code>prototype.isReactComponent</code> → <code>tag = ClassComponent(1)</code></li>
<li>如果 <code>type</code> 是函数 → <code>tag = FunctionComponent(0)</code></li>
<li>如果 <code>type</code> 是字符串（如 <code>"div"</code>）→ <code>tag = HostComponent(5)</code></li>
</ul>
<p><strong>tag</strong> 是表示 FiberNode 类型的数字常量。它定义在 <code>ReactWorkTags.js</code> 中，包含 <code>FunctionComponent(0)</code>、<code>ClassComponent(1)</code>、<code>HostRoot(3)</code>、<code>HostComponent(5)</code>、<code>HostText(6)</code> 等 25 种以上的 tag。React 会根据这个 tag 值，决定在 <code>beginWork</code> 中执行哪一种处理逻辑。</p>
<p><strong>type</strong> 在协调（reconciliation）过程中起着关键作用。当 React 比较上一次渲染的 Fiber 和新元素时，<strong>最先检查的</strong>就是 type。（这个值会从 ReactElement 原样传递到 FiberNode。）</p>
<ul>
<li>如果上一次是 <code>div</code>，这一次仍是 <code>div</code>，React 会<strong>复用</strong>对应的 Fiber 节点，只更新 props</li>
<li>如果上一次是 <code>div</code>，这一次变成了 <code>span</code>，React 会<strong>丢弃</strong>原有 Fiber，并创建新的 Fiber</li>
</ul>
<p><strong>key</strong> 同样是从 ReactElement 传递到 FiberNode 的值，主要用于渲染列表（数组）。没有 key 时，如果列表项的顺序发生变化，React 无法准确判断某一项移动到了哪里。这可能导致不必要的 DOM 操作，也可能让组件内部状态在非预期的情况下被保留或丢失。</p>
<h3 id="childsiblingreturn"><a class="anchor" href="#childsiblingreturn">child、sibling、return</a></h3>
<p>React Fiber 能够以迭代取代递归的秘密，就藏在这里。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> 부모</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">자식1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">/>, &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">자식2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">/>];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><strong>child</strong> 指向组件 render 返回的<strong>第一个</strong>子元素。在上面的示例中就是 <code>&#x3C;자식1/></code>。<strong>sibling</strong> 表示拥有相同父节点的<strong>下一个兄弟</strong>元素。<code>&#x3C;자식1/></code> 的 sibling 是 <code>&#x3C;자식2/></code>。<strong>return</strong> 则指向当前 Fiber 节点处理完成后要<strong>返回的父级</strong> Fiber。<code>&#x3C;자식1/></code> 和 <code>&#x3C;자식2/></code> 的 return 都是 <code>부모</code>。</p>
<p>这三个字段共同形成了一棵<strong>单向链表（Singly Linked List）形式的树</strong>。普通树结构通常会使用子节点数组（<code>children[]</code>），这种做法更直观，但 Fiber 有意避开了它。</p>
<p>为什么？使用基于数组的子节点结构时，遍历需要管理索引；在中途暂停并恢复时，还必须额外追踪“已经处理到哪里”。而在 linked list 结构中，只要记住当前节点的引用，就可以随时继续遍历。这正是 Fiber 能够自然支持<strong>中断与恢复</strong>的结构基础。</p>
<p>React 基于这一结构，以深度优先搜索（DFS）的顺序遍历节点。它沿 <code>child</code> 向下（beginWork），到达叶节点后检查 <code>sibling</code>；如果没有兄弟节点，就沿 <code>return</code> 向上（completeWork）。</p>
<h3 id="pendingprops-与-memoizedprops"><a class="anchor" href="#pendingprops-与-memoizedprops">pendingProps 与 memoizedProps</a></h3>
<p><strong>pendingProps</strong> 是该 Fiber 即将开始处理时收到的<strong>新 props</strong>，而 <strong>memoizedProps</strong> 是上一次渲染中已经处理完成的<strong>旧 props</strong>。</p>
<p>如果两者相同，React 就可以判断“这个组件没有变化”，并直接复用上一次的渲染结果。这正是 <strong>bailout 优化</strong>的核心机制。</p>
<p>同样，<strong>memoizedState</strong> 保存该 Fiber 的 hooks 状态，<strong>updateQueue</strong> 则通过链表管理尚未处理的状态更新（setState 调用）。</p>
<h3 id="statenode"><a class="anchor" href="#statenode">stateNode</a></h3>
<p><strong>stateNode</strong> 引用 Fiber 节点所指向的<strong>实际实例</strong>。</p>
<ul>
<li>对于 <strong>HostComponent</strong>（div、span 等）：实际 DOM 节点</li>
<li>对于 <strong>ClassComponent</strong>：类实例</li>
<li>对于 <strong>HostRoot</strong>：FiberRoot 对象</li>
</ul>
<p>这个字段充当连接 Fiber 虚拟世界与浏览器实际 DOM 的桥梁。</p>
<h2 id="双缓冲current-树与-workinprogress-树"><a class="anchor" href="#双缓冲current-树与-workinprogress-树">双缓冲：current 树与 workInProgress 树</a></h2>
<p>理解 Fiber 时不能遗漏的核心概念，正是<strong>双缓冲（Double Buffering）</strong>。</p>
<p>为了理解这个概念，可以想象游戏图形的绘制过程。如果游戏直接在当前画面上绘制像素，用户就可能看到只画了一半的帧，出现<strong>画面闪烁（flicker）</strong>。为了避免这种情况，游戏引擎会使用<strong>两个缓冲区</strong>：先在一个缓冲区中完整绘制下一帧，绘制完成后，再一次性切换屏幕正在显示的缓冲区。</p>
<p>React Fiber 使用的正是同一种策略。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">currentFiber.alternate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> workInProgressFiber;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">workInProgressFiber.alternate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> currentFiber;</span></span></code></pre></figure>
<p><strong>current 树</strong>是当前已经反映到屏幕上的 Fiber 树，代表用户正在看到的 UI 状态；<strong>workInProgress 树</strong>则是为了下一次渲染而在后台准备的 Fiber 树。</p>
<p>两棵树通过 <code>alternate</code> 属性相互引用。所有变更都在 workInProgress 树上执行；工作完成后，只需一行 <code>root.current = finishedWork</code> 就能完成树的切换。之前的 workInProgress 成为新的 current，之前的 current 则会在下一次渲染中被回收为 workInProgress。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createWorkInProgress</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">current</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">pendingProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.alternate;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 최초 렌더: 새 Fiber를 생성하고 alternate를 연결</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createFiber</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current.tag, pendingProps, current.key, current.mode);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.stateNode </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.stateNode; </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// DOM 노드는 공유!</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.alternate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    current.alternate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> workInProgress;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 재렌더: 기존 alternate를 재사용, effect만 초기화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.pendingProps </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> pendingProps;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.flags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> NoFlags;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.subtreeFlags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> NoFlags;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.deletions </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // lanes, child, memoizedState 등을 복사</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  workInProgress.childLanes </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.childLanes;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  workInProgress.child </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.child;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>这里需要抓住一个关键点：<code>stateNode</code>（实际 DOM 节点）在 current 与 workInProgress 之间是<strong>共享</strong>的。React 并非每次都创建新的 Fiber 对象，而是复用原有的 alternate，只更新发生变化的字段。因此，每次渲染时都无需承受垃圾回收（GC）压力，就能高效构建树。</p>
<p>如果 props 或 state 没有变化呢？React 就可以通过 <strong>bailout 优化</strong>跳过整个子树。如果说游戏中的双缓冲是以帧为单位进行优化，那么 Fiber 的双缓冲甚至可以实现<strong>组件级别的优化</strong>。</p>
<h2 id="pendingworkpriority--lanes"><a class="anchor" href="#pendingworkpriority--lanes">pendingWorkPriority => Lanes</a></h2>
<p>那么，Fiber 如何判断“这项工作更重要”？</p>
<h3 id="expirationtime-的局限"><a class="anchor" href="#expirationtime-的局限">expirationTime 的局限</a></h3>
<p>早期 Fiber 使用基于数字的 <code>pendingWorkPriority</code> 表示优先级，之后又演变为单一数值 <code>expirationTime</code>。过期时间越近，优先级越高，但这种方式存在根本局限。</p>
<p>因为单个数字无法实现“这个更新属于 A 组，那个更新属于 B 组”这样的<strong>灵活分类</strong>。例如，当用户输入与 Transition 更新同时发生时，基于 expirationTime 的机制只能通过范围（range）比较进行分类，因此难以只选择并处理特定更新。</p>
<h3 id="lane"><a class="anchor" href="#lane">Lane</a></h3>
<p>为了解决这一问题，Andrew Clark 在 <a href="https://github.com/facebook/react/pull/18796" target="_blank" rel="noopener noreferrer">PR #18796</a> 中引入了 <strong>Lane 系统</strong>。</p>
<p>理解 Lane 时，可以想象一条<strong>高速公路</strong>。高速公路有多条车道（lane），每条车道用途不同：第一车道是超车道（紧急），第二车道是行车道（普通），路肩用于紧急情况。每辆车（更新）都会按自身性质被分配到相应车道，高速公路管理系统（调度器）则决定先放行哪条车道上的车辆。</p>
<p>React 的 Lane 也是如此。它为每个更新分配<strong>一个 bit（lane）</strong>，并通过位运算创建和比较分组。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 각 업데이트는 하나의 lane(단일 비트)을 가진다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> SyncLane</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">             /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0000000000000000000000000000010</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> InputContinuousLane</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0000000000000000000000000001000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> DefaultLane</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">          /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0000000000000000000000000100000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> TransitionLane1</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">      /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0000000000000000000000100000000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> IdleLane</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">             /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0001000000000000000000000000000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 배치(batch)는 여러 비트의 OR 조합이다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> SyncUpdateLanes</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SyncLane </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> InputContinuousLane </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> DefaultLane;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 특정 lane이 batch에 포함되는지 확인은 단순 비트 연산</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> isIncluded</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (lane </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> lanes) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>这一系统被设计为将 31 个 lane 放进一个 31 位整数中，目的是利用 V8 引擎的 <strong>SMI（Small Integer）<strong>优化。在 V8 中，不超过 31 位的整数会通过指针标记处理，无需在堆上分配，可以直接在栈上运算。主要 lane 的优先级是</strong>bit 越低，优先级越高</strong>。</p>
<p>得益于这一结构，React 只需一次位运算，就能决定应该先处理哪项工作。<code>getNextLanes()</code> 函数可以从 <code>pendingLanes</code> 中挑选优先级最高的 lane 组，跳过已中断（suspended）的 lane，优先重试已收到数据（pinged）的 lane，从而实现精细调度。</p>
<p>此外，为了<strong>防止饥饿（starvation）</strong>，每个 lane 都有过期时间。Sync/InputContinuous 经过 250ms、Transition 经过 5000ms 后，会被加入 <code>expiredLanes</code>，并被强制同步处理。也就是说，无论优先级多低，都不会永远遭到忽略。（如果因为优先级低就永远被忽略，那就不是优先级系统，而是歧视系统了。）</p>
<h2 id="fiber-的-output"><a class="anchor" href="#fiber-的-output">Fiber 的 output</a></h2>
<p>了解 Fiber 的结构后，接下来很自然会好奇：这些 Fiber 节点如何转换为<strong>实际 DOM</strong>？</p>
<p>output 指的是能够应用到实际 DOM 上的具体 DOM 节点信息。这里有一个重要区别。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="jsx" data-theme="github-dark github-light"><code data-language="jsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 사용자 정의 컴포넌트 — output 없음</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> 아바타</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">img</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> src</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"profile.jpg"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 호스트 컴포넌트 — output 생성</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">img</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> src</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"profile.jpg"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"프로필"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span></code></pre></figure>
<p>只有<strong>宿主组件</strong>（div、span、img 等）会创建实际 DOM 节点。浏览器并不知道 <code>&#x3C;아바타/></code> 是什么。自定义组件是一种抽象概念，最终必须分解成宿主组件，浏览器才能理解。</p>
<p>让我们更具体地观察这一过程。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="jsx" data-theme="github-dark github-light"><code data-language="jsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> 프로필</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"프로필"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">아바타</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">유저정보</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> 아바타</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">img</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> src</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"profile.jpg"</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> alt</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"프로필"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> 유저정보</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>홍길동&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>개발자&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>这些组件生成的 Fiber 树与 output 之间的关系如下。</p>
<pre><code>프로필 (출력: 없음, 컴포넌트 함수)
  │
  └─► div.프로필 (출력: &#x3C;div class="프로필">...&#x3C;/div>)
       │
       ├─► 아바타 (출력: 없음, 컴포넌트 함수)
       │    │
       │    └─► img (출력: &#x3C;img src="profile.jpg" alt="프로필">)
       │
       └─► 유저정보 (출력: 없음, 컴포넌트 함수)
            │
            └─► div (출력: &#x3C;div>...&#x3C;/div>)
                 │
                 ├─► h2 (출력: &#x3C;h2>홍길동&#x3C;/h2>)
                 │
                 └─► p (출력: &#x3C;p>개발자&#x3C;/p>)
</code></pre>
<p>output 的收集过程是<strong>自下而上</strong>的。首先在叶子（宿主）节点创建 DOM。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 호스트 컴포넌트들이 실제 DOM 정보 생성</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">img_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createDOMElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'img'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  src: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'profile.jpg'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  alt: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'프로필'</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">h2_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createDOMElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'h2'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {}, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'홍길동'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">p_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createDOMElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'p'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {}, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'개발자'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>接着，父级宿主组件收集子节点的 output。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// div 노드가 자식들의 출력을 수집</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">유저정보_div_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createDOMElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'div'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {}, [</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  h2_fiber.output,  </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// &#x3C;h2>홍길동&#x3C;/h2></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  p_fiber.output    </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// &#x3C;p>개발자&#x3C;/p></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 최상위 div가 모든 자식 출력을 수집</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">프로필_div_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createDOMElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'div'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {className: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'프로필'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  img_fiber.output,           </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// &#x3C;img src="profile.jpg" alt="프로필"></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  유저정보_div_fiber.output   </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// &#x3C;div>&#x3C;h2>홍길동&#x3C;/h2>&#x3C;p>개발자&#x3C;/p>&#x3C;/div></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]);</span></span></code></pre></figure>
<p>最后，自定义组件将子节点的 output 原样向上传递。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 사용자 정의 컴포넌트는 자식의 출력을 위로 전달</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">아바타_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> img_fiber.output;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">유저정보_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 유저정보_div_fiber.output;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">프로필_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 프로필_div_fiber.output;</span></span></code></pre></figure>
<h2 id="fiber-的调度"><a class="anchor" href="#fiber-的调度">Fiber 的调度</a></h2>
<p>如果 Fiber 的核心价值是“可以拆分工作”，那么实际执行“拆分”的地方在哪里？答案就是 <strong>Work Loop</strong>。</p>
<h3 id="work-loopfiber-遍历的心脏"><a class="anchor" href="#work-loopfiber-遍历的心脏">Work Loop：Fiber 遍历的心脏</a></h3>
<p>React 的渲染始于 <code>ReactFiberWorkLoop.js</code> 中定义的 Work Loop。React 会根据具体情况使用两种 Work Loop。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 동기 렌더링: 중단 없이 모든 Fiber를 처리</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> workLoopSync</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    performUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(workInProgress);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 동시성 렌더링: Scheduler가 양보하라고 할 때까지 처리</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> workLoopConcurrentByScheduler</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">shouldYield</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    performUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(workInProgress);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>请注意两个函数的差异。<code>workLoopSync</code> 会<strong>无条件</strong>运行，直到 <code>workInProgress</code> 变为 <code>null</code>。而 <code>workLoopConcurrentByScheduler</code> 每处理一个 Fiber 就询问一次 <code>shouldYield()</code>，一旦 Scheduler 回答应当让出，就会退出循环。</p>
<p>Scheduler 会看本次任务开始后是否已经过了 <strong>5ms</strong> 来作答。5ms 不是工作切片的大小，而是检查是否让出的标准，所以如果处理一个 Fiber 花的时间比这更长，这项工作也不会被拆开。同一个文件里还有让 Transition 这类 non-idle 工作每 25ms 让出一次的 <code>workLoopConcurrent</code>。但这个函数位于 <code>enableThrottledScheduling</code> 标志之后，而在 v19.3.0 的所有构建中这个标志都是关闭的。</p>
<h3 id="performunitofwork"><a class="anchor" href="#performunitofwork">performUnitOfWork</a></h3>
<p><code>performUnitOfWork</code> 是处理单个 Fiber 节点的函数，Fiber 遍历的核心就包含在这个函数中。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> performUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">unitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> current</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> unitOfWork.alternate;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> next</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> beginWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, unitOfWork, renderLanes);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  unitOfWork.memoizedProps </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> unitOfWork.pendingProps;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (next </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> next;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    completeUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(unitOfWork);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>beginWork</code> 处理当前节点并返回第一个子节点。之后把 <code>pendingProps</code> 确定为 <code>memoizedProps</code>；如果存在子节点，就前往子节点，否则调用 <code>completeUnitOfWork</code></p>
<h3 id="beginwork"><a class="anchor" href="#beginwork">beginWork</a></h3>
<p><code>beginWork</code> 从上到下遍历 Fiber 节点，并在每个节点上执行必要的计算。它定义在 <code>ReactFiberBeginWork.js</code> 中，内部根据 Fiber 的 <code>tag</code> 通过一个巨大的 <strong>switch 语句</strong>进行分支。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> beginWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">current</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">workInProgress</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">renderLanes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // bailout 체크: props와 context가 변경되지 않았다면 스킵</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (current </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> oldProps</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.memoizedProps;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> newProps</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> workInProgress.pendingProps;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (oldProps </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> newProps </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hasContextChanged</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> bailoutOnAlreadyFinishedWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, renderLanes);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  switch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (workInProgress.tag) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    case</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> FunctionComponent:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateFunctionComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    case</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ClassComponent:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateClassComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    case</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> HostComponent:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateHostComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    case</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SuspenseComponent:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateSuspenseComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // ... 약 25가지 이상의 케이스</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>关键在于最上方的 <strong>bailout 检查</strong>。如果 props 与 context 和上一次相同，就会通过 <code>bailoutOnAlreadyFinishedWork</code> 跳过整个子树。这是 React 性能优化中最重要的路径之一。</p>
<p><code>beginWork</code> 的返回值是<strong>第一个子 Fiber</strong>。如果存在子节点，它就会成为下一个 <code>workInProgress</code>；如果不存在（<code>null</code>），则进入 <code>completeUnitOfWork</code>。</p>
<h3 id="completework"><a class="anchor" href="#completework">completeWork</a></h3>
<p><code>completeWork</code> 从叶节点开始，沿父节点方向向上完成工作。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> completeUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">unitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> unitOfWork;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  do</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 1. completeWork로 현재 노드의 작업 마무리 (DOM 생성 등)</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    completeWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, completedWork, renderLanes);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 2. 형제가 있으면 형제로 이동 (다시 beginWork 시작)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> siblingFiber</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork.sibling;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (siblingFiber </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> siblingFiber;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 3. 형제가 없으면 부모로 올라감</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    completedWork </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork.return;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (completedWork </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>completeWork</code> 执行的主要工作如下。</p>
<ul>
<li><strong>对于 HostComponent</strong>：创建实际 DOM 节点（<code>createInstance</code>），并 append 子 DOM。如果 DOM 已经存在，就收集发生变化的 props，并保存到 <code>updateQueue</code> 中。</li>
<li><strong><code>bubbleProperties()</code></strong>：把子节点的 flags 汇总到 <code>subtreeFlags</code> 中。这些信息会在 Commit Phase 用于跳过子树的优化。</li>
</ul>
<p>遍历过程可以总结为：<strong>沿 child 向下（beginWork）-> 在叶节点完成后移向 sibling -> 没有兄弟节点时沿 return 向上（completeWork）</strong>。这就是 Fiber 的深度优先搜索顺序。</p>
<h3 id="scheduler-的任务队列"><a class="anchor" href="#scheduler-的任务队列">Scheduler 的任务队列</a></h3>
<p>React 的 Scheduler 包在内部维护<strong>两个 min-heap（最小堆）</strong>。</p>
<pre><code>timerQueue (대기실)                    taskQueue (실행 대기열)
┌──────────────────┐                  ┌──────────────────┐
│ 아직 시작 시간이     │   startTime      │ 지금 실행 가능한     │
│ 안 된 태스크들       │ ──경과 시──→      │ 태스크들           │
│                  │                  │                  │
│ 정렬: startTime   │                  │ 정렬: expiration  │
│ (빠른 순)          │                  │ Time (임박한 순)   │
└──────────────────┘                  └──────────────────┘
</code></pre>
<p><strong>taskQueue</strong> 是“现在就可以执行”的任务队列。<code>expirationTime</code>（= startTime + timeout）越小，也就是越接近过期，就越先执行。<strong>timerQueue</strong> 则是“尚未到执行时间”的任务等候区。当当前时间超过 startTime 时，任务就会移动到 taskQueue。</p>
<p>那么，决定 expirationTime 的 timeout 是如何确定的？每种更新优先级（Priority Level）都有各自的 timeout。</p>
<pre><code>우선순위          timeout        만료까지         예시
─────────────────────────────────────────────────────────
Immediate        -1ms          즉시 만료         flushSync
UserBlocking     250ms         0.25초           클릭, 입력
Normal           5,000ms       5초              일반 setState
Low              10,000ms      10초             startTransition
Idle             ~1,073,741,823ms  ~12.4일      오프스크린 렌더링
</code></pre>
<p><strong>Immediate</strong> 一经创建便立即过期，因此刚进入 taskQueue 就会以最高优先级执行。（一出生就过期，命运多少有点悲凉。）<strong>UserBlocking</strong> 的 250ms 对应人们开始觉得“响应很慢”的阈值（100～300ms）。点击后 0.25 秒内没有响应，用户就会感到不快。<strong>Normal</strong> 的 5 秒看起来很宽裕，但它保证的是“即使在最坏情况下也一定会处理”。实际上，前面的工作一结束，它就会立刻执行。<strong>Idle</strong> 的约 12.4 天实际上等同于无限长：只有其他所有工作结束后才会执行。（几乎没人会连续 12 天不关闭浏览器，所以把它视为无限也无妨。）</p>
<p>这些 timeout 值同时也是<strong>防止饥饿</strong>（starvation）的机制。无论优先级多低，只要超过 timeout，任务就会进入过期状态并被强制执行。即使高优先级工作不断进入，低优先级工作也不会永远遭到忽略。</p>
<h2 id="render-phase-与-commit-phase"><a class="anchor" href="#render-phase-与-commit-phase">Render Phase 与 Commit Phase</a></h2>
<p>到目前为止，我们已经了解了 Fiber 的结构与调度。现在来梳理一下这些部分如何组合起来，完成实际的 UI 更新。</p>
<p>Fiber 在内部会经历 <strong>Render Phase</strong> 和 <strong>Commit Phase</strong> 两个阶段。这种分离正是让 React 并发模型成为可能的核心设计。如果想亲自查看 Fiber 的工作流程，可以点击下面的图片。</p>
<p><a href="https://storied-centaur-55230f.netlify.app/" target="_blank" rel="noopener noreferrer"><picture><source type="image/webp" srcset="/content/250520/2.720w.webp 720w, /content/250520/2.1440w.webp 1440w, /content/250520/2.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/250520/2.png" alt="逐步展示 React Fiber 运行过程的可视化演示，从渲染阶段到布局阶段，配合组件树与工作栈" width="1680" height="1202" loading="eager" fetchpriority="high" decoding="async"></picture></a></p>
<h3 id="render-phase"><a class="anchor" href="#render-phase">Render Phase</a></h3>
<p>Render Phase 用于<strong>计算 UI 需要哪些变更</strong>。这一阶段完全不会对 DOM 产生实际影响。它最重要的特性是<strong>可以异步中断并恢复</strong>。</p>
<p>这一阶段以前面介绍的 <code>beginWork</code> 和 <code>completeWork</code> 为中心运行。</p>
<p>在 <strong>beginWork(fiber)</strong> 中，会根据每个 Fiber 的类型（FunctionComponent、ClassComponent、HostComponent 等）执行相应逻辑，并创建、连接子 Fiber 节点。如果 props 与上一次相同，就可以利用 memoization 跳过（bailout）</p>
<p>在 <strong>completeWork(fiber)</strong> 中，会准备 DOM 创建工作或 effect 信息。之后，通过 <code>bubbleProperties()</code> 将子节点的 flags 汇总到 <code>subtreeFlags</code>，并在沿父节点方向向上时补全信息</p>
<p>因为这一阶段不直接修改 DOM，所以即使随时中断并在之后重新开始，也不会向用户暴露不完整的 UI。这就是 Concurrent 模式的基础。</p>
<h3 id="subtreeflags"><a class="anchor" href="#subtreeflags">subtreeFlags</a></h3>
<p>在 Render Phase 中，每个 Fiber 都会通过<strong>位标志</strong>记录需要哪些副作用（side effect）。下面看看 <code>ReactFiberFlags.js</code> 中定义的主要 flag。</p>
<ul>
<li><code>Placement</code>：向 DOM 插入新节点</li>
<li><code>Update</code>：需要更新 DOM 属性</li>
<li><code>ChildDeletion</code>：需要删除子节点</li>
<li><code>Ref</code>：需要连接/解除 ref</li>
<li><code>Passive</code>：需要执行 useEffect 回调</li>
<li><code>Snapshot</code>：执行 getSnapshotBeforeUpdate</li>
<li><code>Callback</code>：执行生命周期回调</li>
</ul>
<p>早期 React（～16）使用以 <code>firstEffect</code> -> <code>nextEffect</code> -> <code>lastEffect</code> 相连的 linked list，只收集存在副作用的 Fiber。但这种方式会残留对已卸载 Fiber 的引用，造成<strong>内存泄漏</strong>，也难以高效处理 Suspense 等新模式。</p>
<p>从 React 17 开始，React 移除了这个 effect list，转而采用 <strong>subtreeFlags 方式</strong>（<a href="https://github.com/facebook/react/pull/19381" target="_blank" rel="noopener noreferrer">PR #19381</a>）。在 <code>completeWork</code> 阶段，<code>bubbleProperties()</code> 会把子节点的 flags 汇总到父节点。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> bubbleProperties</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">completedWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> subtreeFlags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> NoFlags;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> child </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork.child;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (child </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    subtreeFlags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> child.subtreeFlags;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    subtreeFlags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> child.flags;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    child </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> child.sibling;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  completedWork.subtreeFlags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> subtreeFlags;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>这种结构最大的优点，是可以在 Commit Phase <strong>跳过整个子树</strong>。如果某个 Fiber 的 <code>subtreeFlags &#x26; MutationMask === NoFlags</code>，就意味着该子树中没有任何需要更改 DOM 的节点，因此可以整体跳过。这是之前的 linked list 方式无法实现的优化。</p>
<h3 id="commit-phase"><a class="anchor" href="#commit-phase">Commit Phase</a></h3>
<p>Commit Phase 负责把 Render Phase 计算出的变更<strong>应用到实际 DOM</strong>。这一阶段<strong>始终同步</strong>执行，一旦开始就会不间断地运行到结束，以防用户看到只更新了一半的 UI。</p>
<p>Commit Phase 在内部按以下细致顺序运行。</p>
<ol>
<li>
<p><strong>Before Mutation Phase</strong>：<code>commitBeforeMutationEffects()</code></p>
<ul>
<li>在 DOM 变更前读取当前 DOM 状态。<code>getSnapshotBeforeUpdate</code> 生命周期会在这里执行。此时 <code>current</code> 树仍然代表屏幕上的状态，因此可以安全捕获 DOM 的滚动位置、尺寸等信息。</li>
</ul>
</li>
<li>
<p><strong>Mutation Phase</strong>：<code>commitMutationEffects()</code></p>
<ul>
<li>这一阶段执行<strong>实际 DOM 操作</strong>。插入新节点、修改现有节点、删除无用节点都在这里发生。<code>componentWillUnmount</code> 也在此时执行，因为 <code>current</code> 仍指向旧树，所以可以读取旧状态。</li>
</ul>
</li>
<li>
<p><strong>树切换</strong>：<code>root.current = finishedWork</code></p>
<ul>
<li>这是双缓冲的核心。workInProgress 树会被提升为 current 树。为何必须在 Mutation 之后、Layout 之前切换？原因很重要：<code>componentWillUnmount</code> 需要读取<strong>旧树</strong>，所以必须在 Mutation 阶段执行；而 <code>componentDidMount</code>/<code>componentDidUpdate</code> 需要读取<strong>新树</strong>，所以必须在 Layout 阶段执行。</li>
</ul>
</li>
<li>
<p><strong>Layout Phase</strong>：<code>commitLayoutEffects()</code></p>
<p>DOM 变更完成后，执行基于新 DOM 状态的工作。</p>
<ul>
<li>执行 <code>componentDidMount</code>、<code>componentDidUpdate</code></li>
<li>执行 <code>useLayoutEffect</code> 回调</li>
<li>此时 <code>current</code> 已经指向新树，因此读取 DOM 时会得到更新后的值</li>
</ul>
</li>
<li>
<p><strong>Passive Effects</strong>（异步）</p>
<ul>
<li><code>useEffect</code> 的 cleanup 与 setup 会被单独调度并<strong>异步</strong>执行。它们用于处理不依赖 DOM 变更的副作用（数据获取、事件订阅等），因此无需同步执行。采用异步方式，可以把控制权让给浏览器，使其先绘制画面。</li>
</ul>
</li>
</ol>
<h2 id="concurrent-features-与-fiber"><a class="anchor" href="#concurrent-features-与-fiber">Concurrent Features 与 Fiber</a></h2>
<p>前面介绍的 Fiber 设计（双缓冲、基于 Lane 的优先级、可中断的 Work Loop）究竟带来了怎样的用户体验？让我们通过 React 18 之后的 Concurrent Features 来看看。</p>
<h3 id="usetransition"><a class="anchor" href="#usetransition">useTransition</a></h3>
<p>调用 <code>startTransition(() => setState(...))</code> 后，对应更新会被分配 <code>TransitionLane</code>。14 个 TransitionLane 通过 round-robin（依次轮流分配工作的方式）进行分配，以避免冲突。</p>
<p>TransitionLane 的优先级低于 SyncLane 和 DefaultLane，因此当用户输入等紧急更新到来时，可以<strong>中断</strong> transition 渲染，先处理紧急更新。在此期间，屏幕会保持 <code>current</code> 树（旧状态），transition 则在 workInProgress 树上于后台继续进行。</p>
<p>此时，双缓冲的价值便体现出来。被中断的 transition 渲染只会影响 workInProgress 树，用户看到的画面（current 树）完全不会受损。</p>
<p><code>isPending</code> flag 表示该 transition 尚未完成，因此可以据此显示加载指示器等内容。</p>
<h3 id="usedeferredvalue"><a class="anchor" href="#usedeferredvalue">useDeferredValue</a></h3>
<p><code>useDeferredValue(value)</code> 在首次渲染时会原样返回传入的 <code>value</code>。之后的渲染中，如果当前渲染比较紧急，它会返回之前 memoized 的值，并使用 TransitionLane 调度新的渲染。与 Transition 相同，延迟渲染也可以被中断</p>
<p>从概念上看，它与 <code>startTransition</code> 类似，但区别在于，它不是应用在派发更新的一方，而是应用在<strong>接收值的一方</strong>。典型用例是立即更新搜索输入框中的文本，同时延迟渲染搜索结果列表。</p>
<h3 id="suspense"><a class="anchor" href="#suspense">Suspense</a></h3>
<p>当组件在 <code>&#x3C;Suspense></code> 内部 throw Promise 时，<code>throwException</code> 会捕获它，并将对应 Fiber 标记为 <code>Incomplete</code>。随后，它沿 <code>return</code> 链向上寻找最近的 Suspense 边界，并切换 Suspense 边界以显示 fallback UI。Promise resolve 后，通过 <code>markRootPinged</code> ping 对应 lane，React 再次渲染 suspended 子树</p>
<p>在 Concurrent 模式下，React 可以继续渲染 suspended 组件的<strong>兄弟（sibling）节点</strong>，因此一个数据请求不会阻塞整棵树的渲染。之所以能做到这一点，是因为 Fiber 的 linked list 结构允许自由移动到 sibling。</p>
<h3 id="streaming-ssr-与-selective-hydration"><a class="anchor" href="#streaming-ssr-与-selective-hydration">Streaming SSR 与 Selective Hydration</a></h3>
<p>React 18 的 <code>renderToPipeableStream</code> 会利用 Suspense 边界。</p>
<ul>
<li><strong>服务器</strong>：Suspense 边界 suspend 时，先发送 fallback HTML；数据准备完成后，再通过 <code>&#x3C;script></code> tag 流式传输实际内容</li>
<li><strong>客户端（Selective Hydration）</strong>：每个 Suspense 边界都可以<strong>独立</strong> hydration。如果用户点击尚未 hydration 的区域，React 会通过 <code>SelectiveHydrationLane</code> <strong>优先</strong>处理对应边界的 hydration，然后再派发事件</li>
</ul>
<p>这一切之所以成为可能，是因为每个 Suspense 边界都是可以独立调度的 Fiber 节点。归根结底，Fiber 架构“拆分工作、设置优先级、可以中断/恢复”的核心设计，构成了这些功能的基础。</p>
<h2 id="结语"><a class="anchor" href="#结语">结语</a></h2>
<p>如果用一句话概括本文，<strong>React Fiber 是一种把递归改为迭代、把调用栈移到堆上，从而让渲染可以中断和恢复的架构</strong>。</p>
<p>为了实现这一点，React 组合了基于 linked list 的树结构、双缓冲、基于 Lane 的优先级系统、基于 MessageChannel 的调度器等众多精巧设计。而这一切最终都指向同一个目标：<strong>最大限度提升用户感受到的 UI 响应性</strong>。</p>
<p>当然，Fiber 的内部实现会随 React 版本升级而持续变化，本文介绍的内容也只是特定时间点的快照。但 Fiber“拆分工作、设置优先级、可以中断和恢复”的核心理念，今后应该也不会改变。</p>
<p>希望本文能让读者理解：React Fiber 并不只是一个面试关键词，而是支撑 React 全部功能的运行时架构。虽然不存在唯一正确的答案，但也希望各位读者亲自阅读源码，建立属于自己的理解。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberWorkLoop.js" target="_blank" rel="noopener noreferrer">React 源码，ReactFiberWorkLoop.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberBeginWork.js" target="_blank" rel="noopener noreferrer">React 源码，ReactFiberBeginWork.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberCompleteWork.js" target="_blank" rel="noopener noreferrer">React 源码，ReactFiberCompleteWork.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberLane.js" target="_blank" rel="noopener noreferrer">React 源码，ReactFiberLane.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiber.js" target="_blank" rel="noopener noreferrer">React 源码，ReactFiber.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/scheduler/src/forks/Scheduler.js" target="_blank" rel="noopener noreferrer">React 源码，Scheduler.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/issues/7942" target="_blank" rel="noopener noreferrer">Issue #7942，Fiber Principles</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://github.com/reactwg/react-18/discussions/37" target="_blank" rel="noopener noreferrer">React 18 WG，New Suspense SSR Architecture</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://github.com/reactwg/react-18/discussions/27" target="_blank" rel="noopener noreferrer">React 18 WG，Concurrent Scheduling</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/blog/2022/03/29/react-v18" target="_blank" rel="noopener noreferrer">React v18.0 Blog Post</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>前端</category>
            <category>React</category>
        </item>
        <item>
            <title><![CDATA[React 为什么使用 MessageChannel]]></title>
            <link>https://hooninedev.com/zh-CN/250515</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/250515</guid>
            <pubDate>Thu, 15 May 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊一聊 React 为什么用 MessageChannel 而不是 requestIdleCallback 来调度工作。 本文写给这样的前端开发者：学习 Fiber 时读到"浏览器空闲时一点一点地干活"的说法，结果在 React 源码里却遇到了 MessageChannel，因此感到困惑。先说答案：requestIdleCallback 被调用的频率达不到 React 的需要，而 set...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊一聊 <strong>React 为什么用 MessageChannel 而不是 requestIdleCallback 来调度工作</strong>。</p>
<p>本文写给这样的前端开发者：学习 Fiber 时读到"浏览器空闲时一点一点地干活"的说法，结果在 React 源码里却遇到了 <code>MessageChannel</code>，因此感到困惑。先说答案：<code>requestIdleCallback</code> 被调用的频率达不到 React 的需要，而 <code>setTimeout</code> 一旦嵌套就会附加超过 4ms 的延迟。因此 React 的 Scheduler 包使用 <code>MessageChannel</code>，在浏览器中它可以不带任何人为延迟地调度下一个宏任务。这 4ms 实际上有多大，会用在 headless Chrome 中测得的数字来确认。</p>
<h2 id="被放弃的调度方式"><a class="anchor" href="#被放弃的调度方式">被放弃的调度方式</a></h2>
<p>讲解 Fiber 概念时，常见的代码是用 <code>requestIdleCallback</code> 把工作拆开执行。这是一种每当浏览器无事可做时就处理一个工作单元的模型。React 起初确实用过这个 API，经过几个 PR 才演变成现在的样子。</p>
<ul>
<li><strong>2017 年 1 月</strong>：使用原生 <code>requestIdleCallback</code>，在没有它的浏览器中则使用以 <code>requestAnimationFrame</code> 和 <code>postMessage</code> 模拟的 polyfill（<a href="https://github.com/facebook/react/pull/8833" target="_blank" rel="noopener noreferrer">PR #8833</a>）。像 Safari 这样没有该 API 的浏览器，从一开始就由 polyfill 负责。Safari 正式版<a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback#browser_compatibility" target="_blank" rel="noopener noreferrer">截至 2026 年 10 月仍然没有这个 API</a>。</li>
<li><strong>2018 年 3 月和 4 月</strong>：加入了一个即使存在原生 API 也使用 polyfill 的标志。Andrew Clark 在 PR 描述中写道，这是为了测试 polyfill 能否减少一直以来遇到的 starvation（工作得不到执行机会、被一再推迟的现象）问题，并补充说由于这些问题难以稳定复现，很难下定论（<a href="https://github.com/facebook/react/pull/12385" target="_blank" rel="noopener noreferrer">PR #12385</a>）。一个月后，团队内部判断它比原生实现更好，于是删除了该标志，确定改用 polyfill（<a href="https://github.com/facebook/react/pull/12648" target="_blank" rel="noopener noreferrer">PR #12648</a>）。</li>
<li><strong>2018 年 11 月</strong>：polyfill 原本发往 <code>window</code> 的 message 事件改为通过 <code>MessageChannel</code> 发送。因为发往 <code>window</code> 时，页面上其他的 message 处理函数也会每帧都被调用（<a href="https://github.com/facebook/react/pull/14234" target="_blank" rel="noopener noreferrer">PR #14234</a>）。</li>
<li><strong>2019 年 7 月</strong>：不再猜测下一次 vsync 并在帧末让出，而是以实验标志的形式加入了一个在 message 事件中工作 5ms 后让出的循环（<a href="https://github.com/facebook/react/pull/16214" target="_blank" rel="noopener noreferrer">PR #16214</a>）。这个循环完全不使用 <code>requestAnimationFrame</code>。</li>
<li><strong>2019 年 8 月和 11 月</strong>：8 月有报告称性能测试显示 message 循环的 CPU 利用率更好（<a href="https://github.com/facebook/react/pull/16271" target="_blank" rel="noopener noreferrer">PR #16271</a>，未合并），之后在 11 月删除了 rAF 实现（<a href="https://github.com/facebook/react/pull/17252" target="_blank" rel="noopener noreferrer">PR #17252</a>）。</li>
</ul>
<p>starvation 为什么会发生，可以从 <code>requestIdleCallback</code> 的定义中推测出来。<a href="https://w3c.github.io/requestidlecallback/" target="_blank" rel="noopener noreferrer">W3C 规范</a>把空闲期（idle period）交由浏览器决定，并把帧与帧之间剩余的时间列为其中一个例子。动画进行时这样的空闲期经常出现，但在 60Hz 的屏幕上通常短于 16ms。主线程被长任务占满时，连这些空闲期也会减少，笔者认为 React 的工作也就相应被推迟。Dan Abramov 也在 2018 年 8 月的一条 issue 评论中写道，React 不再使用这个 API 的原因是"it's not as aggressive as we need"（<a href="https://github.com/facebook/react/issues/11171#issuecomment-417349573" target="_blank" rel="noopener noreferrer">facebook/react#11171</a>）。</p>
<p>之后放弃 <code>requestAnimationFrame</code> 方式的原因写在 PR #16214 的描述里。这种方式必须猜测下一次 vsync（与显示器刷新周期对应的信号）的时机。它一开始假定 30fps，把帧长设为 33.33ms；如果连续两个帧间隔都比它短，就把帧长缩短为两者中较长的那个。本帧的截止时间是帧开始的时刻加上这个长度（<a href="https://github.com/facebook/react/blob/6dc2734b41aef944e457eaa23ae218952fce0a54/packages/scheduler/src/forks/SchedulerHostConfig.default.js#L123-L336" target="_blank" rel="noopener noreferrer">PR #17252 合并前夕的 SchedulerHostConfig.default.js</a>）。由于只有缩短帧长的规则，正如 PR 描述所说，页面打开后刷新率上升可以检测到，下降却检测不到。</p>
<p>message 循环无论处在 vsync 周期的哪个位置都每 5ms 让出一次。PR 描述预期这样即使在刷新率非常高的屏幕上，主线程也能保持响应（"should keep the main thread responsive"）。同一个 PR 还写到了风险：更频繁地让出可能加剧与其他浏览器任务的争用；另外也不清楚标签页在后台时 message 事件会被节流到什么程度。现在 Scheduler 源码中的注释写着，大多数任务不需要与帧边界对齐。</p>
<h2 id="messagechannel"><a class="anchor" href="#messagechannel">MessageChannel</a></h2>
<p>去掉 rAF 之后剩下的调度手段就是 <strong>MessageChannel</strong>。下面是 React v19.3.0 中 <a href="https://github.com/facebook/react/blob/v19.3.0/packages/scheduler/src/forks/Scheduler.js#L530-L561" target="_blank" rel="noopener noreferrer">Scheduler.js 第 530 至 561 行</a>，注释有所删减。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> schedulePerformWorkUntilDeadline;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> localSetImmediate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'function'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // Node.js and old IE.</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  schedulePerformWorkUntilDeadline</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    localSetImmediate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(performWorkUntilDeadline);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> MessageChannel </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'undefined'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // DOM and Worker environments.</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // We prefer MessageChannel because of the 4ms setTimeout clamping.</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> channel</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> MessageChannel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> port</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> channel.port2;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  channel.port1.onmessage </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> performWorkUntilDeadline;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  schedulePerformWorkUntilDeadline</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    port.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">postMessage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // We should only fallback here in non-browser environments.</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  schedulePerformWorkUntilDeadline</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    localSetTimeout</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(performWorkUntilDeadline, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>这里有三个分支。如今的浏览器没有 <code>setImmediate</code>，所以会走第二个分支的 <code>MessageChannel</code>。按照源码注释，第一个分支是为 Node.js 和旧版 IE 准备的，而且 <code>MessageChannel</code> 会让 Node.js 进程无法退出，<code>setImmediate</code> 则不会（<a href="https://github.com/facebook/react/issues/20756" target="_blank" rel="noopener noreferrer">facebook/react#20756</a>）。所以 Jest 的 node 环境走 <code>setImmediate</code> 路径。从 Jest 27 起，jsdom 环境的全局中去掉了 <code>setImmediate</code>（<a href="https://github.com/jestjs/jest/pull/11222" target="_blank" rel="noopener noreferrer">jestjs/jest#11222</a>），也没有 <code>MessageChannel</code>，所以走 <code>setTimeout</code> 路径。</p>
<p>Scheduler 这样调度下一轮的目的，是把主线程交还给浏览器。JavaScript 占着主线程时，浏览器既不能处理输入，也不能绘制画面。不过，只有 Transition 和 Retry 这类做时间切片的渲染才会中途让出。React v19.3.0 的 Reconciler 遇到包含 Sync、InputContinuous、Default 这类 blocking lane 的渲染、包含等待太久而过期的 lane 的渲染，或以 <code>forceSync</code> 调用的渲染时，都会不让出、一直渲染到底（<a href="https://github.com/facebook/react/blob/v19.3.0/packages/react-reconciler/src/ReactFiberWorkLoop.js#L1168" target="_blank" rel="noopener noreferrer">ReactFiberWorkLoop.js 第 1168 行</a>）。</p>
<p>在这类渲染中，Reconciler 每处理一个 Fiber 就询问一次 <code>shouldYield()</code>（<a href="https://github.com/facebook/react/blob/v19.3.0/packages/react-reconciler/src/ReactFiberWorkLoop.js#L3073-L3078" target="_blank" rel="noopener noreferrer">ReactFiberWorkLoop.js 第 3073 至 3078 行</a>）。这个函数由 Scheduler 导出，实际的判断由 Scheduler 内部的 <code>shouldYieldToHost()</code> 完成。判断的依据是本次消息任务开始后经过的时间。这段时间一旦达到 <code>frameInterval</code>，就会让出；如果提交（commit）之后调用过 <code>requestPaint()</code>，则不论时间长短都会让出。<code>frameInterval</code> 的初始值是 <code>SchedulerFeatureFlags.js</code> 中定义的 <code>frameYieldMs</code>，也就是 <strong>5ms</strong>（<a href="https://github.com/facebook/react/blob/v19.3.0/packages/scheduler/src/SchedulerFeatureFlags.js#L11" target="_blank" rel="noopener noreferrer">第 11 行</a>）。5ms 不是工作切片的大小，而是检查是否让出的时间标准。如果渲染一个组件需要 20ms，这 20ms 不会被拆开。以上是 stable 构建的行为。在 experimental 构建中，同一文件里的 <code>enableAlwaysYieldScheduler</code> 是开启的，Scheduler 不会用满 5ms，只要下一个任务尚未过期，每完成一个任务就立即让给浏览器，也不理会 <code>requestPaint()</code> 的信号。</p>
<h2 id="settimeout-的-4ms-延迟"><a class="anchor" href="#settimeout-的-4ms-延迟">setTimeout 的 4ms 延迟</a></h2>
<p>让出之后调度下一个切片时，为什么不用 <code>setTimeout</code> 而用 <code>MessageChannel</code>？HTML 规范的<a href="https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timer-initialisation-steps" target="_blank" rel="noopener noreferrer">计时器初始化步骤</a>规定，嵌套深度超过 5 的 <code>setTimeout</code> 如果延迟小于 4ms，就提高到 4ms。在 headless Chrome 中测量 <code>setTimeout(fn, 0)</code> 反复重新注册自身的调用链，第 1 到第 6 次调用的间隔为 0 至 0.1ms，从第 7 次开始附加了 4 至 5ms（大多在 5ms 左右）。<code>MessageChannel</code> 的消息没有这样的最小延迟。消息只是进入任务队列，浏览器可以在其间插入输入处理或渲染，而 Scheduler 瞄准的正是这个空隙。</p>
<p>4ms 看起来很小，但在每 5ms 让出一次的循环里情况就不同了。我把 200ms 的工作拆成 40 个 5ms 切片，分别用 <code>MessageChannel</code> 和 <code>setTimeout(work, 0)</code> 调度下一个切片。粘贴到开发者工具控制台即可直接运行。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> busy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">ms</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> s</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> performance.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(); </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (performance.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ms) {} }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> ch</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> MessageChannel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> done </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, frames </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> raf</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { frames</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (done </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 200</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">requestAnimationFrame</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(raf); };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> start</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> performance.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> work</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  busy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">5</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); done </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 5</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (done </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 200</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">schedule</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">round</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(performance.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> start), </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'ms,'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, frames, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'frames'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">ch.port1.onmessage </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> work;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> schedule</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ch.port2.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">postMessage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// setTimeout(work, 0) 로 바꿔 비교</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">requestAnimationFrame</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(raf);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">schedule</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p>以下是 2026-10-08 在 macOS 的 Chrome 154.0.8037.98（headless）中，三种写法各运行八次的结果。没有在 Safari 和 Firefox 中测量。表格第三行把 <code>busy(5); done += 5;</code> 换成了 <code>busy(200); done += 200;</code>，即不拆分工作。</p>
<table>
<thead>
<tr>
<th>下一个切片的调度方式</th>
<th>完成所需时间</th>
<th>期间绘制的帧数</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>MessageChannel</code></td>
<td>201ms</td>
<td>12 至 13</td>
</tr>
<tr>
<td><code>setTimeout(work, 0)</code></td>
<td>约 365 至 371ms</td>
<td>21 至 22</td>
</tr>
<tr>
<td>不拆分</td>
<td>200ms</td>
<td>0</td>
</tr>
</tbody>
</table>
<p><code>setTimeout</code> 这边在前六次调用之后，每个切片都多出 4 至 5ms 的空闲时间，同样的工作花了约 1.8 倍的时间。数字会随设备和负载波动，但约 1.8 倍这个比例在反复测量中保持不变。帧数更多是因为工作结束得晚、测量区间变长了，并不代表响应性更好。不拆分时工作在 200ms 内完成，但期间一帧也没有绘制。<code>MessageChannel</code> 在同样的 200ms 内完成工作，同时大多以约 60fps 持续输出帧。</p>
<h2 id="结语"><a class="anchor" href="#结语">结语</a></h2>
<p>总结来说，React 需要的不是等到浏览器空闲的 API，而是能短暂工作后立即调度下一轮的 API。<code>requestIdleCallback</code> 被调用得不如 React 需要的那样积极；<code>requestAnimationFrame</code> 方式必须猜测 vsync 的时机；<code>setTimeout</code> 一旦嵌套，就让每个切片都停顿 4ms 以上。满足这些条件的正是 <code>MessageChannel</code>。</p>
<p>这个 Scheduler 分开执行的工作单元，也就是 Fiber 节点长什么样、Work Loop 如何遍历它们，会在<a href="/250520" target="_blank" rel="noopener noreferrer">彻底掌握 React Fiber</a>中讨论。希望各位读者下次在 React 源码中再遇到 <code>MessageChannel</code> 时，能想起它为什么会出现在那里。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/v16.0.0/src/renderers/shared/ReactDOMFrameScheduling.js" target="_blank" rel="noopener noreferrer">React 16.0.0 的 ReactDOMFrameScheduling.js</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>前端</category>
            <category>React</category>
        </item>
        <item>
            <title><![CDATA[Biome 能取代 ESLint 和 Prettier 吗？]]></title>
            <link>https://hooninedev.com/zh-CN/241201</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/241201</guid>
            <pubDate>Sun, 01 Dec 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊聊一款名为 Biome 的工具。 我所在的团队成员使用 WebStorm、VSCode 等不同的 IDE，因此在维持统一的代码风格方面遇到了不少困难。不仅要为每种 IDE 分别管理配置文件，非常麻烦，代码评审中也经常因为格式差异而出现与逻辑无关的意见。 在这种情况下，ESLint 与格式化相关的规则被标记为 Deprecated，我们不得不寻找新的替代方案。Prettier + ESL...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊一款名为 Biome 的工具。</p>
<p>我所在的团队成员使用 WebStorm、VSCode 等不同的 IDE，因此在维持统一的代码风格方面遇到了不少困难。不仅要为每种 IDE 分别管理配置文件，非常麻烦，代码评审中也经常因为格式差异而出现与逻辑无关的意见。</p>
<p>在这种情况下，ESLint 与格式化相关的规则被标记为 Deprecated，我们不得不寻找新的替代方案。<strong>Prettier + ESLint</strong> 组合需要额外配置来避免工具间的冲突，而 <strong>@stylistic/eslint-plugin-ts</strong> 当时还处于社区发展的早期阶段，稳定性尚未得到充分验证。就在这时，我们开始关注 Biome。</p>
<p>那么，Biome 究竟是一款什么样的工具？它真的能取代 ESLint 和 Prettier 吗？</p>
<h2 id="biome-是什么"><a class="anchor" href="#biome-是什么">Biome 是什么？</a></h2>
<p>Biome 是面向 Web 项目的一体化（All-in-One）工具链。它通过一款工具统一提供 JavaScript、TypeScript、JSX、CSS、JSON、GraphQL 等代码的格式化与 lint 功能。其核心理念，就是用单个二进制程序承担 ESLint 和 Prettier 过去各自负责的工作。</p>
<p>Biome 的前身是 <a href="https://github.com/rome/tools" target="_blank" rel="noopener noreferrer">Rome</a>。<strong>Rome Tools Inc.</strong> 在 2021 年获得 450 万美元风险投资后雄心勃勃地起步，但到了 2023 年年中，公司解雇了全部员工，代码仓库也被归档。随后，核心贡献者 fork 了该项目，并于 2023 年 8 月以 Biome 的名义重新出发。它摆脱了 Rome 时期“承诺过多、交付不足”的形象，通过实用且持续的 release 逐步建立起信任。</p>
<p>它最大的特点是采用 Rust 编写。至于这会带来怎样的性能差异，后文会详细说明。</p>
<h2 id="为什么要使用-biome"><a class="anchor" href="#为什么要使用-biome">为什么要使用 Biome？</a></h2>
<p>选择 Biome 的理由主要可以归纳为三点。</p>
<p><strong>一款工具就能同时处理格式化与 lint。</strong> 使用 ESLint + Prettier 组合时，为避免两款工具的规则冲突，需要添加 <code>eslint-config-prettier</code> 等额外配置。Biome 从根本上消除了这种复杂性。</p>
<p><strong>性能非常出色。</strong> 根据官方 benchmark，它的速度约为 Prettier 的 35 倍、ESLint 的 15 倍。后文会通过直接对比来看看这些数字在实际中意味着什么。</p>
<p><picture><source type="image/webp" srcset="/content/241201/1.720w.webp 720w, /content/241201/1.1440w.webp 1440w, /content/241201/1.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/1.png" alt="Biome 官网的格式化速度对比：2,104 个文件共 171,127 行，Biome 0.41 秒，Prettier 14.35 秒，约快 35 倍" width="1680" height="736" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p><strong>与现有工具兼容。</strong> Biome 与 Prettier 的格式化兼容度约为 97%，并内置了 ESLint 的主要规则。<code>eslint-plugin-react-hooks</code>、<code>eslint-plugin-jsx-a11y</code> 等常用插件的规则也已集成，因此迁移负担相对较小。</p>
<h2 id="如何使用"><a class="anchor" href="#如何使用">如何使用？</a></h2>
<p>Biome 的配置相当简单。<a href="https://biomejs.dev/guides/getting-started/" target="_blank" rel="noopener noreferrer">官方文档</a>中有清晰的说明，可以参考。</p>
<p>首先安装 Biome。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npm</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> install</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --save-dev</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --save-exact</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> @biomejs/biome</span></span></code></pre></figure>
<p>然后生成配置文件。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npx</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> @biomejs/biome</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> init</span></span></code></pre></figure>
<p>这会生成 <code>biome.json</code> 文件。在其中定义团队的格式化与 lint 规则即可。</p>
<p>还需要安装 IDE 扩展。使用 VSCode 时请安装 <a href="https://marketplace.visualstudio.com/items?itemName=biomejs.biome" target="_blank" rel="noopener noreferrer">VSCode Biome</a>；使用 WebStorm 时请安装 <a href="https://plugins.jetbrains.com/plugin/22761-biome" target="_blank" rel="noopener noreferrer">WebStorm Biome</a> 插件。</p>
<p>最后，在 VSCode 的 <code>settings.json</code> 中添加以下配置，即可在保存时自动执行格式化和 lint。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark github-light"><code data-language="json" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "editor.defaultFormatter"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"biomejs.biome"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "editor.codeActionsOnSave"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: {</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "quickfix.biome"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"explicit"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "source.organizeImports.biome"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"explicit"</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<h2 id="直接比较一下"><a class="anchor" href="#直接比较一下">直接比较一下</a></h2>
<p>仅仅说速度快很难有直观感受，因此我在同一个项目中直接比较了 Biome 和 ESLint + Prettier。左侧是 Biome，右侧是 ESLint + Prettier。</p>
<h3 id="vite-项目的本地运行时间"><a class="anchor" href="#vite-项目的本地运行时间">Vite 项目的本地运行时间</a></h3>
<p><picture><source type="image/webp" srcset="/content/241201/biome1.720w.webp 720w, /content/241201/biome1.798w.webp 798w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/biome1.png" alt="使用 Biome 的 Vite 开发服务器启动结果，506 毫秒就绪" width="798" height="330" loading="lazy" fetchpriority="low" decoding="async"></picture>  <picture><source type="image/webp" srcset="/content/241201/lint1.720w.webp 720w, /content/241201/lint1.766w.webp 766w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/lint1.png" alt="使用 ESLint 与 Prettier 的 Vite 开发服务器启动结果，630 毫秒就绪" width="766" height="248" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Biome 为 <strong>506ms</strong>，ESLint + Prettier 为 <strong>630ms</strong>，运行时间快了约 20%。</p>
<h3 id="vite-项目的构建时间"><a class="anchor" href="#vite-项目的构建时间">Vite 项目的构建时间</a></h3>
<p><picture><source type="image/webp" srcset="/content/241201/biome2.720w.webp 720w, /content/241201/biome2.920w.webp 920w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/biome2.png" alt="使用 Biome 的 Vite 构建结果：117.13 秒" width="920" height="222" loading="lazy" fetchpriority="low" decoding="async"></picture> <picture><source type="image/webp" srcset="/content/241201/lint2.720w.webp 720w, /content/241201/lint2.1022w.webp 1022w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/lint2.png" alt="使用 ESLint 与 Prettier 的 Vite 构建结果：131.48 秒" width="1022" height="228" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Biome 为 <strong>117.13s</strong>，ESLint + Prettier 为 <strong>131.48s</strong>，构建时间快了约 10%。</p>
<h3 id="lint-任务"><a class="anchor" href="#lint-任务">Lint 任务</a></h3>
<p><picture><source type="image/webp" srcset="/content/241201/biome3.720w.webp 720w, /content/241201/biome3.894w.webp 894w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/biome3.png" alt="Biome 的 lint 执行结果：0.79 秒，CPU 0.470 秒" width="894" height="174" loading="lazy" fetchpriority="low" decoding="async"></picture> <picture><source type="image/webp" srcset="/content/241201/lint3.720w.webp 720w, /content/241201/lint3.974w.webp 974w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/lint3.png" alt="ESLint 的 lint 执行结果：16.32 秒，CPU 8.600 秒" width="974" height="186" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>差距最大的是 lint 任务。Biome 为 <strong>0.79s</strong>（CPU 0.470s），ESLint 为 <strong>16.32s</strong>（CPU 8.600s），<strong>Biome 的性能大约快了 20 倍</strong>。CPU 使用效率也高得多。</p>
<p>这种差异在开发环境中已经很明显，而在 CI/CD pipeline 中检查数百个文件时，差距会进一步扩大。Biome 无需通过 npm 安装即可直接运行二进制程序，因此还能节省 CI 的冷启动时间。</p>
<p><picture><source type="image/webp" srcset="/content/241201/3.265w.webp 265w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/3.jpeg" alt="托着下巴陷入沉思的派大星角色" width="265" height="190" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>嗯……（到这个程度，反而更难找到不用它的理由了。）</p>
<h2 id="为什么这么快"><a class="anchor" href="#为什么这么快">为什么这么快？</a></h2>
<p>“因为用 Rust 开发，所以速度快”这句话没错，但仅凭这一点还不足以完整解释。下面来看看造就 Biome 性能优势的具体技术因素。</p>
<h3 id="rust-的底层性能"><a class="anchor" href="#rust-的底层性能">Rust 的底层性能</a></h3>
<table>
<thead>
<tr>
<th><img src="/content/241201/5.webp" alt="10、100、300 并发连接下的平均 CPU 使用率对比：Node.js 稳定在 89~91%，Rust 为 145~206%，用上了多个核心" width="1200" height="1000" loading="lazy" fetchpriority="low" decoding="async"></th>
<th><img src="/content/241201/6.webp" alt="相同条件下的平均内存占用对比：Node.js 为 64~89MB，Rust 为 6~14MB" width="1200" height="1000" loading="lazy" fetchpriority="low" decoding="async"></th>
</tr>
</thead>
</table>
<p>Biome 使用系统编程语言 Rust 编写。Rust 追求零成本抽象（Zero-cost Abstraction），即使使用高层抽象，也能达到与手动优化的底层代码相同的性能。此外，它不依赖垃圾回收器（GC），而是通过所有权（Ownership）系统管理内存，因此不会产生 GC 带来的 runtime overhead。</p>
<p>相比之下，ESLint 和 Prettier 使用 JavaScript 编写，运行在 Node.js runtime 上。尽管 V8 引擎的 JIT（Just-In-Time）编译会优化 JavaScript，但仍无法完全避开解释型语言的根本限制和垃圾回收成本。</p>
<h3 id="单次解析架构"><a class="anchor" href="#单次解析架构">单次解析架构</a></h3>
<p>Biome 使用一个解析器（Parser）对代码进行一次解析，生成 AST（Abstract Syntax Tree，抽象语法树），并在格式化和 lint 时复用这棵 AST。</p>
<p>使用 ESLint + Prettier 组合时会发生什么？ESLint 先解析代码、生成 AST 并执行 lint；随后 Prettier 再次解析同一份代码，生成另一棵 AST 并执行格式化。也就是说，同一个文件会被解析两次。Biome 的单次解析架构从根本上消除了这种重复。</p>
<h3 id="原生并行处理"><a class="anchor" href="#原生并行处理">原生并行处理</a></h3>
<p><picture><source type="image/webp" srcset="/content/241201/7.720w.webp 720w, /content/241201/7.800w.webp 800w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/7.png" alt="同步与异步处理对比图：同步是请求与响应交替，异步则连续发出请求、稍后接收响应" width="800" height="514" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Biome 利用 Rust 的并发模型，在多个线程中并行处理文件。它将任务拆分为较小的单元，并通过 work-stealing scheduler 在各线程间高效分配负载。Rust 的所有权系统会在编译阶段从根本上阻止数据竞争（Data Race），因此 runtime 的同步成本也能降到最低。</p>
<p>Node.js 默认采用基于事件循环的单线程模型。虽然可以使用 Worker Threads 实现并行处理，但线程创建和消息传递会带来额外 overhead。Biome 直接使用操作系统级的原生线程，因此能在没有这类 overhead 的情况下充分利用 CPU 核心。</p>
<h3 id="内存高效的-ast-处理"><a class="anchor" href="#内存高效的-ast-处理">内存高效的 AST 处理</a></h3>
<p><img src="/content/241201/4.svg" alt="语法树示例：左边是一元运算符 -1，右边是二元运算符 1 + 2，用运算符节点和整数节点表示" width="446" height="360" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>Biome 使用 CST（Concrete Syntax Tree，具体语法树）。根据 Biome 官方架构文档，这棵 CST 基于 rowan 库的内部 fork 实现了 Green/Red Tree 模式，能够保留原始代码中的全部信息，包括注释和空白。Rowan 的 Arena 风格内存分配将节点放在连续的内存区域中，从而提升 CPU 缓存局部性（Cache Locality），并尽量减少不必要的对象分配。</p>
<p>JavaScript 基于对象的 AST 处理方式会让每个节点都成为独立的 heap 对象，导致内存分散，并增大 GC 压力。Biome 的方式可以使用更少的内存实现更快的树遍历。</p>
<h2 id="那么应该引入-biome-吗"><a class="anchor" href="#那么应该引入-biome-吗">那么，应该引入 Biome 吗？</a></h2>
<p>Biome 的性能和便利性显然很有吸引力。不过，我并不认为所有项目都应该无条件引入它。下面来看看几个实际需要考虑的因素。</p>
<h3 id="适合使用-biome-的情况"><a class="anchor" href="#适合使用-biome-的情况">适合使用 Biome 的情况</a></h3>
<ul>
<li>维护<strong>大型代码库</strong>，构建和 lint 性能非常重要</li>
<li>希望缩短 CI/CD pipeline 中的代码检查时间</li>
<li>已经厌倦 ESLint + Prettier 配置的复杂性</li>
<li>正在启动新项目，希望采用简洁的工具配置</li>
</ul>
<p>我的团队也在维护一个大型项目，CI pipeline 中的 lint 耗时很长，开发者也苦于缓慢的 lint 速度，因此最终决定引入 Biome。</p>
<h3 id="需要注意的事项"><a class="anchor" href="#需要注意的事项">需要注意的事项</a></h3>
<p><strong>最大的限制是插件生态。</strong> ESLint 拥有数千个社区插件，而 Biome 主要围绕内置规则运行。它已内置 <code>eslint-plugin-react</code>、<code>eslint-plugin-react-hooks</code>、<code>eslint-plugin-jsx-a11y</code>、<code>eslint-plugin-unicorn</code>、<code>typescript-eslint</code> 等主流插件的相当一部分规则，但并非每个插件的所有规则都已完成移植。Biome v2 已宣布将引入基于 GritQL 的插件系统，不过目前仍处于实验阶段。如果项目必须依赖 <code>@next/eslint-plugin-next</code>、<code>eslint-plugin-angular</code> 等框架专用规则，就需要谨慎考虑迁移。</p>
<p><strong>还需要确认语言支持范围。</strong> JavaScript、TypeScript、JSX、CSS、JSON、GraphQL 均已获得稳定支持，但 Vue 和 Svelte 的 SFC（Single File Component）文件目前只对 <code>&#x3C;script></code> 块提供部分支持。HTML、YAML 和 Markdown 尚未得到支持。</p>
<p><strong>也不能忘记 ESLint 本身还在不断发展。</strong> ESLint v9（2024 年 4 月）引入的 Flat Config（<code>eslint.config.js</code>）大幅简化了原有 <code>.eslintrc</code> 方式的复杂性。此外，它还分别在 2024 年 10 月和 2025 年 2 月发布 <code>@eslint/json</code> 与 <code>@eslint/css</code>，将 lint 范围扩展到 JavaScript 以外的语言。ESLint Stylistic（<code>@stylistic/eslint-plugin</code>）项目则提供了不使用 Prettier、仅靠 ESLint 完成格式化的方案。随着 ESLint 生态的演进，Biome 的“一体化”优势正在一定程度上被削弱。</p>
<p>此外，也需要记住 Rome 转变为 Biome 的历史。Rome 被归档时给现有用户造成的不便，说明选择工具时项目的可持续性有多么重要。幸运的是，Biome 通过 OpenCollective 和 GitHub Sponsors 获得资金支持，并保持着稳定的 release 周期。</p>
<p><picture><source type="image/webp" srcset="/content/241201/8.720w.webp 720w, /content/241201/8.1440w.webp 1440w, /content/241201/8.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/8.png" alt="npm trends 一年的每周下载量：eslint 与 prettier 在三千万到五千万之间，biome 几乎贴着坐标轴底部" width="1680" height="854" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>从 npm trends 来看，Biome 每周约 690 万次的下载量与 ESLint 约 1.2 亿次、Prettier 约 8200 万次相比，仍有很大差距。不过，Biome 的增长速度值得关注。仅仅一年多的时间里，其周下载量便增长到原来的三至四倍以上，尤其是在新项目中的采用率显著上升。</p>
<h2 id="写在最后"><a class="anchor" href="#写在最后">写在最后</a></h2>
<p>对于 Biome 能否完全取代 ESLint 和 Prettier 这个问题，我的回答是：<strong>“目前还不能，但它已经是非常有力的替代方案。”</strong></p>
<p>它的性能非常出色，配置简洁，开发速度也很快。不过，插件生态尚不成熟，而且对部分语言的支持有限，这些问题可能会因项目而异，成为实际障碍。最好仔细评估项目的技术栈与团队需求，再决定是否引入。</p>
<p>有一点可以确定：前端工具生态正在朝着“更快、更简洁、更集成”的方向发展。不可否认，Biome 正站在这股潮流的前沿。它无疑是一款值得期待未来发展的工具。</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>前端</category>
            <category>JavaScript</category>
        </item>
        <item>
            <title><![CDATA[Zustand，你到底为什么是 ProviderLess？]]></title>
            <link>https://hooninedev.com/zh-CN/240818</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/240818</guid>
            <pubDate>Sun, 18 Aug 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊一聊 Zustand 是如何在没有 Provider 的情况下完成状态管理的。 使用 Zustand 时，我一直把无需 Provider 就能管理状态这件事视为理所当然。直到某天，我突然想到一个问题。在 React 生态中，大多数库都已把用 Provider 包裹应用变成了一种近乎仪式化的做法。TanStack React Query 必须由 QueryClientProvider 包...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊一聊 Zustand 是如何在没有 Provider 的情况下完成状态管理的。</p>
<p>使用 Zustand 时，我一直把无需 Provider 就能管理状态这件事视为理所当然。直到某天，我突然想到一个问题。在 React 生态中，大多数库都已把用 Provider 包裹应用变成了一种近乎仪式化的做法。TanStack React Query 必须由 <code>QueryClientProvider</code> 包裹才能使用 <code>useQuery</code>，toss 的 overlay-kit 没有 <code>OverlayProvider</code> 也无法调用 <code>overlay.open()</code>。React 的 Context API 同样必须用 Provider 包裹组件树。那么 Zustand 究竟施了什么魔法，才省掉了这道流程？</p>
<p>出于好奇，我直接拆解了 Zustand 的源码，发现其中隐藏着比预想更有意思的结构。下面就来整理一下这个过程中了解到的内容。</p>
<h2 id="状态在-react-中如何流动"><a class="anchor" href="#状态在-react-中如何流动">状态在 React 中如何流动</a></h2>
<p>在一般的 React 应用中，状态会像下图这样运作。</p>
<p><picture><source type="image/webp" srcset="/content/240818/3.720w.webp 720w, /content/240818/3.880w.webp 880w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/240818/3.png" alt="React 数据流示意图：数据通过 props 向下流动，事件向上传递" width="880" height="509" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>组件内部状态使用 React 提供的状态管理 hook（<code>useState</code>、<code>useReducer</code>）进行管理，状态则通过 props 传递给子组件。到这里都很简单。</p>
<p>问题出现在相距较远的组件需要共享状态时。React 为此提供的官方解决方案就是 Context API，但它要求必须用 Provider 组件包裹下层树。</p>
<h3 id="为什么-context-api-需要-provider"><a class="anchor" href="#为什么-context-api-需要-provider">为什么 Context API 需要 Provider？</a></h3>
<p>要回答这个问题，需要稍微了解一下 React 的内部运行方式。</p>
<p>React 使用一种名为 Fiber 的内部数据结构来管理组件树。每个 Fiber 节点都以父子关系相连。当 Context 的值发生变化时，React 会自上而下遍历 Fiber 树，找出订阅了该 Context 的组件，并触发重新渲染。</p>
<p>关键在于：<strong>Context 值的传播依赖 Fiber 树的结构。</strong> Provider 位于树中的哪个位置，决定了值的传递范围；调用 <code>useContext</code> 的组件会沿着自身上层的 Fiber 树向上查找最近的 Provider。如果没有 Provider 呢？那就只会使用传给 <code>createContext</code> 的默认值。</p>
<p>也就是说，Context API 与 React 的渲染系统紧密耦合。状态的存储、传播和订阅全都发生在 React 组件树内部。</p>
<p>那么 Zustand 是如何绕过这个结构的呢？</p>
<h2 id="zustand-活在-react-外部"><a class="anchor" href="#zustand-活在-react-外部">Zustand 活在 React 外部</a></h2>
<p><picture><source type="image/webp" srcset="/content/240818/4.720w.webp 720w, /content/240818/4.1300w.webp 1300w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/240818/4.png" alt="Flux 模式示意图：Action、Dispatcher、Store、View 单向连接" width="1300" height="393" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Zustand 基于 Flux 模式运行。闭包内部的 <code>state</code> 扮演 Store，用户定义的函数扮演 Action，<code>set</code> 函数扮演 Dispatcher，React 组件则扮演 View。决定性的差异就在这里。</p>
<p><strong>Zustand 的 Store 存在于 React 组件树之外，也就是 JavaScript 模块的作用域内</strong>。所谓组件树之外，是指与 React 内部的状态管理不同，Zustand 的状态独立存在，与 React 的 Fiber 树无关。任何组件只要执行 <code>import</code> 就能访问 Store，无需用 Provider 包裹应用。（它像全局变量一样可以从任何地方访问，同时又受到闭包的妥善保护。）</p>
<p>为什么能做到这一点？来看下面的代码。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { create } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'zustand'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> useStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> create</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  count: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  increment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ count: state.count </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> })),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}));</span></span></code></pre></figure>
<p>这段代码中的 <code>create</code> 会在模块加载时调用。也就是说，在 React 开始渲染之前，Store 就已经存在于内存中。这就是<strong>模块级单例（Module-level Singleton）模式</strong>。</p>
<h3 id="什么是模块级单例"><a class="anchor" href="#什么是模块级单例">什么是模块级单例？</a></h3>
<p>JavaScript 的 ES 模块系统会<strong>仅在首次加载时对模块求值（evaluate），并缓存结果</strong>。之后，无论从哪里 <code>import</code> 同一个模块，都不会重新执行，而是返回缓存中的同一个对象。也就是说，无论组件 A 还是组件 B 执行 <code>import { useStore } from './store'</code>，二者引用的都是<strong>完全相同的 Store 实例</strong>。</p>
<p>既不需要另外实现 singleton 类，也不需要把它挂到全局变量（<code>window.store</code>）上。模块系统本身就自然满足了单例“只创建一次，并且从任何地方访问的都是同一个实例”这一条件。Zustand 直接利用这种语言层面的保证，让所有组件无需单独的 Provider 也能共享同一个 Store。</p>
<p>读到这里，自然会产生一个问题：Zustand 的内部究竟是什么样的？</p>
<h2 id="zustand-的内部结构"><a class="anchor" href="#zustand-的内部结构">Zustand 的内部结构</a></h2>
<p>查看 <a href="https://github.com/pmndrs/zustand/tree/main/src" target="_blank" rel="noopener noreferrer">Zustand 的 GitHub 仓库</a>就会发现，其核心逻辑简洁得令人惊讶。核心主要由两个文件组成：<code>vanilla.ts</code> 负责 Store 本身，<code>react.ts</code> 负责与 React 建立连接。</p>
<h3 id="vanillats"><a class="anchor" href="#vanillats">vanilla.ts</a></h3>
<p><a href="https://github.com/pmndrs/zustand/blob/main/src/vanilla.ts" target="_blank" rel="noopener noreferrer">vanilla.ts</a> 是 Zustand 的心脏。Store 如何创建、状态如何管理，全都包含在这一个文件里。说得更简单一些，封闭在闭包里的状态以及操作该状态的函数，都定义在这个文件中。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createStoreImpl</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CreateStoreImpl</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">createState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ReturnType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> createState></span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Listener</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">prevState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> void</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> listeners</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Listener</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StoreApi</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'setState'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">partial</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">replace</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> nextState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> partial </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'function'</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (partial </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)(state)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> partial</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Object.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">is</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(nextState, state)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> previousState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      state </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        (replace </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> nextState </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'object'</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> nextState </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">))</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">          ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (nextState </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">          :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Object.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">assign</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({}, state, nextState)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      listeners.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">forEach</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">listener</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> listener</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(state, previousState))</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StoreApi</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'getState'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getInitialState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StoreApi</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'getInitialState'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    initialState</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> subscribe</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StoreApi</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'subscribe'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">listener</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    listeners.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">add</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(listener)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> listeners.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">delete</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(listener)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> api</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { setState, getState, getInitialState, subscribe }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> initialState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (state </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(setState, getState, api))</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> api </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>逐行拆解这段代码，就能看清 Zustand 的核心机制。</p>
<h4 id="通过闭包封装状态"><a class="anchor" href="#通过闭包封装状态">通过闭包封装状态</a></h4>
<ul>
<li>
<p>变量 <code>let state: TState</code> 被声明为 <code>createStoreImpl</code> 函数的局部变量。即使函数执行结束，<code>setState</code>、<code>getState</code> 等内部函数仍然引用着这个变量，所以它不会被垃圾回收。这就是闭包的本质。</p>
</li>
<li>
<p>外部没有任何办法直接访问 <code>state</code> 变量。只能通过 <code>getState()</code> 读取，通过 <code>setState()</code> 写入。（相当于用闭包实现了面向对象中的 private 字段。）</p>
</li>
</ul>
<h4 id="利用-objectis-检测变化"><a class="anchor" href="#利用-objectis-检测变化">利用 <code>Object.is</code> 检测变化</a></h4>
<ul>
<li>
<p><code>setState</code> 计算出新状态后，会通过 <code>Object.is(nextState, state)</code> 与原状态进行比较。如果引用相同，就什么都不会发生。这是防止不必要重新渲染的第一道防线。</p>
</li>
<li>
<p>不过，这种 <code>Object.is</code> 比较检查的是<strong>严格引用相等（strict reference equality）</strong>，因此使用方需要留意一个问题。只取出一个原始值（如数字、字符串等）时没有问题。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> count</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state.count);</span></span></code></pre></figure>
<p>但如果 selector <strong>返回一个新对象</strong>，情况就不同了。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">count</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">name</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  count: state.count,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  name: state.name,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}));</span></span></code></pre></figure>
<p><code>{ count, name }</code> 对象即使值相同，每次调用也会创建新的引用。<code>Object.is</code> 不比较内部属性，只比较引用，因此在 Zustand 看来，每次都会被判断为“状态变了”，从而触发重新渲染。</p>
<p>为了解决这个问题，Zustand 提供了 <strong><code>useShallow</code></strong> hook。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { useShallow } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'zustand/react/shallow'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">count</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">name</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useShallow</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ count: state.count, name: state.name }))</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p><code>useShallow</code> 会逐一比较返回对象的<strong>顶层属性</strong>，只有值真正发生变化时才触发重新渲染。这与 Redux 的 <code>useSelector</code> 默认使用引用比较、同时允许将 <code>shallowEqual</code> 作为第二个参数传入的思路相似。（不过，顾名思义，<code>useShallow</code> 只做“浅层”比较，不会追踪嵌套对象的内部。）</p>
</li>
</ul>
<h4 id="采用-pubsub-模式的-listener-系统"><a class="anchor" href="#采用-pubsub-模式的-listener-系统">采用 Pub/Sub 模式的 listener 系统</a></h4>
<ul>
<li><code>const listeners: Set&#x3C;Listener> = new Set()</code> 这一行就是 Zustand 的整个订阅系统。状态发生变化时，通过 <code>listeners.forEach</code> 通知所有订阅者。</li>
<li>调用 <code>subscribe</code> 时，listener 会被添加到 <code>Set</code>；调用其返回的函数时，则从 <code>Set</code> 中删除。</li>
<li>这个模式之所以重要，是因为它构成了一个<strong>完全独立于 React Fiber 树的通知系统</strong>。并不是由 Provider 遍历树来寻找订阅者，而是由 Store 直接管理订阅者列表。</li>
</ul>
<h4 id="创建初始状态"><a class="anchor" href="#创建初始状态">创建初始状态</a></h4>
<ul>
<li>
<p>来看一下处理初始状态的最后一行代码。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> initialState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (state </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(setState, getState, api))</span></span></code></pre></figure>
<p>一行里压缩了很多内容。在 JavaScript 中，赋值运算符（<code>=</code>）是一个会<strong>返回所赋值本身</strong>的表达式（expression）。也就是说，括号中的 <code>state = createState(...)</code> 会先执行，把初始状态赋给 <code>state</code>，其返回值再赋给 <code>const initialState</code>。最终，<code>state</code> 与 <code>initialState</code> <strong>引用同一个对象</strong>。</p>
<p>可为什么要特意用两个变量保存同一个值呢？关键在于两个变量的职责不同。</p>
<ul>
<li><strong><code>state</code></strong> 是用 <code>let</code> 声明的变量。每当调用 <code>setState</code> 时，它都会被替换成新值。也就是说，它表示<strong>当前时刻仍在变化的状态</strong>。</li>
<li><strong><code>initialState</code></strong> 是用 <code>const</code> 声明的变量。Store 创建时的状态会被永久保存。之后无论调用何种 <code>setState</code>，这个值都不会改变。它相当于<strong>Store 的第一个快照</strong>。</li>
</ul>
<p>这个 <code>initialState</code> 通过 <code>getInitialState()</code> 方法暴露给外部，并在 <code>react.ts</code> 中作为 <code>useSyncExternalStore</code> 的<strong>第三个参数（服务端快照</strong>）传入。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> slice</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useSyncExternalStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  api.subscribe,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> selector</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()),       </span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> selector</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getInitialState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()), </span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span></code></pre></figure>
<p>服务端渲染（SSR）环境中没有浏览器 API，也没有用户交互，因此不会调用 <code>setState</code>。所以服务端始终使用 <code>initialState</code>（= 初始状态）作为快照。当客户端开始 hydration 时，React 会比较服务端渲染的 HTML 与客户端初次渲染的结果。因为两边都基于同一个 <code>initialState</code> 渲染，所以能够<strong>防止 hydration 不一致</strong>。</p>
</li>
</ul>
<h3 id="reactts"><a class="anchor" href="#reactts">react.ts</a></h3>
<p><a href="https://github.com/pmndrs/zustand/blob/main/src/react.ts" target="_blank" rel="noopener noreferrer">react.ts</a> 负责把上面创建的纯 JavaScript Store 连接到 React 的渲染系统。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">StateSlice</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  api</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ReadonlyStoreApi</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  selector</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StateSlice</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> identity </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> slice</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useSyncExternalStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    api.subscribe,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> selector</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()), [api, selector]),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> selector</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getInitialState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()), [api, selector]),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useDebugValue</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(slice)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> slice</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>这里的核心是 <code>useSyncExternalStore</code>。这个 hook 在 React 18 中引入，旨在<strong>把存在于 React 外部的状态存储安全地集成到 React 渲染周期中</strong>。</p>
<p>看看 <code>useSyncExternalStore</code> 接收的三个参数，结构就很清晰了。（与前面讨论 vanilla.ts 时的内容几乎相同。）</p>
<ul>
<li><strong><code>api.subscribe</code></strong>：订阅 Store 变化的函数。React 通过它提出“状态变化时请通知我”的请求。</li>
<li><strong><code>() => selector(api.getState())</code></strong>：返回当前状态的快照。React 每次渲染都会调用它来获取最新状态。</li>
<li><strong><code>() => selector(api.getInitialState())</code></strong>：服务端渲染时使用的初始快照。它可以防止 hydration 过程中服务端与客户端的状态不一致。</li>
</ul>
<p>尤其是，<code>useSyncExternalStore</code> 解决了 React 并发模式（Concurrent Mode）中可能发生的 <strong>tearing 问题</strong>。Tearing 是指在同一次渲染过程中，不同组件显示了<strong>同一数据源的不同快照</strong>的现象。</p>
<p>看一个具体场景会更容易理解。组件 A 读取 <code>store.value</code>（= 10）并开始渲染。这时，React 在并发模式下<strong>暂时暂停（yield）<strong>渲染，并把控制权交还给浏览器。就在这个空档，WebSocket 消息抵达，把 <code>store.value</code> 改成了 11。React 恢复渲染后，组件 B 读取 <code>store.value</code>（= 11）。结果，同一帧中 A 显示 10，B 显示 11，形成了</strong>撕裂（teared）的 UI</strong>。React 18 以前的渲染始终是同步的，所以不会出现这个问题。</p>
<p><code>useSyncExternalStore</code> 会记录渲染开始时的快照（<code>getSnapshot</code>）。如果外部 Store 在渲染过程中发生变化，导致快照不同，它就会检测到这一点，并<strong>从头重新开始渲染</strong>。这样就能保证所有组件都基于同一个快照进行渲染。</p>
<p>然后，<code>createImpl</code> 函数把这一切组合到一起。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createImpl</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">createState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StateCreator</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, [], []>) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> api</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(createState)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useBoundStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">selector</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api, selector)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  Object.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">assign</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(useBoundStore, api)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> useBoundStore</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>它通过 <code>createStore</code> 创建 vanilla Store，用名为 <code>useBoundStore</code> 的自定义 hook 包裹，然后通过 <code>Object.assign</code> 把 Store API 的方法（<code>setState</code>、<code>getState</code>、<code>subscribe</code> 等）直接附加到 hook 函数本身。最终返回的 <code>useBoundStore</code> 具有双重性质：<strong>既是 React hook，同时也是 Store API</strong>。（明明是函数却还有方法，是一种很有 JavaScript 风格的模式。）</p>
<h2 id="其他状态管理库又如何"><a class="anchor" href="#其他状态管理库又如何">其他状态管理库又如何？</a></h2>
<p>理解到这里，自然会想和其他库做一番比较。</p>
<p>Jotai、Recoil、MobX、Xstate、Redux 等状态管理库有很多，这里主要比较一下我亲自使用过的库。</p>
<blockquote>
<p>顺带一提，经常与 Jotai 比较的 <strong>Recoil</strong>（Meta）在 2025 年 1 月归档了仓库，事实上已经停止开发，也没有支持 React 19。如果需要原子化状态模型，那么现阶段可以说 Jotai 是唯一现实的选择。</p>
</blockquote>
<h3 id="redux"><a class="anchor" href="#redux">Redux</a></h3>
<p>Redux 内部同样使用模块级 Store。那么它为什么需要 Provider？</p>
<p>Redux 的 <code>&#x3C;Provider store={store}></code> 通过 React Context 将 Store 实例<strong>注入（inject）<strong>组件树。<code>useSelector</code> 和 <code>useDispatch</code> 会在内部调用 <code>useContext</code>，访问 Provider 提供的 Store。这里的重要之处在于，Redux 使用 Context <strong>不是作为状态传播通道，而是作为依赖注入（Dependency Injection）手段</strong>。通过 Context 传递的并非状态值本身，而是管理状态的</strong>Store 对象引用</strong>。实际的状态订阅和更新则由 Store 内部的 Pub/Sub 处理。</p>
<p>这种设计带来的好处很明确。测试时，用 Provider 包裹另一个 Store 实例即可实现完全隔离；在同一个应用中，也可以通过 <code>context</code> prop 构建多个彼此独立的 Store 树。正如 Redux 维护者 Mark Erikson 所强调的，“Context 是一种传输机制（transport mechanism），而不是状态管理工具”。</p>
<h3 id="jotai"><a class="anchor" href="#jotai">Jotai</a></h3>
<p>Jotai 采用了与 Redux、Zustand 有根本差异的<strong>原子化（atomic）状态模型</strong>。它不会把状态集中在一个大型 Store 对象里，而是采用<strong>将每个状态片段拆分成独立 atom</strong>的方式。（Jotai 官方文档也解释说：“如果 Zustand 类似 Redux，那么 Jotai 就类似 Recoil。”）</p>
<p>这种结构的核心差异在于<strong>渲染优化的方式</strong>。Zustand 是一种<strong>自上而下（top-down</strong>）的方法，通过 selector 从单个 Store 中只提取所需部分。开发者必须像 <code>useStore((state) => state.count)</code> 这样亲自编写 selector，有时还要通过 memoization 来保持引用相等（referential equality）。而 Jotai 会自动构建 atom 之间的<strong>依赖图（dependency graph</strong>）。当某个 atom 变化时，它会进行<strong>自下而上（bottom-up</strong>）的传播，只精确地重新渲染依赖该 atom 的组件。在电子表格或画布编辑器这类数十个状态相互交织的场景中，这种自动依赖追踪会发挥很大作用。</p>
<p>从 Provider 的角度看，Jotai 处于一个有趣的中间位置。它默认使用全局 Store，无需 Provider 即可运行；需要时，也能用 <code>&#x3C;Provider></code> 包裹，创建隔离的 Store 作用域。借用 Jotai 官方文档的说法，Jotai 是 <strong>“context first, module second”</strong>，Zustand 则是 <strong>“module first, context second”</strong>。</p>
<h3 id="zustand-的选择"><a class="anchor" href="#zustand-的选择">Zustand 的选择</a></h3>
<p>Zustand 做出了最激进的选择。它默认是模块级单例，完全没有 Provider。这种选择带来的是<strong>极其简单的 API</strong>：用 <code>create</code> 创建 Store，再在组件中调用 hook，就结束了。</p>
<p>不过，“完全没有 Provider”准确来说是在描述它的<strong>默认设计</strong>。从 v4 开始，可以把 <code>createStore</code>（vanilla Store）与 React 的 <code>createContext</code> 组合起来，实现 <strong>Scoped Store</strong> 模式。</p>
<p><a href="https://tkdodo.eu/blog/zustand-and-react-context" target="_blank" rel="noopener noreferrer">React Query 维护者 TkDodo 的博客</a>深入讨论了这个模式。他提出的核心观点是，全局 singleton Store 有三个限制。</p>
<ul>
<li><strong>无法通过 Props 初始化</strong>：Store 在模块加载时创建，因此无法把服务端返回的数据或父组件的 props 用作初始值。</li>
<li><strong>测试隔离困难</strong>：每次测试都必须手动重置 Store。</li>
<li><strong>无法复用</strong>：如果在同一个页面渲染两个需要相同 Store 结构的组件，它们会共享状态。</li>
</ul>
<p>Scoped Store 模式可以解决全部三个问题。核心思路是：<strong>通过 Context 传递的不是状态值，而是 Store 实例的引用</strong>。（这与 Redux Provider 所做的事结构完全相同。）</p>
<p>具体实现如下。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { createStore, useStore } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'zustand'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { createContext, useContext, useState } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'react'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 1. 스토어 팩토리 함수 — props를 받아 스토어를 생성</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createSelectionStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">initialItems</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  createStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">SelectionState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    items: initialItems,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    selected: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    toggle</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> next</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(state.selected);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        next.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">has</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> next.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">delete</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> next.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">add</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { selected: next };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 2. Context 생성</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> SelectionStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ReturnType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> createSelectionStore>;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> SelectionContext</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createContext</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">SelectionStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 3. Provider — useState로 스토어를 한 번만 생성</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> SelectionProvider</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  children,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  initialItems,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  children</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> React</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">ReactNode</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  initialItems</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">store</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createSelectionStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(initialItems));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    &#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">SelectionContext.Provider value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{store}</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      {</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    &#x3C;/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">SelectionContext.Provider</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 4. 커스텀 훅 — Context에서 스토어를 꺼내 useStore로 구독</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useSelectionStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,>(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">selector</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> SelectionState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> store</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useContext</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(SelectionContext);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">store) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'SelectionProvider가 필요합니다'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(store, selector);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>现在，可以在同一个页面中随意渲染任意数量的独立多选组件。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 각 SelectionProvider가 자신만의 스토어 인스턴스를 가진다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SelectionProvider</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> initialItems</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'A'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'B'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'C'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">MultiSelect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SelectionProvider</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SelectionProvider</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> initialItems</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'X'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'Y'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'Z'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">MultiSelect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />  {</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 위 컴포넌트와 상태가 완전히 독립 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SelectionProvider</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>这里值得注意的是，通过 Context 传递的<strong>不是状态值，而是 Store 对象</strong>。即使状态值发生变化，Context 的 <code>value</code>（= Store 引用）也不会变化，因此<strong>不会因 Context 值变化而发生不必要的重新渲染。</strong> 真正的重新渲染由 <code>useStore</code> 内部的 <code>useSyncExternalStore</code> 基于 selector 处理。Context 的传输职责与 Zustand 的订阅职责得到了清晰分离。</p>
<p>TkDodo 还介绍了一个在 design system 的多选组件中实际采用该模式的案例。原本使用 <code>useState</code> + Context 管理内部状态的结构，在条目超过 50 个后出现性能下降；改为 Zustand 基于 selector 的订阅后，问题得到了解决。</p>
<p>v3 通过 <code>zustand/context</code> 提供的 <code>createContext</code> helper 在 v4 被删除后，这个模式逐渐固定为<strong>直接组合 React 原生 <code>createContext</code> 与 Zustand 的 <code>createStore</code>/<code>useStore</code></strong>。该 API 在 v5 中也保持不变，<a href="https://github.com/pmndrs/zustand/blob/main/docs/previous-versions/zustand-v3-create-context.md" target="_blank" rel="noopener noreferrer">Zustand 官方文档</a>也在 v4+ 迁移指南中介绍了这一模式。</p>
<h2 id="providerless-的阴影"><a class="anchor" href="#providerless-的阴影">ProviderLess 的阴影</a></h2>
<p>当然，没有 Provider 并不全是优点。下面整理一下我认为需要注意的地方。</p>
<h3 id="ssr-中的状态共享问题"><a class="anchor" href="#ssr-中的状态共享问题">SSR 中的状态共享问题</a></h3>
<p>模块级 singleton 在服务端环境中可能很危险。Node.js 服务器会在同一个进程中处理多个请求，而模块在进程内只加载一次。这意味着不同用户的请求可能会<strong>共享同一个 Store 实例</strong>。</p>
<p>这正是 Zustand 提供 <code>getInitialState</code>，并把服务端快照作为 <code>useSyncExternalStore</code> 第三个参数传入的原因。但仅凭这一点，未必能彻底隔离请求之间的状态。因此在 SSR 环境中，建议使用前面提到的 Scoped Store 模式（<code>createStore</code> + React Context），为每个请求创建新的 Store。</p>
<h3 id="测试隔离的困难"><a class="anchor" href="#测试隔离的困难">测试隔离的困难</a></h3>
<p>基于 Provider 的库只要在每个测试中用不同的 Provider 包裹，就能自然地隔离 Store。相反，Zustand 的模块级 singleton 可能导致状态在测试之间泄漏。因此，必须在每个测试的 <code>beforeEach</code> 中明确重置 Store。（我也曾经被这个问题折腾过一次。）</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 테스트 파일에서의 스토어 리셋 예시</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">beforeEach</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  useStore.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(useStore.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getInitialState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">());</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>在这里，Scoped Store 模式同样是解决方案。如果采用 Provider 包裹的方式，就能在每个测试中创建并注入新的 Store，从而无需重置逻辑即可实现完全隔离。</p>
<h3 id="缺少多实例"><a class="anchor" href="#缺少多实例">缺少多实例</a></h3>
<p>如果一个应用需要两个结构相同但彼此独立的 Store，使用 Provider 模式时，分别用不同 Provider 包裹即可。但在模块级 singleton 中，必须另行调用 Store 创建函数，生成不同的 Store 实例。例如，同一个页面里有两个独立的 tab panel，且各自的选择状态需要分开管理时，用全局 singleton 就很难自然地表达。</p>
<p>这种情况下，<code>createStore</code> + Context 模式同样是正确答案。只要每个 tab panel 组件渲染自己的 Provider，就能创建结构相同却完全独立的实例。Zustand 官方文档也建议在“可复用组件需要 Store”时使用这一模式。</p>
<h2 id="总结"><a class="anchor" href="#总结">总结</a></h2>
<p>总结目前讨论的内容，Zustand 的 ProviderLess 设计由以下四种机制共同实现。</p>
<ul>
<li><strong>模块级单例</strong>：Store 创建在 React 组件树之外，即 JavaScript 模块的作用域内。</li>
<li><strong>通过闭包封装状态</strong>：<code>vanilla.ts</code> 的 <code>createStoreImpl</code> 中，<code>state</code> 变量与 <code>listeners</code> Set 被封闭在闭包内，阻止外部访问。</li>
<li><strong>自有 Pub/Sub 系统</strong>：它不遍历 Fiber 树，而是直接管理 <code>Set&#x3C;Listener></code>，向订阅者通知状态变化。</li>
<li><strong>通过 <code>useSyncExternalStore</code> 集成 React</strong>：把外部 Store 的状态变化安全地同步到 React 的渲染周期。</li>
</ul>
<p>归根结底，Zustand 提出的问题是：“状态一定要活在 React 里面吗？”Zustand 的回答很明确：把状态放在 React 外，需要时搭一座桥就好。这座桥就是 <code>useSyncExternalStore</code>。</p>
<p>当然，这种方式并非在所有场景下都是最佳选择。面对 SSR、测试隔离、多实例等情况，基于 Provider 的设计可能更合适。没有唯一的正确答案，但如果理解了各个库选择了怎样的设计 trade-off，就能根据场景选出合适的工具。</p>
<p>也建议读到这里的各位，有机会亲自打开正在使用的某个库的源码看一看。你可能会发现官方文档中没有的深度。</p>
<p><picture><source type="image/webp" srcset="/content/240818/7.216w.webp 216w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/240818/7.jpeg" alt="手绘角色说着「等一下!!」" width="216" height="233" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<h3 id="另外还有一则新消息"><a class="anchor" href="#另外还有一则新消息">另外，还有一则新消息</a></h3>
<p>在查找上述内容时得知，<strong>Zustand v5.0.0 已于 2024 年 10 月正式发布</strong>。</p>
<p>有意思的是，v5 几乎没有新功能。v4.x 已经在添加新功能的同时逐步将原有 API 标记为 deprecated，因此 v5 更像是一次<strong>整理（cleanup）版本</strong>。主要变更如下。（详细内容请参阅<a href="https://github.com/pmndrs/zustand/releases" target="_blank" rel="noopener noreferrer">发布页面</a>和<a href="https://zustand.docs.pmnd.rs/reference/migrations/migrating-to-v5" target="_blank" rel="noopener noreferrer">迁移指南</a>。）</p>
<ul>
<li>最低要求提升至 <strong>React 18、TypeScript 4.5 及以上版本</strong>。</li>
<li><strong>删除了 <code>getServerState</code></strong>。（由 <code>useSyncExternalStore</code> 的第三个参数取代）</li>
<li><strong>停止支持 ES5</strong>。</li>
<li>删除了在 <code>create</code> 函数中<strong>指定自定义 equality 函数</strong>的功能。</li>
<li><strong>改进了 <code>shallow</code> 函数</strong>，使其支持 iterable 对象。</li>
</ul>
<p>从 v4 迁移到 v5 时，建议先升级至 v4 的最新版本。v4 最新版会显示 deprecation 警告，因此先解决这些警告，再升级到 v5，就能顺利完成迁移。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/reference/react/useSyncExternalStore" target="_blank" rel="noopener noreferrer">React useSyncExternalStore</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://jotai.org/docs/basics/comparison" target="_blank" rel="noopener noreferrer">Jotai Comparison</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://interbolt.org/blog/react-ui-tearing/" target="_blank" rel="noopener noreferrer">InterBolt, Concurrent React, External Stores, and Tearing</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>前端</category>
            <category>React</category>
        </item>
        <item>
            <title><![CDATA[理解压缩算法]]></title>
            <link>https://hooninedev.com/zh-CN/240706</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/240706</guid>
            <pubDate>Sat, 06 Jul 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊聊软件中的压缩算法。 本文写给需要把大体积构建产物上传到服务器或存储、正在 zip、tar.gz、tar.zst 之间犹豫的开发者。读完之后，你会知道主流压缩格式在速度和压缩率上的差异出在哪里，并获得在 tar.gz 与 tar.zst 之间做选择的判断标准。 我曾负责改进一个公司内部项目的部署流程。它需要把体积很大的构建产物上传到 S3，我也由此切身体会到：构建目录的大小会直接影响上...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊软件中的压缩算法。</p>
<p>本文写给需要把大体积构建产物上传到服务器或存储、正在 zip、tar.gz、tar.zst 之间犹豫的开发者。读完之后，你会知道主流压缩格式在速度和压缩率上的差异出在哪里，并获得在 tar.gz 与 tar.zst 之间做选择的判断标准。</p>
<p>我曾负责改进一个公司内部项目的部署流程。它需要把体积很大的构建产物上传到 S3，我也由此切身体会到：构建目录的大小会直接影响上传时间与存储成本。于是一个问题自然出现了：怎样才能更高效地压缩并上传这些文件？</p>
<p>真正开始研究后，我发现压缩格式比想象中多得多：zip、gzip、zstd、bzip2、xz 等等。名字都很相似，但要找到一份清楚说明它们有何不同、分别适合什么场景的资料并不容易。（我原以为压缩都差不多，后来才发现世界很大，让文件变小的方法也很多。）</p>
<p>进入比较之前，先说明一个共同的根源。如今的主流压缩工具几乎都是 1977 年发表的 LZ77 的后代。LZ77 把已经处理过的一段数据（滑动窗口）当作字典，把重复出现的模式替换成“往回数若干个字符，再复制若干个字符”这样的简短引用。窗口越大，越能捕捉相距更远的重复，但计算量和内存开销也随之增加。ZIP 和 GZIP 使用的 DEFLATE，就是在此基础上加入霍夫曼编码（给出现频率高的符号分配较短比特的熵编码）。LZ77 与同辈的 LZ78 如何分道扬镳，另见 <a href="/240701" target="_blank" rel="noopener noreferrer">LZ77 与 LZ78 的区别</a>。</p>
<p>评价压缩性能时通常要看两个维度：<strong>压缩率</strong>，也就是能缩小多少；以及<strong>压缩速度</strong>，也就是多久能完成。追求更高压缩率通常需要更多计算，压缩时间也会更长。现实中的压缩策略，核心就是在两者之间找到合适的平衡点。</p>
<p>有了这些基础，下面逐一比较几种常见格式。</p>
<h2 id="zip"><a class="anchor" href="#zip">ZIP</a></h2>
<p>ZIP 是 Phil Katz 在 1989 年创造的文件格式，内部通常使用 <strong>DEFLATE 算法</strong>，也就是 LZ77 与 Huffman coding 的组合。重要的是，ZIP 本身不是“压缩算法”，而是“文件格式，也就是容器”。ZIP 这个容器里装着由 DEFLATE 等算法压缩的数据。</p>
<p>ZIP 的特点是<strong>分别压缩每个文件</strong>。这种逐个文件分别压缩的结构被称为非固实归档（Non-solid Archive）。与之相对，把所有文件连成一个流再一次性压缩的方式叫固实归档。得益于非固实结构，可以只取出归档中的某一个文件。另一方面，它无法利用文件之间重复的数据，所以压缩率可能低于后面要介绍的 tar.gz。</p>
<p>Windows、macOS、Linux 等大多数操作系统都能在不安装额外软件的情况下直接支持 ZIP，因此当跨平台兼容性很重要时，它是最稳妥的选择。</p>
<h2 id="gzipgnu-zip"><a class="anchor" href="#gzipgnu-zip">GZIP（GNU Zip）</a></h2>
<p>GZIP 和 ZIP 一样，内部使用 <strong>DEFLATE 算法</strong>。既然算法相同，为什么还需要另一种格式？ZIP 同时承担把多个文件放进一个归档的容器职责，而 GZIP 专门用于压缩<strong>单个文件或单个数据流</strong>。</p>
<p>如果要用 GZIP 压缩多个文件或一个目录，需要先用 TAR 把它们打包成一个归档，再用 GZIP 压缩。这两步最终生成 <code>.tar.gz</code> 或 <code>.tgz</code> 文件。</p>
<p>GZIP 的文件结构由 RFC 1952 规定，非常简单：一个<strong>固定的 10 字节头部</strong>、可选的扩展头部（包含原文件名、注释等）、DEFLATE 压缩数据，以及包含 CRC-32 校验值和原始大小的<strong>8 字节尾部</strong>。CRC-32 用于验证解压数据是否与原数据一致。因此，GZIP 可以理解为包裹 DEFLATE 数据流的一层轻量封装。</p>
<p>DEFLATE 使用的滑动窗口<strong>最大为 32KB</strong>。这个大小会限制 GZIP 的压缩率，因为相隔 32KB 以上的模式无法互相引用。GZIP 还提供 1 到 9 的压缩级别。级别 1 速度快但压缩率较低，约为 60%；级别 9 较慢，但能达到约 75%。默认级别 6 是速度和压缩率之间的平衡点。</p>
<p>在 Unix/Linux 环境中，GZIP 长期被当作分发源代码、压缩日志和软件包的标准工具。它也仍然广泛用于通过 <code>Content-Encoding: gzip</code> 进行 HTTP 压缩，不过在这一领域正逐渐被 Brotli 取代。</p>
<h2 id="zstdzstandard"><a class="anchor" href="#zstdzstandard">ZSTD（Zstandard）</a></h2>
<p>ZSTD 是 Meta（原 Facebook）的 Yann Collet 开发的压缩算法，于 2016 年开源。它最大的优势是：在保持与 GZIP 相近压缩率的同时，<strong>压缩和解压速度都快得多</strong>。</p>
<p>ZSTD 的内部结构大致分成三步。首先，LZ77 系的<strong>匹配查找器（Match Finder）<strong>从输入中寻找重复模式。然后把找到的字面量、匹配长度、偏移量等信息编码成</strong>序列</strong>。最后用<strong>熵编码</strong>压缩这些序列。它不用 GZIP 的霍夫曼编码，而是使用 <strong>FSE（Finite State Entropy）</strong>。FSE 是基于 ANS（Asymmetric Numeral Systems）理论的熵编码器。ANS 每处理一个符号就更新一个由单个整数表示的状态值，出现越频繁的符号输出的比特越少。它的目标是以接近霍夫曼编码的速度，达到接近算术编码（Arithmetic Coding）的压缩率。霍夫曼编码只能为每个符号分配整数个比特，而 FSE 可以表达相当于小数比特的概率，因此更接近理论最优值。（名字听起来很宏大，核心其实只是“用更聪明的方法，以更少比特表示同样的数据”。）</p>
<p>匹配查找器也会随压缩级别改变策略。低级别 1 至 4 使用简单哈希表快速搜索；中等级别 5 至 12 比较多个候选，并用 Lazy 策略选择更好的匹配；高级别 13 至 22 使用二叉树和动态规划寻找接近最优的匹配。级别可以从 1 精细调到 22，因此实时传输可以用低级别，归档则可以用高级别。</p>
<p>在 Silesia Corpus 基准测试中，ZSTD 默认级别 3 的压缩速度约为 300MB/s，解压速度约为 1,200MB/s。GZIP 默认级别 6 则只有约 34MB/s 和 380MB/s。<strong>ZSTD 压缩约快 8 倍、解压约快 3 倍，压缩率反而以 3.17 高于 GZIP 的 3.09。</strong> 这些数字最直观地说明了 ZSTD 如何改善传统权衡。</p>
<p>它的生态采用也在快速扩大。ZSTD 被用于 Linux 内核模块压缩和文件系统透明压缩，Arch Linux、Fedora、Debian、Ubuntu 等主要发行版也把它采用为软件包压缩的默认格式。从 2025 年 2 月发布的 v1.5.7 开始，最多使用 4 个线程的<strong>多线程压缩默认启用</strong>，与单线程 GZIP 的实际速度差进一步拉大。AWS 也曾表示，把内部服务从 gzip 切换到 zstd 后，S3 存储减少了约 30%。</p>
<h2 id="bzip2"><a class="anchor" href="#bzip2">BZIP2</a></h2>
<p>BZIP2 通过多阶段转换来压缩数据，核心流程如下。</p>
<ol>
<li><strong>RLE（Run-Length Encoding）</strong>：减少原始数据中连续的重复</li>
<li><strong>BWT（Burrows-Wheeler Transform）</strong>：重新排列数据，使其更容易压缩</li>
<li><strong>MTF（Move-to-Front Transform）</strong>：把 BWT 输出转换成数字序列</li>
<li><strong>RLE</strong>：再次减少 MTF 结果中的重复</li>
<li><strong>Huffman Coding</strong>：最后按频率进行编码</li>
</ol>
<p>BZIP2 的压缩率高于 GZIP，但压缩和解压都较慢。它长期用于压缩率重要而速度不那么重要的归档场景。</p>
<p>不过，它最后一次发布是 2019 年的 v1.0.8，目前没有活跃开发。越来越多基准测试表明 ZSTD 在压缩率和速度两方面都胜过 BZIP2，因此新项目更常选择 ZSTD。</p>
<h2 id="xz"><a class="anchor" href="#xz">XZ</a></h2>
<p>XZ 是使用 <strong>LZMA2</strong> 的压缩格式。Igor Pavlov 开发的 LZMA（Lempel-Ziv-Markov chain Algorithm）把基于 LZ77 的字典压缩与范围编码（Range Encoding）结合起来。LZMA2 与其说是“改进版 LZMA”，不如说更接近包裹 LZMA 数据流的<strong>容器格式</strong>。它主要增加了多线程压缩与解压，以及对不可压缩数据的高效处理。</p>
<p>在本文介绍的格式中，XZ 拥有<strong>最高的压缩率</strong>。代价是压缩速度很慢，内存占用也很大。它适合把节省存储空间放在最高优先级的归档场景。</p>
<p>但在 2024 年 3 月，<strong>XZ 的核心库 xz-utils 被发现存在后门，引发严重的供应链安全事件 CVE-2024-3094</strong>。攻击者通过长达两年的社会工程获得维护者权限，该漏洞获得最高的 CVSS 10.0 分。主要发行版立刻回滚到安全版本，但这起事件给开源供应链安全敲响了警钟。（XZ 本身的技术价值依然存在，不过选择工具时也值得了解这段背景。）</p>
<h2 id="tar"><a class="anchor" href="#tar">TAR</a></h2>
<p>TAR（Tape Archive）本身不是压缩算法，而是一种<strong>把多个文件和目录组合成一个归档文件</strong>的工具与格式。顾名思义，它最初是为磁带备份设计的。磁带是一种顺序介质，因此连续拼接数据是很自然的结构。</p>
<p>TAR 的内部结构出乎意料地简单。所有数据都按<strong>512 字节块</strong>处理。每个文件前有一个 512 字节头部，其中记录文件名（最多 100 字节）、文件模式、所有者 UID/GID、大小、修改时间和校验值等元数据。文件数据紧跟在头部之后，并填充到 512 字节的整数倍。归档结尾用两个 512 字节的全零块表示。现代大多数 TAR 实现遵循 POSIX 定义的 <strong>UStar</strong>（Unix Standard TAR）格式，支持最长 256 字节的文件名和更多元数据字段。</p>
<p>关键在于，TAR 会原样保留权限、所有权、时间戳和符号链接等<strong>Unix 文件系统元数据</strong>。ZIP 有时无法完整保留这些 Unix 特有信息，因此在服务器部署中 TAR 往往更合适。</p>
<p>TAR 本身不会缩小数据。因为头部和填充，它反而比原始数据略大。真正的压缩需要与 GZIP、BZIP2、XZ、ZSTD 等工具结合完成。这就是 <code>.tar.gz</code>、<code>.tar.bz2</code>、<code>.tar.xz</code>、<code>.tar.zst</code> 等扩展名存在的原因。TAR 负责“打包”，压缩工具负责“缩小”，这是 Unix “做好一件事”哲学的典型例子。</p>
<p>TAR 是 Unix/Linux 环境的标准归档方式，而 Windows 可能需要 7-Zip 等额外软件。</p>
<h2 id="也了解一下-brotli"><a class="anchor" href="#也了解一下-brotli">也了解一下 Brotli</a></h2>
<p>前端开发者也应该了解 <strong>Brotli</strong>。它是 Google 开发的压缩算法，并于 2015 年作为 HTTP 数据流压缩规范 <code>Content-Encoding: br</code> 标准化。</p>
<p>所有主流浏览器都在 HTTPS 环境中支持 Brotli，全球支持率超过 96%。它通常比 GZIP <strong>多压缩约 15% 至 25%</strong>，尤其适合 JavaScript、CSS、HTML 等文本型静态文件。Cloudflare 等主要 CDN 已将它作为默认压缩方式，现代 Web 优化的常见做法可以概括为“优先 Brotli，回退到 GZIP”。</p>
<p>如果构建产物上传到 S3 并通过 CDN 提供服务，预先用 Brotli 压缩静态文件可以显著减少网络传输。（当时还没有足够的项目资料支持立刻引入它，但作为开发者，了解并在以后重新评估这项选择仍有价值。）</p>
<h2 id="为什么-targz-的压缩率高于-zip"><a class="anchor" href="#为什么-targz-的压缩率高于-zip">为什么 tar.gz 的压缩率高于 ZIP？</a></h2>
<p>原因在于<strong>固实归档</strong>（Solid Archive）与<strong>非固实归档</strong>（Non-solid Archive）的区别。</p>
<p>tar.gz 会先用 TAR 把所有文件组合成一段连续数据流，再由 GZIP 一次压缩整个数据流。这样就能识别和利用<strong>跨文件的重复数据</strong>，这就是固实归档。如果构建目录里有几十个结构相似的 JavaScript bundle，文件 A 中出现的模式在文件 B 再次出现时就可以引用。由于不必为每个压缩流分别记录头部、校验值和目录（Table of Contents），元数据开销也会减少。</p>
<p>ZIP 属于非固实方式，会独立压缩每个文件，因此无法利用文件之间的冗余。即使文件 A 和 B 包含相同代码块，两条独立的 DEFLATE 数据流也不知道对方存在。这就是 tar.gz 通常比 ZIP 获得高 5% 至 15% 压缩率的原因。构建产物中结构相似的文件越多，差距往往越大。</p>
<p>固实归档也有明显缺点。</p>
<ul>
<li>如果只想取出某一个文件，可能仍要<strong>先解压它之前的全部数据</strong>。所有文件处于同一数据流，无法直接跳到中间位置。ZIP 可以随机访问单个文件，因此在经常提取特定文件的场景里可能更合适。</li>
<li>如果归档的一部分损坏，<strong>损坏点之后的所有数据都可能无法恢复</strong>。非固实格式有时只会丢失受损文件，其余内容仍能保留。</li>
</ul>
<h2 id="2024-年选择了-targz现在会怎么选"><a class="anchor" href="#2024-年选择了-targz现在会怎么选">2024 年选择了 tar.gz，现在会怎么选？</a></h2>
<p>本节是 2026 年补充的。当时选择 tar.gz 是因为兼容性和稳定性。产物上传 S3 后需要在多种环境中解压，因此几乎随处可用的 tar.gz 是安全选择。</p>
<p>但如果今天再次遇到同样的情况，我会认真考虑 <strong>tar.zst（TAR + ZSTD）</strong>。回想前面的基准数字。</p>
<p>GZIP 默认级别的压缩速度为 34MB/s，ZSTD 默认级别则为 300MB/s。对一个 2GB 构建目录做简单计算，GZIP 约需 60 秒，ZSTD 约需 7 秒。从 ZSTD v1.5.7 开始默认启用的多线程能力最多使用 4 个线程，实际体感时间会更短。在 CI/CD 流水线中，这个差距会在每次部署时不断累积，不容忽视。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="sh" data-theme="github-dark github-light"><code data-language="sh" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># tar.zst 생성 (멀티스레드 자동 활용)</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">tar</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --zstd</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -cf</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> archive.tar.zst</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> directory/</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># 또는 압축 레벨 지정 (-T0은 사용 가능한 모든 코어 활용)</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">tar</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -cf</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> archive.tar.zst</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -I</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'zstd -3 -T0'</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> directory/</span></span></code></pre></figure>
<p>ZSTD 的压缩率也与 GZIP 相当甚至更高，因此“为了速度而牺牲压缩率”的权衡实际上已经不存在。它既更快，结果也更小。</p>
<p>不过，仍然必须确认接收环境能够解压 zstd。主要 Linux 发行版已经包含它，macOS 也可以通过 Homebrew 的 <code>brew install zstd</code> 轻松安装。老旧系统或最小化安装环境可能需要额外安装，因此应提前检查团队使用的所有环境。如果兼容性是第一优先级，tar.gz 仍然是最稳妥的通用选择。</p>
<h2 id="一览对比"><a class="anchor" href="#一览对比">一览对比</a></h2>
<table>
<thead>
<tr>
<th>格式</th>
<th>算法</th>
<th>压缩率</th>
<th>速度</th>
<th>主要特点</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>ZIP</strong></td>
<td>DEFLATE</td>
<td>中等</td>
<td>快</td>
<td>跨平台、非固实</td>
</tr>
<tr>
<td><strong>GZIP</strong></td>
<td>DEFLATE</td>
<td>中等</td>
<td>快</td>
<td>单一数据流、常与 TAR 结合</td>
</tr>
<tr>
<td><strong>ZSTD</strong></td>
<td>Zstandard</td>
<td>高</td>
<td>非常快</td>
<td>级别可调、现代标准</td>
</tr>
<tr>
<td><strong>BZIP2</strong></td>
<td>BWT+MTF+Huffman</td>
<td>高</td>
<td>慢</td>
<td>开发基本停滞</td>
</tr>
<tr>
<td><strong>XZ</strong></td>
<td>LZMA2</td>
<td>非常高</td>
<td>非常慢</td>
<td>最高压缩率、需注意安全背景</td>
</tr>
<tr>
<td><strong>Brotli</strong></td>
<td>Brotli</td>
<td>高</td>
<td>中等</td>
<td>专为 Web 优化</td>
</tr>
</tbody>
</table>
<h2 id="结语"><a class="anchor" href="#结语">结语</a></h2>
<p>深入研究压缩之前，我坦率地想过：“直接用 zip 打包不就行了吗？”真正处理超过 2GB 的构建目录后，我才直观感受到，算法选择会显著改变上传时间和成本。</p>
<p>每种压缩格式都有自己的设计哲学和权衡：ZIP 的兼容性、GZIP 的通用性、ZSTD 的速度、XZ 的压缩率。不存在适用于所有情况的“最佳”方案，正确选择取决于项目约束。</p>
<p>理解那些平时不假思索就使用的工具，能帮助我们在下一次遇到类似问题时作出更好的判断。希望这篇文章能给需要选择压缩算法的人提供一点参考。</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>随想</category>
            <category>软件</category>
        </item>
        <item>
            <title><![CDATA[LZ77 与 LZ78 的区别]]></title>
            <link>https://hooninedev.com/zh-CN/240701</link>
            <guid isPermaLink="false">https://hooninedev.com/zh-CN/240701</guid>
            <pubDate>Mon, 01 Jul 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[这篇文章想聊聊 LZ77 与 LZ78 有何不同。 本文写给使用 zip、gzip、zstd 这类压缩工具、好奇其中基于字典的压缩如何运作的开发者。读完之后，你能说明这两种算法在处理字典上的区别，以及如今的主流压缩工具出自哪一系。 比较构建产物该用哪种压缩格式时，最终都会一路追溯到这两种算法。 基于字典的压缩 无损压缩（Lossless Compression）是一种能够完整还原原始数据的压缩方式...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊 <strong>LZ77 与 LZ78 有何不同</strong>。</p>
<p>本文写给使用 zip、gzip、zstd 这类压缩工具、好奇其中基于字典的压缩如何运作的开发者。读完之后，你能说明这两种算法在处理字典上的区别，以及如今的主流压缩工具出自哪一系。</p>
<p>比较构建产物该用哪种压缩格式时，最终都会一路追溯到这两种算法。</p>
<h2 id="基于字典的压缩"><a class="anchor" href="#基于字典的压缩">基于字典的压缩</a></h2>
<p>无损压缩（Lossless Compression）是一种能够完整还原原始数据的压缩方式。它不同于图像和音频中常见的有损压缩（Lossy Compression）：解压后的数据与原始数据连一个比特都不会不同。源代码和构建产物对数据完整性要求很高，因此必须使用无损压缩。</p>
<p>无损压缩的核心思想是<strong>利用数据中存在的统计冗余</strong>。把反复出现的模式替换成更短的表示，整体体积就会减小。</p>
<p>其中，<strong>基于字典（Dictionary-Based）的方法</strong>是无损压缩中被广泛使用的算法家族。这里的字典，是一种让压缩器能够再次指向之前已经见过的数据的机制。这个机制是走过的数据本身，还是另外积累的列表，正是 LZ77 与 LZ78 的分野。Jacob Ziv 和 Abraham Lempel 在 1977 年发表于 IEEE Transactions on Information Theory 的<a href="https://doi.org/10.1109/TIT.1977.1055714" target="_blank" rel="noopener noreferrer">论文</a>中提出的 <strong>LZ77</strong>，以及两人第二年<a href="https://doi.org/10.1109/TIT.1978.1055934" target="_blank" rel="noopener noreferrer">接着发表的论文</a>中的 <strong>LZ78</strong>，正是这个家族的始祖。“LZ”取自两位研究者姓氏的首字母。后来出现的基于字典的压缩算法（DEFLATE、LZMA、LZ4、Zstd 等）都能追溯到这两种算法。</p>
<p>举个简单的例子。如果“Linux”这个单词重复出现 100 次，从第二次开始就不再写原文，而是写一个指向“前面见过的那一段”的短引用。只要引用比原文短，整体大小就会下降。</p>
<p>那么，LZ77 和 LZ78 具体有什么区别？</p>
<h2 id="lz77滑动窗口方式"><a class="anchor" href="#lz77滑动窗口方式">LZ77：滑动窗口方式</a></h2>
<p>LZ77 <strong>不会单独建立一份显式字典</strong>，而是把输入流中的一段区域直接当作字典。这段区域称为<strong>滑动窗口</strong>，因为它会按已处理的长度向前滑动。</p>
<p>窗口分成两个区域。</p>
<ul>
<li><strong>搜索缓冲区（Search Buffer）</strong>：已经处理过的数据，承担字典的作用。</li>
<li><strong>前向缓冲区（Look-ahead Buffer）</strong>：尚未处理、接下来要压缩的数据。</li>
</ul>
<p>算法会检查前向缓冲区的开头是否曾在搜索缓冲区中出现。如果找到相同模式，就把这次匹配编码为（<strong>距离、长度、下一个字符</strong>）的元组。距离表示要向后退多少字符才能找到匹配的起点，长度表示匹配持续多少字符。原论文把第一个值记为缓冲区内的位置，现在的实现则把同样的信息记为距离。</p>
<p>例如，用本文后面给出的教学用编码器压缩字符串 <code>"banana_banana"</code>，会得到五个 LZ77 token：<code>(0,0,b)</code> <code>(0,0,a)</code> <code>(0,0,n)</code> <code>(2,3,_)</code> <code>(7,5,a)</code>。前三个是第一次见到的字符，没有匹配，只带下一个字符。第二个 <code>"banana"</code> 只用最后一个 token <code>(7,5,a)</code> 就处理完了，意思是“向后退 7 个字符，复制 5 个字符，再接上 <code>a</code>”。之所以不复制全部 6 个字符，是因为这个编码器总是把输入的最后一个字符留在“下一个字符”的位置上。</p>
<p>在 <code>(2,3,_)</code> 中，长度 3 比距离 2 还长。解码器在已经写出 <code>ban</code> 的状态下，从往回 2 个字符处的 <code>a</code> 开始复制；复制第三个字符时，读到的是刚刚写出的 <code>a</code>。于是得到 <code>ana</code>，再接上 <code>_</code>。逐个字符跟下来是这样的。</p>
<table>
<thead>
<tr>
<th>步骤</th>
<th>读到的字符</th>
<th>输出</th>
</tr>
</thead>
<tbody>
<tr>
<td>开始</td>
<td>无</td>
<td><code>ban</code></td>
</tr>
<tr>
<td>复制 1</td>
<td>第 2 个字符 <code>a</code></td>
<td><code>bana</code></td>
</tr>
<tr>
<td>复制 2</td>
<td>第 3 个字符 <code>n</code></td>
<td><code>banan</code></td>
</tr>
<tr>
<td>复制 3</td>
<td>第 4 个字符 <code>a</code>（复制 1 中写出的）</td>
<td><code>banana</code></td>
</tr>
<tr>
<td>下一个字符</td>
<td>token 中的 <code>_</code></td>
<td><code>banana_</code></td>
</tr>
</tbody>
</table>
<p><a href="https://www.rfc-editor.org/rfc/rfc1951" target="_blank" rel="noopener noreferrer">RFC 1951</a> 也明确规定了同样的行为：若最后两个字节是 X、Y，<code>&#x3C;length = 5, distance = 2></code> 会追加 X,Y,X,Y,X。之所以能这样做，是因为字典本身就是刚刚还原出来的数据。</p>
<p>这种方式<strong>不另外保存或传输字典。</strong> 解码器在解压过程中自行重建搜索缓冲区，因此字典被隐式地嵌入数据本身。由于引用指向前面的数据，解压基本上要从开头按顺序进行。不过，引用能够到达的范围只到窗口为止。RFC 1951 把 DEFLATE 的引用限制在最多往前 32K 字节。zlib 的 <code>Z_FULL_FLUSH</code> 会重置压缩状态，使得在前面的压缩数据损坏或需要随机访问时，可以从该位置重新开始解压。<a href="https://github.com/madler/zlib/blob/v1.3.1/zlib.h" target="_blank" rel="noopener noreferrer">zlib.h</a> 警告，用得太频繁会严重降低压缩率。</p>
<p>窗口大小与压缩率之间存在直接的权衡。窗口越大，就越能引用距离更远的模式，压缩率也越高；但内存占用会增加，要查找的候选也更多，匹配搜索通常也会变慢。</p>
<h2 id="lz78显式字典方式"><a class="anchor" href="#lz78显式字典方式">LZ78：显式字典方式</a></h2>
<p>与 LZ77 不同，LZ78 会在压缩过程中<strong>构建一份显式字典</strong>，不使用滑动窗口。它把之前见过的模式保存成带索引的字典项，之后遇到相同模式时用索引替换。</p>
<p>LZ78 的输出单位是（<strong>字典索引、下一个字符</strong>）形式的标签。编码器先找到字典中最长的匹配项，再输出该项的索引和打破匹配的下一个字符，随后把 <strong>“刚才匹配的项＋新字符”</strong> 加入字典。字典会在处理过程中逐步增长。</p>
<p>用 LZ78 压缩同一个 <code>"banana_banana"</code>，会得到八个 token，字典里积累七个条目。token 是 <code>(0,b)</code> <code>(0,a)</code> <code>(0,n)</code> <code>(2,n)</code> <code>(2,_)</code> <code>(1,a)</code> <code>(3,a)</code> <code>(7,)</code>，字典是 <code>1:b</code> <code>2:a</code> <code>3:n</code> <code>4:an</code> <code>5:a_</code> <code>6:ba</code> <code>7:na</code>。第二个 <code>"banana"</code> 被拆成生成 <code>ba</code>、<code>na</code>、<code>na</code> 的三个 token，而 LZ77 只用一个 token 就处理完了这一段。LZ78 的字典条目每次只能变长一个字符，所以同一个词要见过好几次，才会出现长条目。最后的 <code>(7,)</code> 是输入结束、没有下一个字符的情况。这个编码器不把 token 打包成比特，因此不能用 token 数量来比较压缩率。</p>
<p>这两组 token 可以用下面的代码复现。这是 2026-10-08 在 macOS 26.6.2、Node v24.16.0 上把代码保存为文件并运行 <code>node lz.mjs</code> 得到的结果。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// LZ77: (거리, 길이, 다음 문자). 가장 긴 일치를 고르고, 겹친 복사를 허용한다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> lz77</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">s</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">win</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 4096</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> out</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> []</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> best </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { d: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, l: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> j </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">max</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> win); j </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> i; j</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">      // 입력의 마지막 글자는 매치에 넣지 않고 다음 문자로 남긴다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s[j </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s[i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l]) l</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (l </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> best.l) best </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { d: i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> j, l }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    out.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([best.d, best.l, s[i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> best.l]])</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> best.l </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> out</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// LZ78: (사전 인덱스, 다음 문자). 인덱스 0 은 빈 문자열이다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> lz78</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">s</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> dict</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> out</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> []</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; i</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> c</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s[i]</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">has</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) { w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c; </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">continue</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      out.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c), </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">''</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 입력이 끝나 다음 문자가 없다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      break</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    out.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(w) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, c])</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c, dict.size </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { out, dict: [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">keys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()] }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 두 디코더 모두 토큰만 받는다. 사전은 전달받지 않는다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> unlz77</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">tokens</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">d</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">l</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">c</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">of</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> tokens) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> k </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; k </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l; k</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o[o.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> d] </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 방금 쓴 글자도 다시 읽는다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> unlz78</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">tokens</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> dict</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">''</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">i</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">c</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">of</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> tokens) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> entry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> dict[i] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> entry</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(entry) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 인코더와 같은 순서로 사전을 다시 쌓는다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> s</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'banana_banana'</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> a</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> lz77</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(s)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> lz78</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(s)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'LZ77'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, a.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(([</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">d</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">l</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">c</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `(${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">d</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">},${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">l</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">},${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">c</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">})`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">join</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">' '</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">), </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">unlz77</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(a) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'LZ78'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, b.out.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(([</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">i</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">c</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `(${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">i</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">},${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">c</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">})`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">join</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">' '</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">), </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">unlz78</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(b.out) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'사전'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, b.dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">e</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">k</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">k</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}:${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">e</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">join</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">' '</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">))</span></span></code></pre></figure>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>LZ77 (0,0,b) (0,0,a) (0,0,n) (2,3,_) (7,5,a) true</span></span>
<span data-line=""><span>LZ78 (0,b) (0,a) (0,n) (2,n) (2,_) (1,a) (3,a) (7,) true</span></span>
<span data-line=""><span>사전 1:b 2:a 3:n 4:an 5:a_ 6:ba 7:na</span></span></code></pre></figure>
<h2 id="两种方式的分歧"><a class="anchor" href="#两种方式的分歧">两种方式的分歧</a></h2>
<p>代码里的两个解码器都只接收 token。也就是说，<strong>LZ78 同样不传输字典</strong>。解码器一边读 token，一边按与编码器相同的顺序添加条目，于是重新积累出同一份字典。不传输字典是两种算法的共同点，二者真正分开的地方在于<strong>遗忘旧内容的方式</strong>。LZ77 的窗口向前滑动，旧数据会自然被遗忘。LZ78 的字典如果放任不管就会一直增长。原论文在每个固定长度的分块结束时把字典整个清空，实际的实现则给字典大小设上限，满了以后要么冻结，要么清空，要么重用部分条目。</p>
<p>LZ78 最著名的变体是 <strong>LZW</strong>（Lempel-Ziv-Welch）。Terry Welch 在 1984 年发表了这项改进，它被用于 GIF 图像格式和 Unix 的 <code>compress</code> 工具（扩展名为 <code>.Z</code>）。这两种实现都给字典设了上限。<a href="https://www.w3.org/Graphics/GIF/spec-gif89a.txt" target="_blank" rel="noopener noreferrer">GIF89a 规范</a> 把编码限制在最多 12 位（最大值 4095），并另设一个把字典恢复到初始状态的 Clear code。根据 <a href="https://github.com/vapier/ncompress/blob/v5.0/compress.1" target="_blank" rel="noopener noreferrer">ncompress 手册页</a>，<code>compress</code> 在编码长度达到 <code>-b</code> 上限（默认 16 位）之后会持续观察压缩率，一旦压缩率下降，就丢弃字典并从头重建。</p>
<p>两篇论文各自在自己的模型下证明了渐近最优性。1977 年的论文证明了 LZ77 的压缩率会一致地逼近为事先已知信源而设计的编码所能达到的下界（"uniformly approaches the lower bounds"），1978 年的论文则以个体序列（individual sequence）为对象，证明了 LZ78 的 incremental parsing 是渐近最优的。由于模型不同，仅凭这些结果无法比较两者。</p>
<h2 id="lz77-一系的后代"><a class="anchor" href="#lz77-一系的后代">LZ77 一系的后代</a></h2>
<p>那么，现代压缩算法是从哪一边延续下来的？今天的主流压缩算法大多是 <strong>LZ77 的后代</strong>。</p>
<p>从 Storer 和 Szymanski 1982 年的论文延续而来的 <strong>LZSS</strong> 是 LZ77 的变体。如果匹配太短，指针比原文还长，它就不输出指针，而是直接输出字面量（原始字符）。</p>
<p><strong>DEFLATE</strong> 由 Phil Katz 为 PKZIP 2 设计，1996 年其规范整理为 RFC 1951。RFC 1951 把 DEFLATE 描述为 LZ77 与<strong>霍夫曼编码</strong>（为高频符号分配更短比特串的熵编码）的组合。它的输出是字面量与（长度, 距离）对交错的序列，并把字面量和匹配长度合并进同一个字母表（0～285），用同一套霍夫曼编码来区分。大多数 ZIP 程序默认使用的压缩方式、GZIP 和 PNG 都使用这个 DEFLATE。也就是说，我们每天接触的 <code>.zip</code>、<code>.gz</code>、<code>.png</code> 文件大多是 LZ77 的直系后代。</p>
<p>LZ77 一系成为主流的原因无法归结为一个，但规范自己明确提出的一点是专利。RFC 1951 一方面指出 LZ77 的许多变体都有专利，另一方面明确写道 DEFLATE 格式可以很容易地以不受专利约束的方式实现。<a href="https://www.w3.org/TR/png-3/" target="_blank" rel="noopener noreferrer">PNG 规范</a> 在开头就把 PNG 介绍为不受专利限制的 GIF 替代格式。GIF 正是使用前面提到的 LZW 的格式。</p>
<p>后来出现的 <strong>LZMA</strong>（7-Zip、XZ）、<strong>LZ4</strong> 和 <strong>Zstd</strong> 也都从 LZ77 的滑动窗口思想出发。LZMA 和 Zstd 同时发展了匹配搜索与熵编码，LZ4 则完全去掉熵编码，以字节为单位的格式换取速度。LZ78 一系则以 LZW 的形式留在 GIF 这类格式之中。</p>
<h2 id="结语"><a class="anchor" href="#结语">结语</a></h2>
<p>LZ77 与 LZ78 都源于“用简短引用替换重复模式”这一想法。两者都不传输字典，而是由解码器重新积累，但它们在引用指向的是已经走过的数据中的位置，还是另外积累的字典中的编号这一点上分道扬镳，遗忘旧内容的方式也不同。经由 DEFLATE 延续到 LZMA、LZ4、Zstd 的主流压缩工具是 LZ77 一系的后代，LZ78 一系则以 LZW 的形式留在 GIF 这类格式中。</p>
<p>这一谱系在实际选择格式时会带来怎样的差异，也就是比较 ZIP、GZIP、ZSTD、XZ 的速度与压缩率、决定构建产物该用哪一种，请见 <a href="/240706" target="_blank" rel="noopener noreferrer">理解压缩算法</a>。</p>
<p>下次解压 <code>.zip</code> 或 <code>.gz</code> 文件时，希望你能想起，其中正来回传递着“往回数若干个字符，再复制若干个字符”这样的指令。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://doi.org/10.1145/322344.322346" target="_blank" rel="noopener noreferrer">Storer, Szymanski, Data compression via textual substitution (1982)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://doi.org/10.1109/MC.1984.1659158" target="_blank" rel="noopener noreferrer">Welch, A Technique for High-Performance Data Compression (1984)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT" target="_blank" rel="noopener noreferrer">PKWARE, APPNOTE.TXT: .ZIP File Format Specification</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>随想</category>
            <category>软件</category>
        </item>
    </channel>
</rss>