<?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>Wed, 19 Aug 2026 00:42:53 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[状态管理]]></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> 的文章。其中将状态定义为**“所有可能影响 UI 的数据”**。点赞数、购物车列表、模态框是否打开、输入值、当前登录用户的信息、当前选中的标签页、搜索结果、是否正在加载——这些全都是状态。</p>
<p>React 官方文档给出了更形式化的定义。页面标题就是 <a href="https://react.dev/learn/state-a-components-memory" target="_blank" rel="noopener noreferrer">“State: A Component's Memory”</a>，展开来说，大致是指**“组件在多次渲染之间保留（retain）数据，并在数据更新时触发 React 重新渲染的机制”<strong>。也就是说，这类数据不会随时间消失，会因某个事件而更新，并在更新时让 UI 重新绘制。还有一点需要指出：状态</strong>按组件实例彼此隔离。** 即使页面上存在十个相同组件，它们也各自拥有独立状态。这个事实与后文“状态应该放在哪里”的讨论直接相关。</p>
<p>两种定义指向的是同一件事：**“会影响渲染，并随时间变化的值”**就是状态。不变的常量（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>这些大家应该已经很熟悉了。但局部状态真正棘手的地方，是**“这个状态应该放在哪里”**这一位置决策。</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>“应用中的任何位置，都可以用这个名字引用这个值。”** 这项承诺成本高昂，正是全局状态的本质。</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>。如果只用 useState 保存表单值，刷新后所有内容都会丢失。更自然的做法是保存在 <strong>sessionStorage</strong>（标签页级临时存储）或 <strong>URL</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 中已经包含了**“鞋类商品按价格降序排列后的第 2 页”**这一完整状态，无需再用 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> 之类的库通过*解析器（parser）*概念解决了这两个问题。<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>守卫逻辑很容易堆积在一个地方。常见的情况是，一个组件里同时写满了**“未登录就去登录页、无权限就去 403、购物车为空就去商品页、用户被封禁就显示封禁提示”**。守卫越臃肿，就越难调试究竟是哪个条件在哪里阻断了流程。</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>。这样一来，权限策略调整只需变更<em>数据</em>。</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 会长久地留在我们身边。我们逐行查看代码的时间会越来越少。但越是如此，能够回答**“这个状态应该放在哪里？”**这类小问题的能力就越有价值。让 AI“在这里再加一个 useState”很容易；但那一行代码会在应用的蛛网上增添一根怎样的丝线，只有阅读代码的人凭借自己的判断力才能看清。</p>
<p>不存在唯一正确答案。但至少，**“不知道状态是什么就创建状态”<strong>与</strong>“意识到状态的种类和位置后再创建状态”**之间，有着明确的区别。希望读者下次写下一行 <code>useState</code> 之前，也能暂时停一下，问问自己：“它属于哪一类状态？”</p>
<h3 id="参考资料"><a class="anchor" href="#参考资料">参考资料</a></h3>
<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>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[这篇文章想聊一聊领域（Domain）。 在开发过程中，我经常会遇到“领域（Domain）”这个词。但真要回答“领域到底是什么？”，却很难给出一个清晰明了的答案。（说实话，刚开始学开发时，我还以为领域就是指 www。） 查找领域相关资料时，自然会延伸到领域模型、领域对象、领域对象模型等概念。但一直让我觉得遗憾的是，很少有文章能讲清它们彼此有何不同，以及这些概念在并非后端的前端中意味着什么。本文会从各...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊一聊<strong>领域（Domain）</strong>。</p>
<p>在开发过程中，我经常会遇到**“领域（Domain）”**这个词。但真要回答“领域到底是什么？”，却很难给出一个清晰明了的答案。（说实话，刚开始学开发时，我还以为领域就是指 www。）</p>
<p>查找领域相关资料时，自然会延伸到<strong>领域模型</strong>、<strong>领域对象</strong>、<strong>领域对象模型</strong>等概念。但一直让我觉得遗憾的是，很少有文章能讲清它们彼此有何不同，以及这些概念在并非后端的<strong>前端</strong>中意味着什么。本文会从各个概念的定义出发，结合示例梳理如何在前端恰当地分离和抽象领域逻辑。</p>
<p>我最近对税务相关领域很感兴趣。眼看 5 月的综合所得税申报期即将到来，本文就用税务来举例。</p>
<hr>
<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 这类以税务领域为核心的退税服务，就要通过用户界面呈现收入类型、费用率、所得扣除、税额抵免、退税额等领域概念。因此，前端开发者也必须深入理解自己处理的领域。换句话说，了解**“这项服务要解决什么问题”**，与出色地实现用户界面组件同样重要。</p>
<p>然而，即便只有“税务”这一个领域，深入其中也会发现大量子领域。仅以我略知皮毛的综合所得税计算流水线为例，就已经是这样。</p>
<p><img src="/content/260418/1.png" alt="1.png" width="1090" height="566" loading="eager" fetchpriority="high" decoding="async"></p>
<p>这条流水线的每一个阶段，都是拥有独立规则和数据的子领域。“税务”这一大领域内部，交织着收入（Income）、扣除（Deduction）、税额（Tax）、申报结果（Filing）等细分领域。如何在代码中划分它们，正是领域建模的核心问题。</p>
<h2 id="领域模型domain-model"><a class="anchor" href="#领域模型domain-model">领域模型（Domain Model）</a></h2>
<p>那么，什么是领域模型？领域和“领域模型”有什么区别？</p>
<p>Martin Fowler 与 Eric Evans 对领域模型作出了如下定义。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>同时包含行为和数据的领域对象模型。—— Martin Fowler</p></div><div class="quote-original" lang="en"><p>An object model of the domain that incorporates both behavior and data.</p></div></blockquote>
<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>选取解决问题所需的方面并加以结构化**的结果。</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>数据模型定义“数据以什么形式流转”，而**领域模型定义“这些数据在业务上意味着什么，又遵循哪些规则”。**如果无法区分两者，组件就会直接依赖 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>、**服务（Service）**三类。（Martin Fowler 将这种分类称为“Evans Classification”。）服务是一个独立概念，用来表达“无法自然归属于某个特定对象的领域操作”。不过，本文关注的核心是如何识别数据，因此将重点讨论实体和值对象。</p>
<p>**实体（Entity）**是具有唯一身份、能够贯穿时间与多种表现形式的对象。报税申报单（TaxFiling）、纳税人（Taxpayer）、收入记录（IncomeRecord）等都通过唯一标识符识别；即使属性发生变化，只要标识符相同，就仍是同一个实体。即使修改了申报单的扣除项目，只要申报单标识符没有变化，它就仍是同一份申报单。</p>
<p>**值对象（Value Object）**是仅由属性组合赋予意义的对象，所有属性值都相同时，就视为同一个对象。金额（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>？</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 批评这类代码，称其**“与只披了一层面向对象外衣的过程式代码别无二致”**。</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>；规则发生变化时，也只需修改这一个文件。关键在于，**应该把类型及其上的规则视为一个整体。**如果只把类型放进领域文件夹，却把规则抽到工具函数中，这种局部分离即使外表整洁，仍然处于贫血状态。</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 中有一个**聚合（Aggregate）**概念，指的是“把一组相关对象作为一个单元来处理”。前端无需照搬这一概念，但其中的核心原则值得借鉴：<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>坦率地说，答案是**“视情况而定”**。但根据我的经验，在 React + TypeScript 环境中，类并非万能，这背后有一些现实原因。</p>
<p><strong>1. 与 React 状态管理之间的摩擦</strong></p>
<p>React 的状态管理与**普通对象（Plain Object）**配合得最自然。<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>
<p><strong>2. 难以保证不可变性</strong></p>
<p>React 基于**引用相等性（referential equality）**检测状态变化。如果类实例的方法执行 <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>
<p><strong>1. 通过模块命名空间实现内聚</strong></p>
<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>
<p><strong>2. 统一把第一个参数作为领域主体</strong></p>
<p>函数式风格还有另一种表达内聚的约定：**始终把第一个参数设为“行为主体”。**将签名统一为 <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>
<p><strong>3. 用领域对象工厂函数聚合行为</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">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>**将领域类型与 API 响应类型分离。**无论使用 <code>interface</code> 还是 <code>type</code>，都应在单独的文件中定义前端使用的领域概念。</li>
<li>**把包含业务规则的逻辑移出组件。**不放在 <code>domain/</code> 文件夹也没关系，重要的是把它写成不依赖 React 的纯函数。</li>
<li>**集中完成 API 响应 → 领域模型的转换。**无论使用转换器函数还是 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>**引入用例层。**当领域逻辑的组合变得复杂时，就需要一个应用层，将“查询收入信息 → 应用费用率 → 计算扣除项目 → 计算税额 → 确定退税额”这一场景封装成一个函数。</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>、**扣除项目（deduction）**也会被分成彼此独立的子领域。即使税率发生变化，申报状态转换逻辑也不会受到影响；即使新增扣除项目，申报单的提交流程也保持不变。这就是限界上下文在实践中的应用。</p>
<h2 id="结语"><a class="anchor" href="#结语">结语</a></h2>
<p>总而言之，<strong>领域</strong>是我们要解决的问题范围；<strong>领域模型</strong>是对这一问题进行选择性抽象后形成的概念体系；<strong>领域对象模型</strong>是用代码实现这套概念体系的结果；<strong>领域对象</strong>则是实现中的各个具体对象。</p>
<p>而在前端实践这些概念，并不只是划分文件夹，更是要<strong>有意识地判断多层边界</strong>。“这是业务规则，还是表现层逻辑？”“这些数据属于领域状态，还是用户界面状态？”“这个函数的内聚性足够吗？”只要养成反复提出这些问题的习惯，代码结构自然会逐渐改善。</p>
<p>当然，并非所有项目都需要完整搭建 Clean Architecture 的各个层。为简单的增删改查应用划分四个层，并给每个领域都应用工厂模式，未免本末倒置。在类的优雅内聚与函数式风格的实用灵活之间，答案取决于项目复杂度和团队语境。</p>
<p>没有唯一正确答案。但至少，**“不知道领域是什么就开始写代码”<strong>与</strong>“识别领域、判断边界并有意识地分离代码”**之间有着明确差异。希望读者也能在自己的项目中问一次：“这里的领域是什么？这段代码又应该放在哪里？”</p>
<h3 id="参考资料"><a class="anchor" href="#参考资料">参考资料</a></h3>
<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[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> 是一个 400 多行的组件，日期选择、时间线可视化、预订详情工具提示、我的预订列表和取消功能全都塞在同一个文件里。</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>拆分标准是**“这段代码能否独立表达意义”**。时间线可视化接收某个日期的预订数据并绘制网格，是一个独立的关注点。我的预订列表负责查询和取消用户的预订数据，同样是一个独立的关注点。它们没有理由待在同一个文件里。</p>
<p>拆分后，<code>index.tsx</code> 只承担**协调者（orchestrator）**的角色。它仅负责状态管理、消息展示和组合子组件，而将实际的数据获取和渲染细节交给子组件。</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>当然，这并不意味着它就是标准答案。我的判断标准只是：**“仅凭函数名能否预测其行为？”**如果可以预测，就进行抽象；如果不能，保留内联反而可能更有利于可读性。</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>，尝试以表单的方式管理。但最终还是将其移除，改用把 **searchParams 作为单一事实来源（Single Source of Truth）**的 <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>这一决定的核心依据，是我认为**“让状态各自独立变动并不合适”**。如果 <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>阅读其他参与者的复盘时，我发现了一个有趣的共通点。几乎所有文章里都出现了这样一句话：**“代码不是靠阅读，而是靠预测。”**我们的大脑并非逐行解释代码，而是根据经验中积累的模式进行预测式阅读。当预测落空时，认知负担就会急剧增加。</p>
<p>从这个角度来看，模拟考试评估的不只是编码能力，而是**“能把写给同事阅读的代码变得多可预测”**这种协作能力。（也许读懂出题者和同事的心，才是软件工程师真正的能力）</p>
<p>查看其他参与者的复盘后，**“理解别人写的代码并不容易”“优先设计接口很重要，但面对庞大的现有代码时，这种方法也可能动摇”<strong>等内容让我很有共鸣。我也有过类似的经历。当现有代码已经能够运行时，人很容易产生替它的结构寻找合理解释的诱惑，也就是“代码都已经能跑了，有必要吗？”这样的念头。但模拟考试的核心，正是越过这种诱惑，以</strong>“不是我，而是其他人阅读这段代码时，能多快理解它；能否依据自己的认知判断并逐步解决问题”**为标准做出判断。</p>
<h2 id="从重构中学到的事"><a class="anchor" href="#从重构中学到的事">从重构中学到的事</a></h2>
<p>**重构顺序决定结果。**从外部（基础设施）向内部（UI）推进，是避免中途纠缠的稳妥路径。在工具和领域模型都梳理完毕后再拆分组件，各组件依赖什么便一目了然。</p>
<p>**判断是否抽象的标准是“命名”。**如果提取成函数或变量后，其名称能够说明行为，就值得抽象。如果名字不可避免地变得模糊，保留内联反而可能是更好的选择。</p>
<p>**状态的位置就是架构。**需要一起变化的状态应该放在同一个地方。与其同步 <code>useState</code> 和 <code>searchParams</code>，不如只把 searchParams 作为事实来源，这在结构上更加健康。</p>
<h2 id="结语"><a class="anchor" href="#结语">结语</a></h2>
<p>完成题目后，我和两位同事聊了聊。独自阅读代码时看不见的问题，在通过对话梳理思路的过程中逐渐显露出来。当别人对我觉得理所当然、随手略过的结构选择问出“为什么这么做？”时，我才看见自己此前没有意识到的判断盲点。</p>
<p>AI 确实正在大幅缩短编写和审查代码所需的时间。但即便如此，我依然认为代码审查和每日会议很重要，原因正是这种经历。AI 可以验证代码的一致性，但指出**“你遗漏的是这个视角”**，终究还是要靠共享相同语境的同事。发现我未曾看到的部分，再通过这些发现让产品更加稳定——这或许就是协作的本质。</p>
<p>在解决问题、编写代码的过程中，并不存在唯一的正确答案。参加同一场模拟考试的其他人也选择了各自不同的路径，并且都有自己的依据。重要的是，能够解释**“为什么要这样写”**。也建议各位读者偶尔用第一次看到自己代码的人的视角重新审视它。仅仅这一种视角，就可能成为决定代码质量的最有力标准。</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 年 5 月，最常被提及的编程工具包括 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 已经追了上来。但**“准确评估 AI 所写代码的能力”**，反而几乎成了他们独有的优势。</p>
<ul>
<li>使用 AI 的普通人：已经按我的需求实现了，运行也正常。</li>
<li>使用 AI 的开发者：虽然能运行，但这个依赖项可能引发某些问题；这种模式按这样的方式改进才更符合约定。再把相关部分检查一遍吧。</li>
</ul>
<p>前文提到的 Veracode 研究中，有 XSS 防护失败率 86%、日志注入防护失败率 88% 这两项数据。能够发现并解决这些问题的人，正是像我们这样的专业人士。他们会自然演变为负责 AI 产出质量保证（QA）的资深角色。</p>
<p>此外，专业人士的领域中还新增了一个全新的主题：**生成式 UI（Generative UI）**与 <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 将**“编排编程智能体（Orchestrating Coding Agents）”**列为核心概念。它意味着不再只是让一个 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>、**“开发者生产力（DevProd）工程师”**等新岗位。</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 编写代码的时代，越是能看到“代码之外的东西”的人，越能生存下去。希望一年后还能再次整理成文章，回顾这幅景象又发生了怎样的变化。就此收笔。</p>
<p><strong>（如果一年后再看，这篇文章显得过于理所当然或已经过时，那或许正说明我们应对得很好。）</strong></p>
<h2 id="参考资料"><a class="anchor" href="#参考资料">参考资料</a></h2>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary></details>]]></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>在我看来，抽象的本质是**“把代码阅读者需要掌握的上下文减少到适当层级”**。</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>阅读这段代码的开发者，必须理解循环的初始化、条件和递增方式，通过索引访问元素，检查条件并进行分支，还要把外部累加变量如何更新全部记在脑中。可这段代码真正想做的，不过是**“计算已完成订单的总额”**这一句话。为了理解这句话，读者却不得不同时承载四种不同的上下文。</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>谈到抽象，就绕不开**抽象层级（Level of Abstraction）**这个概念。代码的抽象层级“高”或“低”，究竟是什么意思？</p>
<p><strong>抽象层级低的代码</strong>更接近计算机执行的具体步骤，例如直接解析字符串、通过索引遍历数组、操作字节。它赤裸裸地展现了**如何（How）**运行。</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 在 <em>Clean Code</em> 中把这一概念总结为**“每个函数只处于一个抽象层级（One Level of Abstraction per Function）”**原则。因为当高层级与低层级代码混在同一个函数中时，读者每读一行都要判断：“这是核心逻辑，还是实现细节？”</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 也把它称为**“降层规则（The Stepdown Rule）”**。从上到下阅读代码时，应当像读新闻报道一样：顶部呈现全貌，越往下细节越多。</p>
<p>Kent Beck 也在 <em>Smalltalk Best Practice Patterns</em> 中以<strong>组合方法（Composed Method）模式</strong>提出了相同原则：一个方法只能由处于同一抽象层级的操作组成，每个步骤都应表示为一行方法调用。</p>
<p>这些讨论最终都指向同一个结论：**一个函数只能在一个抽象层级上叙事。**仅仅遵守这一点，代码可读性就会发生显著变化。</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>归根结底，从**“Toast 就应该长这样”这一当下的具体形象**出发的设计，很难应对变化。</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 把它称为**“揭示意图的命名（Intention-Revealing Name）”模式**，并指出，如果无法取出简洁的名字，就应该重新审视抽象本身。</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 在 <em>Clean Code</em> 中对此说过：**“长而具有描述性的名字，胜过短而晦涩的名字。”<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 中思考该用哪一个。我把这种情况称为**“组件强迫开发者思考”**。</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>判断抽象粒度是否合适的标准是：**“这次拆分真的减少了代码阅读者的上下文吗？”**如果拆分后上下文反而变得分散、更难把握，就说明还没到进行这次抽象的时候。</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>**一致性正在被破坏。**明明是同一段逻辑，却在某些组件中内联，在另一些组件中拆成单独函数；相同的计算逻辑散落各处。</li>
<li>**内部结构被不必要地暴露给外部。**调用者不得不逐一处理它根本不需要知道的实现细节。</li>
<li>**自身行为不断暴露。**模块无法隐藏内部步骤，使用方必须原样跟随这些步骤。</li>
</ul>
<p>问题在于，发现这些信号通常并不困难，但在实践中，人们很容易忽略它们，转而专注于满足更“重要”的需求。被进度追赶或埋头实现功能时，我们会觉得“反正先跑起来，以后再整理”，但那个“以后”很少真正到来。</p>
<p>还有一点同样重要，那就是<strong>保持一致的抽象标准</strong>。如果代码库中的同类逻辑，有些地方内联，有些地方写成函数，还有些地方拆成自定义 hook，新来的代码阅读者就会困惑：“这些差异是刻意设计的吗？”无论抽象与否，团队内部的标准都应保持一致。</p>
<p>Joel Spolsky 在 2002 年提出的**“抽象泄漏定律（The Law of Leaky Abstractions）”**也值得结合这一上下文记住。抽象试图隐藏复杂实现，但实现细节最终仍会泄漏（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>不过，这份直觉并非一朝一夕就能形成。只有学习大量模式、阅读各种代码，并亲自踩过坑，才会自然涌现**“这个似乎应该拆分”**的感觉。日后同事问“为什么把它拆开？”时，如果能够自然回答“因为它属于 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 的管理方式。最初，我只是在组件里内联写上 ['user', userId] 这样的数组；后来每次做缓存失效都要在不同地方重复写相同的键，开始频繁出现拼写错误，于是又把它们迁移到 QUERYKEYS 这样的常量对象中。再后来读了 TkDodo...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊一聊 <strong>TanStack Query 的 queryKey</strong>。</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>
<p>明明只是一个缓存标识符的小数组，为什么会衍生出这么多模式？<strong>为什么一个 queryKey 会留下如此丰富的演进痕迹？</strong> 每一个阶段究竟想解决什么问题？</p>
<p>本文会沿着 TanStack Query 官方文档、TkDodo 的系列博客，以及 v5 引入的 <code>queryOptions</code> 内部实现，梳理 queryKey 的工作原理，以及它为何逐步演进成今天的形态。</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 如何判断 queryKey 是“同一个键”？如果只是用 <code>===</code> 比较，对象引用会不同，那岂不是每次都会缓存未命中？</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/main/packages/query-core/src/queryCache.ts" target="_blank" rel="noopener noreferrer">官方实现</a>中，这个数据结构不是普通对象，而是 <code>Map&#x3C;string, Query></code>。它在类内部声明为 <code>#queries = new Map&#x3C;string, Query>()</code>，所有读写都通过 <code>#queries.set(query.queryHash, query)</code> 和 <code>#queries.get(queryHash)</code> 完成。键是 queryKey 的序列化形式（<code>queryHash</code>），值是 <code>Query</code> 类的实例。</p>
<p>旧版本也曾使用普通对象，但到 v5 已统一为原生 <code>Map</code>。（<code>Map</code> 不存在键冲突或原型污染风险，能保留插入顺序，而且字符串键的查找平均为 O(1)，作为缓存数据结构几乎是标准答案。）</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> 使用**引用相等（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> 只是完成这种转换最简单的工具。（这也是 TanStack Query 在 v3 时期尝试多种序列化策略后，最终采用稳定版 <code>JSON.stringify</code> 的原因。）</p>
<p>这里的关键是生成哈希值的函数 <code>hashKey</code>。<a href="https://github.com/TanStack/query/blob/main/packages/query-core/src/utils.ts" 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>后再序列化。</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 对普通对象的键进行排序，正是出于这个原因。无论输入顺序如何，都让输出保持一致，使序列化结果与对象语义形成一一对应。用数学语言来说，就是从键顺序不同的对象构成的等价类（equivalence class）中，选出排序后的形式作为代表元。</p>
<p>不对数组排序，也是同一原则的另一面。数组是一种顺序本身承载语义的数据结构，一旦排序就会丢失信息。对象的键顺序是偶然的，数组的元素顺序则是有意的。<code>hashKey</code> 对二者作了准确区分。正因如此，官方指南才建议按“通用 → 具体”的顺序组织 queryKey。只要数组顺序承载语义，这层语义就必须由开发者亲自定义。</p>
<p>还有一个细节值得说明：键排序只作用于<strong>普通对象</strong>。同一文件中的 <code>isPlainObject</code> 并不只是检查 <code>typeof === 'object'</code>，还会检查 <code>Object.getPrototypeOf(o) === Object.prototype</code>，以区分<strong>纯对象字面量</strong>和<strong>类实例</strong>。因此，<code>{ foo: 1 }</code> 这样的字面量会被排序，而通过 <code>class User { ... }</code> 创建的实例不会排序，直接进入下一步。（如果把类实例直接放进 queryKey，<code>JSON.stringify</code> 又只会输出可枚举属性，两者结合后可能得到违背预期的哈希值，这正是一个容易踩坑的地方。）</p>
<p>这种工作方式会带来两个重要结果。</p>
<p><strong>1. 对象的键顺序无关紧要。</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">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>
<p><strong>2. 数组的元素顺序很重要。</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">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>
<p>因为数组是一种顺序本身就有意义的数据结构。<code>JSON.stringify</code> 也会保留数组顺序。</p>
<p>另外还应知道，<code>undefined</code> 值会在序列化过程中消失。<code>{ a: 1, b: undefined }</code> 和 <code>{ a: 1 }</code> 会生成相同的 hash。（我以前不知道这一点，还犯过“既然显式写了 undefined，那肯定是不同的 cache！”这样的错误。）</p>
<p>此外，queryKey 不能包含<strong>循环引用或函数</strong>，因为 <code>JSON.stringify</code> 无法处理它们。<code>Date</code> 对象、<code>Map/Set</code>、<code>BigInt</code> 等，在默认行为下同样不建议使用。queryKey 应当是可序列化的纯数据结构。</p>
<p>有趣的是，这项约束并非完全不可绕过。TanStack Query 提供了 <code>queryKeyHashFn</code> 选项，留出了一个<strong>替换 hash 函数本身的逃生口</strong>。内部的 <code>hashQueryKeyByOptions(queryKey, options)</code> 会判断 options 中是否有 <code>queryKeyHashFn</code>：有则调用它，没有则调用默认的 <code>hashKey</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:#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: [{ id: userId, fetchedAt: </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">  queryFn,</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // Date를 ISO 문자열로 바꿔서 해싱</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryKeyHashFn</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>
<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">(key, (</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"> (v </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">instanceof</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"> v.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toISOString</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 style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>但这个选项必须为每个查询单独指定，而且通过 <code>queryClient.setQueryData</code> 之类不掌握选项信息的命令式 API 调用时不会生效，这是它的局限（<a href="https://github.com/TanStack/query/issues/1343" target="_blank" rel="noopener noreferrer">Issue #1343</a>）。因此在实践中，与其使用这个逃生口，<strong>更安全的做法是在创建 queryKey 时就将值转换为可序列化的形式。</strong>（我也曾直接放入 <code>Date</code>，然后困惑了很久：“明明是同一个时间点，为什么缓存没有更新？”最终答案是：“虽然那个 <code>Date</code> 表示同一时间点，但它是不同的对象实例，所以每次哈希值都不同。”）</p>
<h2 id="querykey-编写规则"><a class="anchor" href="#querykey-编写规则">queryKey 编写规则</a></h2>
<p>理解了前面较为复杂的内部机制后，编写规则也就顺理成章了。官方文档推荐的规则可以归纳如下。</p>
<p><strong>规则 1：queryKey 必须是数组。</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:#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>
<p><strong>规则 2：把 queryFn 依赖的所有变量都放进 queryKey。</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">// 잘못된 예: 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>
<p><strong>规则 3：按照从最 generic 到最 specific 的顺序排列。</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:#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="1-内联数组"><a class="anchor" href="#1-内联数组">1. 内联数组</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="2-常量对象"><a class="anchor" href="#2-常量对象">2. 常量对象</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="3-query-key-factory"><a class="anchor" href="#3-query-key-factory">3. 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="4-lukemoralesquery-key-factory"><a class="anchor" href="#4-lukemoralesquery-key-factory">4. @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="5-queryoptionsv5-官方"><a class="anchor" href="#5-queryoptionsv5-官方">5. 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>
<p><strong>1. 同时获得层级结构和类型推断。</strong></p>
<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>
<p><strong>2. 可以在组件中局部覆盖 options。</strong></p>
<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>
<p><strong>3. 包装 <code>useQuery</code> 的自定义 hook 会逐渐消失。</strong></p>
<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> 依赖外部变量。**如果所有依赖都从 queryKey 中取出，**那么没有放进 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>
<h2 id="参考资料"><a class="anchor" href="#参考资料">参考资料</a></h2>
<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[错误处理]]></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[这篇文章想谈一谈：在前端，我们该如何捕获错误。 笔者在实际工作中编写错误处理代码时，经常会有一种说不清的不踏实感。有些错误用 try/catch 捕获，有些由 ErrorBoundary 捕获，还有些则交给 TanStack Query 的 onError。它们的职责范围既有细微重叠，又存在错位。结果就是，有时错误悄悄漏了出去，有时又传播到了本不希望它抵达的地方。 问题在于，我们很少把这些工具的运...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想谈一谈：<strong>在前端，我们该如何捕获错误</strong>。</p>
<p>笔者在实际工作中编写错误处理代码时，经常会有一种说不清的不踏实感。有些错误用 <code>try/catch</code> 捕获，有些由 <code>ErrorBoundary</code> 捕获，还有些则交给 TanStack Query 的 <code>onError</code>。它们的职责范围既有细微重叠，又存在错位。结果就是，有时错误悄悄漏了出去，有时又传播到了本不希望它抵达的地方。</p>
<p>问题在于，我们很少把这些工具的运行方式放在一起系统梳理。虽然知道“Error Boundary 只能捕获渲染阶段的错误”，但如果被追问这句话在实际运行中究竟意味着什么、调用 <code>reset</code> 后内部会发生什么，以及开启 <code>throwOnError</code> 后 TanStack Query 会在什么时机重新抛出错误，往往就很难准确回答。</p>
<p>本文将以 React 官方指南、<code>react-error-boundary</code> 库和 TanStack Query v5 官方文档为基础，梳理前端错误处理工具<strong>各自负责到哪里</strong>，以及<strong>应该如何组合</strong>。</p>
<h2 id="react-能捕获与不能捕获的错误"><a class="anchor" href="#react-能捕获与不能捕获的错误">React 能捕获与不能捕获的错误</a></h2>
<p>先从最基础的问题开始：<strong>React 能捕获哪些错误？</strong></p>
<p>React 官方文档明确区分了 Error Boundary 能捕获和不能捕获的错误。</p>
<p><strong>Error Boundary 能捕获的范围</strong></p>
<ul>
<li>子组件**渲染（render）**过程中发生的错误</li>
<li>**生命周期方法（lifecycle method）**中发生的错误</li>
<li>**构造函数（constructor）**中发生的错误</li>
</ul>
<p><strong>Error Boundary 无法捕获的范围</strong></p>
<ul>
<li>**事件处理器（event handler）**中的错误</li>
<li><code>setTimeout</code>、<code>requestAnimationFrame</code>、<strong>Promise 等异步代码</strong>中的错误</li>
<li>**服务端渲染（SSR）**过程中的错误</li>
<li><strong>Error Boundary 自身</strong>发生的错误</li>
</ul>
<p>为什么这种区分如此重要？因为我们平时处理的大多数错误，其实都属于<strong>第二类</strong>。比如点击按钮触发 mutation 后服务器返回 500、<code>useEffect</code> 中的 fetch 失败，或提交表单时校验逻辑抛出异常。这些错误不会被 React 自动捕获，必须由我们显式捕获并处理。</p>
<p>因此，前端错误处理分成两条路径：<strong>渲染阶段的错误交给 Error Boundary</strong>，<strong>其他错误交给 try/catch 或库提供的回调</strong>。TanStack Query 这类异步状态管理库，会在两条路径交汇之处充当桥梁。</p>
<h2 id="error-boundary-的本质"><a class="anchor" href="#error-boundary-的本质">Error Boundary 的本质</a></h2>
<p>Error Boundary 本质上是一个拥有两个生命周期方法的<strong>类组件</strong>。根据 React 官方文档，要成为 Error Boundary，需要实现下面两个方法中的至少一个（通常两个都会实现）。</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">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ErrorBoundary</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> extends</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">Component</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:#FFAB70;--shiki-light:#E36209">props</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    super</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(props);</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.state </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { hasError: </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:#6A737D;--shiki-light:#6A737D">  // 에러 발생 시 state를 업데이트해 다음 렌더에서 fallback UI를 보여준다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  static</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getDerivedStateFromError</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>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { hasError: </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">  // 에러가 발생한 직후에 호출. 로깅 같은 사이드이펙트는 여기서 처리한다</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  componentDidCatch</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">info</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    logErrorToMyService</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error, info.componentStack);</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">  render</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:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.state.hasError) {</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">.props.fallback;</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:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.props.children;</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>getDerivedStateFromError</code> 必须是<strong>纯函数</strong>。它只负责返回新的 state，不能产生副作用。与之相对，<code>componentDidCatch</code> 正是用来执行副作用的地方。向 Sentry 上报错误或在控制台输出组件堆栈，都应该在这里完成。</p>
<p>这里有一点很重要：这两个方法<strong>只存在于类组件中</strong>。目前仍没有官方方式可以用函数组件创建 Error Boundary。<a href="https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary" target="_blank" rel="noopener noreferrer">React 官方文档</a>也明确说明了这一点。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>目前还无法将 Error Boundary 编写为函数组件。</p></div><div class="quote-original" lang="en"><p>There is currently no way to write an Error Boundary as a function component.</p></div></blockquote>
<p>每次都亲自编写类组件很麻烦，因此通常会使用 <code>react-error-boundary</code> 库。（它由曾任 React 核心维护者的 Brian Vaughn 编写，实际上已经成为一种事实标准。）</p>
<h2 id="react-error-boundary-的-3-种-fallback"><a class="anchor" href="#react-error-boundary-的-3-种-fallback">react-error-boundary 的 3 种 fallback</a></h2>
<p><code>react-error-boundary</code> 的 <code>ErrorBoundary</code> 组件提供了<strong>三种方式</strong>来通过 prop 指定 fallback UI。下面简单看看各自的用法。</p>
<h3 id="fallback"><a class="anchor" href="#fallback">fallback</a></h3>
<p>这是最简单的形式，直接传入静态 JSX。</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 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:#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">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Page</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>
<p>不需要访问错误对象或 reset 函数时可以使用。实际项目通常需要显示错误消息或提供重试操作，所以笔者至今还没有在工作中用过这种方式。</p>
<h3 id="fallbackcomponent"><a class="anchor" href="#fallbackcomponent">FallbackComponent</a></h3>
<p>将 fallback UI 拆成独立组件，再传入它的<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">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ErrorFallback</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>
<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">pre</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{error.message}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">pre</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">{resetErrorBoundary}>다시 시도&#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:#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">Page</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>
<p>错误对象和 <code>resetErrorBoundary</code> 函数会自动通过 props 注入。如果 fallback UI 可能在其他地方复用，这种方式会很清晰。</p>
<h3 id="fallbackrender"><a class="anchor" href="#fallbackrender">fallbackRender</a></h3>
<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:#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">  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:#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">>오류가 발생했습니다: {error.message}&#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">{resetErrorBoundary}>다시 시도&#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 style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Page</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>
<p>它与 <code>FallbackComponent</code> 本质上做的是同一件事，但可以<strong>不创建独立组件，直接内联处理</strong>。需要访问外部闭包（例如父组件的 state 或处理函数）时很有用。</p>
<p>三种方式没有唯一正确答案。笔者在实际工作中最常用的模式，是<strong>创建一个共用的 ErrorFallback 组件，再通过 <code>FallbackComponent</code> 注入</strong>，因为需要保持设计系统和产品语调的一致性。只有当页面需要不同的 fallback 时，才会用 <code>fallbackRender</code> 内联编写。</p>
<h2 id="reset-实际上做了什么"><a class="anchor" href="#reset-实际上做了什么">reset 实际上做了什么？</a></h2>
<p>使用 <code>react-error-boundary</code> 时，自然会遇到 <code>resetErrorBoundary</code> 函数，也就是在 fallback 中点击“重试”按钮时调用的函数。下面看看它实际做了什么。</p>
<p>先说结论：<code>resetErrorBoundary</code> 只是向 ErrorBoundary 组件发出信号，让它<strong>重置自身状态并重新渲染 children</strong>。它不会自动修改 TanStack Query 缓存等任何外部状态。</p>
<p>按步骤展开，内部会发生以下事情。</p>
<ol>
<li>调用 <code>resetErrorBoundary()</code>。</li>
<li>ErrorBoundary 内部的 <code>hasError</code> 状态恢复为 <code>false</code>。</li>
<li>（可选）执行 <code>onReset</code> 回调，用户自定义的副作用会在这里发生。</li>
<li>重新渲染 children。如果引发错误的根因（状态、缓存等）依然存在，<strong>同一个错误就会再次被抛出。</strong></li>
</ol>
<p>关键在最后一步。**reset 只意味着“忘掉错误，再尝试渲染一次”，并不意味着“修复导致错误的原因”。**因此，如果只做 reset，同一个错误可能会无限重复。</p>
<p>为了解决这个问题，还需要另外两个工具。</p>
<h3 id="onreset"><a class="anchor" href="#onreset">onReset</a></h3>
<p>它的作用类似一个在 reset 即将发生前调用的钩子，可在这里清理导致错误的外部状态。</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">  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:#B392F0;--shiki-light:#6F42C1">  onReset</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">    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">'user'</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:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Page</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="resetkeys"><a class="anchor" href="#resetkeys">resetKeys</a></h3>
<p>当数组中的值发生变化时，ErrorBoundary 会自动 reset。可以传入 URL 参数、搜索词、当前标签页等能够判断“这个值变了，重新尝试就有意义”的键。</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">  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:#B392F0;--shiki-light:#6F42C1">  resetKeys</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</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:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">UserProfile</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> userId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{userId} /></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>userId</code> 变化时会自动执行 reset，并重新渲染 children。用户切换到另一个个人资料后，之前的错误也会自然消失。</p>
<h2 id="如何捕获事件处理器和异步错误"><a class="anchor" href="#如何捕获事件处理器和异步错误">如何捕获事件处理器和异步错误？</a></h2>
<p>前面提到，Error Boundary 无法捕获事件处理器和异步代码中的错误。但我们处理的大多数错误恰恰发生在那里，该怎么办？</p>
<p>为此，<code>react-error-boundary</code> 提供了 <strong><code>useErrorBoundary</code> 钩子</strong>。这个钩子返回一个名为 <code>showBoundary</code> 的函数，调用它就能将错误强制抛给最近的 ErrorBoundary。</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"> { useErrorBoundary } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'react-error-boundary'</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"> MyComponent</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">showBoundary</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"> useErrorBoundary</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"> handleClick</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">    try</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"> someAsyncOperation</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">catch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (error) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      showBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error);</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"> &#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">{handleClick}>실행&#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>关键是，<strong>开发者必须显式地把错误向上提升</strong>，React 不会自动代劳。如果希望将异步错误转移到 ErrorBoundary 的职责范围，就要用 <code>try/catch</code> 捕获，再传给 <code>showBoundary</code>。</p>
<p>理解这个模式后，“为什么有些错误能被 ErrorBoundary 捕获，有些却不能”这个问题便迎刃而解。答案很简单：<strong>“有没有把它提升到渲染阶段。”</strong></p>
<h2 id="tanstack-query-如何处理错误"><a class="anchor" href="#tanstack-query-如何处理错误">TanStack Query 如何处理错误？</a></h2>
<p>梳理到这里，自然会产生另一个问题。我们每天使用的 <code>useQuery</code> 负责异步请求，其中发生的错误究竟如何处理？</p>
<p>TanStack Query 默认会通过 <strong><code>error</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:#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">error</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:#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">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">],</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryFn: fetchTodos,</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"> (isError) {</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">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>에러: {error.message}&#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>error</code> 字段中有了值。ErrorBoundary 不会介入。</p>
<p>这里需要强调一个重要事实：**TanStack Query 的默认行为是“不抛出错误”。**无论 queryFn 中是 throw 还是 reject，错误都只会进入 <code>error</code> 字段，不会打断 React 的渲染流程。因此，如果没有额外配置，ErrorBoundary 永远不会被触发。</p>
<p>还有一点，TanStack Query <strong>默认会在出错后自动重试 3 次</strong>。</p>
<p>默认 <code>retryDelay</code> 采用指数退避（exponential backoff），最长会增加到 30 秒。也就是说，首次失败后用户不会立刻看到错误。系统会分别间隔 1 秒、2 秒、4 秒重试，如果仍然失败，才会填充 <code>error</code> 字段。（如果你在开发时曾疑惑“为什么错误这么晚才出现？”，十有八九就是这个原因。）</p>
<h3 id="用-throwonerror-连接-errorboundary"><a class="anchor" href="#用-throwonerror-连接-errorboundary">用 throwOnError 连接 ErrorBoundary</a></h3>
<p>那么，怎样才能让 TanStack Query 的错误流向 ErrorBoundary？答案是 <strong><code>throwOnError</code></strong> 选项。（在 v4 之前它叫 <code>useErrorBoundary</code>，到 v5 更名为 <code>throwOnError</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">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">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">],</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryFn: fetchTodos,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  throwOnError: </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>启用这个选项后，TanStack Query 会在<strong>下一个渲染周期重新 throw 错误</strong>。这样，该 throw 就成为渲染阶段的错误，ErrorBoundary 终于能够捕获它。</p>
<p><code>throwOnError</code> 也可以接收函数。这样就能分流：某些错误交给 ErrorBoundary，另一些由组件自行处理。</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">({</span></span>
<span data-line=""><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:#E1E4E8;--shiki-light:#24292E">  queryFn: fetchTodos,</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 5xx 서버 에러만 ErrorBoundary로 보낸다</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  throwOnError</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:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> error.response?.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</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:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>这个模式之所以实用，是因为<strong>4xx 之类的客户端错误（例如输入校验失败、权限不足）<strong>通常更适合就地显示消息，而对于</strong>5xx 之类的服务端错误</strong>，则更适合覆盖整个页面并提示“请稍后重试”。</p>
<h3 id="usesuspensequery"><a class="anchor" href="#usesuspensequery">useSuspenseQuery</a></h3>
<p>如果使用的是 <code>useSuspenseQuery</code>，就无需考虑 <code>throwOnError</code>。在 Suspense 模式下，<strong>默认行为就是始终抛出错误</strong>。</p>
<p>换句话说，使用 <code>useSuspenseQuery</code> 就意味着：<strong>加载状态由 Suspense 处理，错误由 ErrorBoundary 处理</strong>。组件内部不再需要 <code>if (isError)</code> 或 <code>if (isLoading)</code> 之类的分支，但需要在外部用这两个边界包裹组件。</p>
<h2 id="queryerrorresetboundary"><a class="anchor" href="#queryerrorresetboundary">QueryErrorResetBoundary</a></h2>
<p>读到这里，又会产生一个问题：用户在 fallback 中点击“重试”按钮后会发生什么？</p>
<p>如前所述，<code>resetErrorBoundary</code> 只会重置 ErrorBoundary 的 <code>hasError</code> 状态。但 TanStack Query 缓存里仍然留着<strong>持续处于错误状态的查询</strong>。children 重新渲染后，TanStack Query 查看缓存，判断“这个查询已经出错”，便会立即再次抛出同一个错误。（这会形成可怕的无限循环。）</p>
<p>为了解决这个问题，TanStack Query 提供了 <strong><code>useQueryErrorResetBoundary</code> 钩子</strong>和 <strong><code>QueryErrorResetBoundary</code> 组件</strong>。名字虽长，作用却很简单：发出一条命令，<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"> { useQueryErrorResetBoundary } </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 style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { ErrorBoundary } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'react-error-boundary'</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"> App</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">reset</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"> useQueryErrorResetBoundary</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"> (</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">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:#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">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">{resetErrorBoundary}>다시 시도&#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 style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Page</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">}</span></span></code></pre></figure>
<p>下面按时间顺序梳理这里发生的事情。</p>
<ol>
<li>用户点击“重试”按钮 → 调用 <code>resetErrorBoundary()</code></li>
<li>ErrorBoundary 执行 <code>onReset</code> 回调 → 调用 <code>reset()</code>（重置 TanStack Query 的错误状态）</li>
<li>ErrorBoundary 重置自身状态并重新渲染 children</li>
<li>children 中的 <code>useQuery</code> 开始运行 → 错误状态已清除，因此重新尝试 fetch</li>
</ol>
<p>关键是把 <code>onReset</code> 与 <code>reset</code> 连接起来的部分。正是这一行代码让 ErrorBoundary 和 TanStack Query 的状态保持同步。</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">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { QueryErrorResetBoundary } </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 style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { ErrorBoundary } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'react-error-boundary'</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"> App</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:#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">>에러가 발생했습니다: {error.message}&#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">{resetErrorBoundary}>다시 시도&#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 style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Page</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>它与钩子版本最大的区别，是通过 <strong>render prop 模式</strong>把 <code>reset</code> 函数传给子组件。<code>QueryErrorResetBoundary</code> 将函数作为自己的 children，调用时传入 <code>{ reset }</code>，再渲染该函数的返回值。因此，可以在函数内部直接连接 <code>onReset={reset}</code>。</p>
<p>如果找不到最近的 <code>QueryErrorResetBoundary</code>，钩子版本会<strong>重置全局缓存中的错误</strong>。组件版本则只会在自己的子组件区域内限定 reset 范围。如果希望精确控制作用域，组件版本更稳妥。</p>
<p>这里还要说明一点：**reset 不会清空缓存。**它不会删除全部数据，而更接近于“解除被标记为错误的查询状态”。如果确实要让数据失效，需要另行调用 <code>queryClient.invalidateQueries()</code>。</p>
<h2 id="mutation-的错误"><a class="anchor" href="#mutation-的错误">Mutation 的错误</a></h2>
<p>到目前为止，讨论的模式几乎都以 <code>useQuery</code> 为基准。但 <strong><code>useMutation</code> 的情况有所不同。</strong></p>
<p>最大的区别在于，mutation 通常由**用户的显式操作（点击、提交）**触发。因此，在靠近操作发生的位置处理错误更自然。与其用 fallback 覆盖整个页面，不如通过 toast 消息或表单旁的错误文字提示“支付失败：请重新检查银行卡信息”。</p>
<p>TkDodo 在 <a href="https://tkdodo.eu/blog/mastering-mutations-in-react-query" target="_blank" rel="noopener noreferrer">精通 React Query 中的 Mutation</a>一文中，用一句话概括了这种差异的本质：**Query 是声明式（declarative）的，而 Mutation 是命令式（imperative）的。**Query 会在组件挂载后自动执行，其他组件也能订阅同一个键，并且结果会被缓存复用。相对地，mutation 只有在用户点击按钮后才会执行，既不缓存，也与调用它的组件实例一一绑定。这种本质差异将两者的错误处理方式分开了。</p>
<p><code>useQuery</code> 的默认 <code>retry</code> 是 <code>3</code>，但 <strong><code>useMutation</code> 的默认 <code>retry</code> 是 <code>0</code>。<strong>原因很简单：mutation 会产生</strong>副作用（side effect）</strong>。如果支付请求因网络超时失败，而库自动再调用两次，用户的银行卡可能会被扣款三次。</p>
<p>因此，原则上只有在开发者<strong>确信操作具有幂等性（idempotent）时</strong>，才应显式开启 mutation 重试。例如，重复发送同一请求也能保证结果一致的 GET 类安全查询，或者服务端接收幂等键（idempotency key）并能阻止重复操作的情况。</p>
<p><code>useQuery</code> 的错误会<strong>写入缓存</strong>。因此，它会立即传播给订阅相同 <code>queryKey</code> 的其他组件，必须使用 <code>QueryErrorResetBoundary</code> 等机制统一重置。</p>
<p>mutation 则不同。某个组件的 mutation 实例发生错误后，错误<strong>只会保留在该实例的状态中</strong>，不会影响其他使用同一 <code>mutationFn</code> 的组件。因此，TanStack Query 中没有 <code>MutationErrorResetBoundary</code> 这种东西，<strong>因为根本不需要。</strong></p>
<p>这一差异会给实际工作带来一个影响：如果两个组件调用同一个 <code>useMutation</code>，其中一个组件发生的错误不会出现在另一个组件中。如果希望“在应用全局感知这次 mutation 的错误”，组件级 <code>onError</code> 就不够了，需要将它提升到 <code>MutationCache.onError</code>。</p>
<h3 id="mutate-与-mutateasync"><a class="anchor" href="#mutate-与-mutateasync">mutate 与 mutateAsync</a></h3>
<p><code>useMutation</code> 会返回两种执行函数，它们的差异决定了错误处理方式。</p>
<p>mutate 的返回类型是 <code>void</code>，不返回 Promise。因此不能用 await 等待结果，只能通过 <code>onSuccess/onError</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">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> mutation</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useMutation</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  mutationFn: createPost,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  onError</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:#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">    toast.</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">`등록 실패: ${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">error</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">message</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:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">mutation.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">mutate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(newPost);</span></span></code></pre></figure>
<p>相对地，<code>mutateAsync</code> 返回 Promise，可以用 <code>try/catch</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">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> mutation</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useMutation</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ mutationFn: createPost });</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"> 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:#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">  try</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"> mutation.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">mutateAsync</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(newPost);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    router.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`/posts/${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">result</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:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">catch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (error) {</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:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>应该在什么情况下使用哪一个？笔者按以下标准区分。</p>
<ul>
<li><strong>mutation 结束后需要执行后续操作</strong>（例如成功后跳转、使用返回值）→ <code>mutateAsync</code></li>
<li><strong>只需发起调用，副作用交给回调处理</strong>（例如切换点赞状态、只显示 toast）→ <code>mutate</code> + <code>onError</code></li>
</ul>
<p>这里有一个常见错误：**使用 <code>mutateAsync</code> 却没有添加 <code>try/catch</code>，会引发 unhandled promise rejection。**基于回调的 <code>mutate</code> 会自行吸收错误，而 <code>mutateAsync</code> 默认会把错误抛给调用方。如果不了解这个差异而混用两者，控制台就会充满红色警告。</p>
<h3 id="onerror"><a class="anchor" href="#onerror">onError</a></h3>
<p>还有一个经常被忽略的细节：<code>useMutation</code> 的 <code>onError</code> 可以在<strong>两个位置</strong>（hook、mutate）定义。</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"> mutation</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useMutation</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  mutationFn: createPost,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  onError</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:#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">    Sentry.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">captureException</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error);</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>钩子级回调始终会执行，而 mutate 调用级回调只针对当前调用执行。</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">mutation.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">mutate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(newPost, {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  onError</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:#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">    setFormError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error.message);</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>官方文档明确规定的执行顺序是：**钩子级 → mutate 调用级。**如果两个回调都已定义，会先执行钩子级回调，再执行 mutate 调用级回调。</p>
<h2 id="全局错误处理"><a class="anchor" href="#全局错误处理">全局错误处理</a></h2>
<p>目前为止的模式都在组件层面。但实际也可能有“希望集中记录所有查询错误”或“遇到 401 错误必须退出登录”等需求。对于这类横切关注点，可以在创建 <strong>QueryClient 时为 <code>QueryCache</code>/<code>MutationCache</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:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { QueryClient, QueryCache, MutationCache } </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">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">  queryCache: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> QueryCache</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onError</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"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      if</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:#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">        toast.</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">`데이터 갱신 실패: ${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">error</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">message</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:#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">  mutationCache: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> MutationCache</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onError</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:#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"> (error.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 401</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">        redirectToLogin</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:#E1E4E8;--shiki-light:#24292E">  }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>关键在于，<code>QueryCache.onError</code> <strong>对每个查询只调用一次</strong>。即使有多个组件订阅同一个查询，回调也只执行一次，因此不会出现重复 toast 等问题。</p>
<p>也可以像上面的例子一样检查 <code>query.state.data !== undefined</code>。如果是<strong>已有缓存数据时 refetch 失败</strong>，用户至少还能在页面上看到数据。这时用 ErrorBoundary 覆盖整个页面就有些过度，只需告知用户刷新失败即可。相反，如果首次加载就在没有缓存数据的情况下失败，则应该由 ErrorBoundary 捕获并显示 fallback。</p>
<p>将两条流程结合起来，就能设计出一套清晰的策略：“首次加载失败交给 ErrorBoundary，后台 refetch 失败则显示 toast”。</p>
<h2 id="共用组件"><a class="anchor" href="#共用组件">共用组件</a></h2>
<p>读到这里，很自然会产生一个想法：每次都用 <code>QueryErrorResetBoundary</code>、<code>ErrorBoundary</code> 和 <code>Suspense</code> 包三层太麻烦，能否<strong>组合成一个组件复用</strong>？</p>
<p>这个想法很自然。笔者过去也曾创建并使用过下面这样的 <code>AsyncBoundary</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">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { QueryErrorResetBoundary } </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 style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { Suspense, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">type</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ComponentType, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">type</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ReactNode } </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 style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { ErrorBoundary, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">type</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> FallbackProps } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'react-error-boundary'</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"> { ErrorFallback } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> './ErrorFallback'</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"> { Spinner } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> './Spinner'</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"> Props</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"> ReactNode</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  pendingFallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</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">  rejectedFallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ComponentType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</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:#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"> AsyncBoundary</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:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  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:#79B8FF;--shiki-light:#005CC5">Spinner</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  rejectedFallback</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">}</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 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">{rejectedFallback}></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>在页面中，只需这样一段代码即可。</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">AsyncBoundary</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">Content</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">AsyncBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>看上去很简洁，但同事给出了这样的反馈。</p>
<blockquote>
<p>AsyncBoundary 这个名称并不是一种约定俗成到像代名词一样的叫法，所以无论里面有什么，似乎都不会特别违和。不过，<strong>里面还有 React Query 的 ResetBoundary，这一点确实有些难以预料。</strong></p>
</blockquote>
<blockquote>
<p>另外，<code>pendingFallback</code> 和 <code>rejectedFallback</code> 带有默认值也让我有点在意。只看 <code>&#x3C;AsyncBoundary></code> 这一行，无法知道内部会使用哪种 fallback，甚至可能<strong>根本意识不到它们来自 props 的默认值。</strong></p>
</blockquote>
<h3 id="名称隐藏了依赖"><a class="anchor" href="#名称隐藏了依赖">名称隐藏了依赖</a></h3>
<p>这个组件名为 <code>AsyncBoundary</code>，只传达了“异步边界”的含义。但它的内部实现与 <strong>TanStack Query 强耦合</strong>：其中包含 <code>QueryErrorResetBoundary</code>，并在 <code>onReset</code> 中接入 <code>reset</code>。也就是说，这个组件其实是**“面向 React Query 异步区域的边界”**，名称却完全没有体现这一点。</p>
<p>这为什么是个问题？因为它会<strong>打破阅读者的预期</strong>。阅读代码并不是逐行解读，而是依据经验形成的模式不断进行<strong>预测</strong>。一旦预测落空，认知负担就会骤然上升。</p>
<p>同事第一次看到 <code>AsyncBoundary</code> 这个名字时，脑海中浮现的是“用于异步处理的通用边界”。看起来无论使用 SWR，还是直接调用 fetch，都可以拿来使用。但它实际上内置了 <code>QueryErrorResetBoundary</code>，所以即使在<strong>没有使用 TanStack Query 的上下文中，也会带入毫无意义的耦合</strong>。名称与实现之间出现了裂缝。</p>
<p>可以把这种情况看作抽象泄漏（leaky abstraction）的反方向。通常的泄漏是“本应藏在抽象背后的细节露了出来”，这里却是**本应显式存在的依赖，被名称隐藏得过于彻底。**这或许是更糟的一类问题。（因为使用者会在不知情的情况下直接拿来用。）</p>
<h3 id="在名称中体现依赖"><a class="anchor" href="#在名称中体现依赖">在名称中体现依赖</a></h3>
<p>最简单的解决方式是改名。不要叫 <code>AsyncBoundary</code>，而应改成 <strong><code>QueryAsyncBoundary</code></strong> 之类能在名称中明确依赖的名字。笔者查看了 Toss 开发的 <a href="https://suspensive.org/" target="_blank" rel="noopener noreferrer">Suspensive</a> 库，发现它也明确表达了依赖。<code>@suspensive/react</code> 只包含通用的 <code>ErrorBoundary</code> 和 <code>Suspense</code>，与 TanStack Query 结合的组件则被拆到独立的 <code>@suspensive/react-query</code> 包中，命名为 <code>QueryAsyncBoundary</code>。</p>
<p>只多了这几个字符，传递给代码阅读者的信息量却大不相同。看到 <code>Query</code> 前缀的瞬间，就能立刻明白：**“这是 TanStack Query 环境专用的。”**它能提前阻止将组件误用到错误上下文中的情况。</p>
<h3 id="拆分成可组合单元"><a class="anchor" href="#拆分成可组合单元">拆分成可组合单元</a></h3>
<p>更根本的做法是：<strong>不要捆绑。</strong></p>
<p>ErrorBoundary 和 Suspense 本质上属于<strong>不同的关注点</strong>。把它们捆成一个组件，可能会失去组合的灵活性。有些页面可能只需要 ErrorBoundary，有些只需要 Suspense，还有些可能希望在一个 ErrorBoundary 中放入两个 Suspense。一旦组合成 <code>AsyncBoundary</code>，这些变体都会变得别扭；保持拆分，则可以自由组合。</p>
<p>这种模式会让代码多一行，但优点是<strong>可以直接从代码中读出每个边界负责什么</strong>。而且，使用 <code>useSuspenseQuery</code> 时，希望同时处理的加载单元与希望捕获错误的单元往往并不相同，因此拆开反而更自然。</p>
<p>笔者最终的结论是：**如果反复出现的组合模式确实完全相同，就把它们组合起来；如果需要变化，就保持拆分。**即使组合，也要通过名称显式体现依赖。只要遵守这两条原则，就不太容易再收到“看不出 AsyncBoundary 里面有什么”的代码审查意见。</p>
<h3 id="默认-props"><a class="anchor" href="#默认-props">默认 Props</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:#E1E4E8;--shiki-light:#24292E">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:#79B8FF;--shiki-light:#005CC5">Spinner</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">rejectedFallback </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ErrorFallback,</span></span></code></pre></figure>
<p>只写一行 <code>&#x3C;QueryAsyncBoundary>...&#x3C;/QueryAsyncBoundary></code> 就能工作，是因为内部会自动使用 <code>Spinner</code> 和 <code>ErrorFallback</code>。<strong>这是无法根据名称预料的信息。</strong></p>
<p>这正是前面所批评的“名称隐藏依赖”的另一种表现。虽然通过 <code>Query</code> 前缀让名称体现了依赖，但 <code>Spinner</code> 和 <code>ErrorFallback</code> 这两个 UI 依赖仍然藏在默认 prop 后面，<strong>只是把隐藏的位置向内挪了一层。</strong></p>
<p>解决方式很简单：<strong>将两个 fallback 都设为必填 prop，并在每个调用位置显式注入。</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">interface</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:#FFAB70;--shiki-light:#E36209">  children</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</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">  pendingFallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</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">  rejectedFallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ComponentType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</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:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<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">QueryAsyncBoundary</span></span>
<span data-line=""><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:#79B8FF;--shiki-light:#005CC5">Spinner</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  rejectedFallback</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">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Content</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:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>代码会多两行，但接受这项成本的理由很明确：**增加编写者的成本，换取所有阅读者更低的追踪成本。**在调用位置就能直接看到会显示哪种 fallback，无需再打开另一个文件确认“这个组件的默认值是什么来着？”那句我们熟悉的观点——代码被阅读的次数远多于被编写的次数——在这里同样成立。</p>
<h2 id="errorfallback"><a class="anchor" href="#errorfallback">ErrorFallback</a></h2>
<p>还有一个值得讨论的地方。通常，我们会把 <code>ErrorFallback</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">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> DEFAULT_ERROR_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>
<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"> ErrorFallback</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">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> message</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getErrorMessage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DEFAULT_ERROR_MESSAGE</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"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Flex</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> direction</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"column"</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> alignItems</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"center"</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:#B392F0;--shiki-light:#6F42C1"> aria-live</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"assertive"</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">Text</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{message}&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Text</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">Spacing</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> size</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">16</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">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">{resetErrorBoundary}>다시 시도&#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">Flex</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>role="alert"</code> 和 <code>aria-live="assertive"</code> 都考虑周全的清晰实现。但不妨问一个问题：<strong>“无论是 401、404、500，还是网络断开，都显示同一个页面真的合适吗？”</strong></p>
<p>大多数情况下，答案是<strong>不合适</strong>，因为用户针对不同错误应该采取不同的行动。</p>
<table>
<thead>
<tr>
<th>错误类型</th>
<th>用户操作</th>
<th>“重试”是否有意义？</th>
</tr>
</thead>
<tbody>
<tr>
<td>网络断开</td>
<td>检查连接后重试</td>
<td>O</td>
</tr>
<tr>
<td>5xx 服务端错误</td>
<td>稍后重试</td>
<td>O</td>
</tr>
<tr>
<td>401 身份验证失败</td>
<td>前往登录页面</td>
<td>X</td>
</tr>
<tr>
<td>403 权限不足</td>
<td>前往其他页面</td>
<td>X</td>
</tr>
<tr>
<td>404 资源不存在</td>
<td>返回列表</td>
<td>△</td>
</tr>
<tr>
<td>422 校验失败</td>
<td>修改输入值</td>
<td>X</td>
</tr>
</tbody>
</table>
<p>在所有情况下都显示“重试”按钮，相当于向用户错误地提示了**“能够解决该错误的操作”**。遇到 401 时，无论点击多少次“重试”，都只会再次得到相同的 401。用户真正应该做的是登录。</p>
<p>因此，错误 fallback 应当<strong>根据错误类型采用不同的呈现方式</strong>。无需一开始就写一个庞大的 <code>if/else</code>，只要创建一些小型组件再进行分流即可。</p>
<p>每个 fallback 组件只展示适合该错误的消息和操作，让页面上只留下用户真正能够采取的行动。</p>
<h3 id="shouldcatch"><a class="anchor" href="#shouldcatch">shouldCatch</a></h3>
<p>再进一步，还可以在组件层面区分**“要捕获的错误”与“要继续传播的错误”**。Suspensive 的 <code>ErrorBoundary</code> 提供了 <code>shouldCatch</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:#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">  shouldCatch</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:#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">&#x26;&#x26;</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 style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><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">{ServerErrorFallback}</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">ErrorBoundary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> shouldCatch</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{NetworkError} </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">{NetworkErrorFallback}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Page</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">&#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>内层 ErrorBoundary 只捕获网络错误，不捕获 5xx 错误。未被捕获的错误会按照 React 的默认行为<strong>继续向上传播到上层 ErrorBoundary</strong>，再由外层 ErrorBoundary 捕获 5xx。与用 if/else 编写相同的错误处理相比，这种方式的吸引力在于可以<strong>赋予边界本身明确的含义</strong>。</p>
<p><code>react-error-boundary</code> 没有这个 prop，但可以在 fallback 内部分流，实现相同效果。重要的是模式本身，而不是具体使用哪个库。</p>
<h2 id="总结"><a class="anchor" href="#总结">总结</a></h2>
<p>总而言之，前端错误处理<strong>无法靠单一工具完成</strong>。渲染阶段的错误由 Error Boundary 负责；事件处理器中的错误由 <code>try/catch</code> 或 <code>showBoundary</code> 负责；异步数据获取错误由 TanStack Query 的 <code>throwOnError</code> 与 <code>useQueryErrorResetBoundary</code> 负责；mutation 错误由 <code>mutateAsync</code> 或 <code>onError</code> 负责；横切关注点则由 <code>QueryCache</code>/<code>MutationCache</code> 负责。在这些基础之上，还需要一并设计<strong>共用组件的命名与组合粒度</strong>以及<strong>错误类型本身的领域建模</strong>，才能形成一致的错误处理策略。</p>
<p>了解这些工具各自的职责之后，才能明确决定：**“这个错误在这里捕获，那个错误继续传播到那里。”**这些决定不断累积，最终构成稳定的用户体验：不让用户看到白屏，不让同一条 toast 弹出五次，不让短暂的网络故障拖垮整个页面，在遇到 401 时显示登录页面而不是“重试”。正是这些细节共同塑造了“这个服务做得很好”的印象。</p>
<p>当然，并非所有项目都需要用上所有模式。简单的后台管理工具可能只需一个 ErrorBoundary 加 toast 就足够；而在支付这种一次失误就意味着真金白银的领域，则需要为每个 mutation 配置细致的错误处理。答案最终由业务领域决定。</p>
<p>也希望读者能借此检查一下自己的项目：“我们的服务目前在哪里、用什么名称的组件捕获哪些错误？”有些错误看似已经被妥善捕获，实际上却可能悄悄漏出，或抵达了错误的 fallback，这类情况或许比想象中更多。（笔者自己也总是如此。）</p>
<h2 id="参考资料"><a class="anchor" href="#参考资料">参考资料</a></h2>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner">[文档] <a href="https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary" target="_blank" rel="noopener noreferrer">React：错误边界</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner">[文档] <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">[文档] <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">[文档] <a href="https://tanstack.com/query/v5/docs/framework/react/guides/important-defaults" target="_blank" rel="noopener noreferrer">TanStack Query：重要默认配置</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner">[文章] <a href="https://tkdodo.eu/blog/react-query-error-handling" target="_blank" rel="noopener noreferrer">TkDodo：React Query 错误处理</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner">[文章] <a href="https://tkdodo.eu/blog/breaking-react-querys-api-on-purpose" target="_blank" rel="noopener noreferrer">TkDodo：有意打破 React Query API</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner">[仓库] <a href="https://github.com/toss/suspensive" target="_blank" rel="noopener noreferrer">toss/suspensive：@suspensive/react-query</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner">[文档] <a href="https://reactrouter.com/how-to/error-boundary" target="_blank" rel="noopener noreferrer">React Router：错误边界</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 时，只把 “Fiber” 当作面试中的高频问题。背下“把 React 的渲染工作拆分成工作单元来处理”这一句话，就以为那是全部。但真正开始阅读 React 源码后，我才意识到 Fiber 并非一个简单概念，而是一套掌管 React 渲染一切环节的运行时架构。 至今仍忘不了第一次打开 React 源码时受到的冲击。当...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊堪称 React 心脏的 <strong>Fiber 架构</strong>。</p>
<p>笔者刚接触 React 时，只把 <strong>“Fiber”</strong> 当作面试中的高频问题。背下“把 React 的渲染工作拆分成工作单元来处理”这一句话，就以为那是全部。但真正开始阅读 React 源码后，我才意识到 Fiber 并非一个简单概念，而是一套掌管 React 渲染<strong>一切环节</strong>的运行时架构。</p>
<blockquote>
<p>至今仍忘不了第一次打开 React 源码时受到的冲击。当时脑海里只有一句：“这些……都是什么？”</p>
</blockquote>
<p>本文不会停留在面对“Fiber 是什么？”时只回答“把工作拆成单元来处理”的层面，而会深入探究 Fiber <strong>为什么</strong>诞生、它是<strong>如何</strong>设计的，以及这种结构又<strong>如何</strong>让 React 的 Concurrent Features 成为可能。</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 并不使用它。原因会在后文详细说明。）</p>
<video width="640" height="480" controls>
  <source src="/content/250520/fiber.mov" type="video/mp4">
</video>
<p>采用 Fiber 后，即使在渲染过程中，也能立即响应用户事件（按钮点击、输入等）。因为工作被拆成小块执行，浏览器终于有了喘息的空间。</p>
<p>如果想亲自体验两者的区别，可以点击**<a href="https://animated-lollipop-2b6cbb.netlify.app/" target="_blank" rel="noopener noreferrer">这里</a>**。你可以直观看到 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>画面撕裂（tearing）</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">// 동시성 렌더링: 시간 제한 내에서 작업을 나누어 처리</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> workLoopConcurrent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">nonIdle</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"> (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:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> yieldAfter</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</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"> (nonIdle </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 25</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">    do</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 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:#B392F0;--shiki-light:#6F42C1"> now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> yieldAfter);</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>workLoopConcurrent</code> 设有<strong>时间限制</strong>，一旦超时就会退出循环。</p>
<p>这里有趣的是 yield 间隔的差异。Transition、Retry 等 **non-idle 工作（用户能够感知的更新）**每 <strong>25ms</strong> 让出一次控制权，而 **idle 工作（可以等到用户没有任何操作时再处理的低优先级工作）**每 <strong>5ms</strong> 让出一次。为 non-idle 工作分配 25ms，是为了有意将动画限制在约 30fps 的水平，防止 transition 渲染让其他工作陷入饥饿状态。</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="放弃-requestidlecallback-的原因"><a class="anchor" href="#放弃-requestidlecallback-的原因">放弃 requestIdleCallback 的原因</a></h3>
<p>前面展示 Fiber 概念模型时使用了 <code>requestIdleCallback</code>，但实际的 React 并不使用它，原因很明确。</p>
<ul>
<li><strong>调用频率太低</strong>：只会在真正的“空闲时间（浏览器无事可做的时间）”被调用，因此在繁忙页面上，React 工作可能被无限期推迟。Dan Abramov 也曾提到，“requestIdleCallback is called too infrequently to be useful for scheduling React work”。</li>
<li><strong>浏览器兼容性问题</strong>：Safari 长期没有实现它，而且不同浏览器的行为并不一致。</li>
<li><strong>20ms 上限</strong>：idle deadline 存在上限，React 无法按自身需求对时间进行可预测的控制。</li>
</ul>
<p>之后，React 又尝试过 <code>requestAnimationFrame</code> + 帧预算估算的方式，但由于 React 的工作并不需要与 vsync（让帧输出与显示器完成垂直扫描的时点同步的技术）周期对齐，这种方案最终也被弃用。</p>
<h3 id="messagechannel"><a class="anchor" href="#messagechannel">MessageChannel</a></h3>
<p>最终，React 选择了 <strong>MessageChannel</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">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:#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:#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"> channel.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>
<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">  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:#B392F0;--shiki-light:#6F42C1"> setTimeout</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></code></pre></figure>
<p>为什么不使用 <code>setTimeout</code>，而要使用 <code>MessageChannel</code>？根据 HTML 规范，<code>setTimeout</code> 嵌套 5 次以上时，会被强制施加<strong>至少 4ms 的延迟</strong>。而 <code>MessageChannel</code> 没有这一限制，可以在下一个事件循环 tick 中立即作为 macrotask 执行。对于以 5ms 为单位拆分工作的 Fiber 来说，人为增加 4ms 延迟是致命的。</p>
<p>（5ms 中有 4ms 都在等待，真正工作的时间就只剩 1ms。这已经不是 work-life balance，只剩 life 了。）</p>
<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 值同时也是**防止饥饿（starvation）**的机制。无论优先级多低，只要超过 timeout，任务就会进入过期状态并被强制执行。即使高优先级工作不断进入，低优先级工作也不会永远遭到忽略。</p>
<p>Scheduler 的 <code>shouldYieldToHost()</code> 会检查工作开始后的经过时间是否超过 <code>frameInterval</code>（默认 <strong>5ms</strong>，定义在 <code>SchedulerFeatureFlags.js</code> 中），并据此决定是否将控制权交还给主线程。</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"><img src="/content/250520/2.png" alt="2.png" width="2486" height="1778" loading="eager" fetchpriority="high" decoding="async"></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><strong>Before Mutation Phase</strong>：<code>commitBeforeMutationEffects()</code>
<ul>
<li>在 DOM 变更前读取当前 DOM 状态。<code>getSnapshotBeforeUpdate</code> 生命周期会在这里执行。此时 <code>current</code> 树仍然代表屏幕上的状态，因此可以安全捕获 DOM 的滚动位置、尺寸等信息。</li>
</ul>
</li>
<li><strong>Mutation Phase</strong>：<code>commitMutationEffects()</code>
<ul>
<li>这一阶段执行<strong>实际 DOM 操作</strong>。插入新节点、修改现有节点、删除无用节点都在这里发生。<code>componentWillUnmount</code> 也在此时执行，因为 <code>current</code> 仍指向旧树，所以可以读取旧状态。</li>
</ul>
</li>
<li><strong>树切换</strong>：<code>root.current = finishedWork</code>
<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><strong>Layout Phase</strong>：<code>commitLayoutEffects()</code>
<ul>
<li>DOM 变更完成后，执行基于新 DOM 状态的工作。
<ul>
<li>执行 <code>componentDidMount</code>、<code>componentDidUpdate</code></li>
<li>执行 <code>useLayoutEffect</code> 回调</li>
<li>此时 <code>current</code> 已经指向新树，因此读取 DOM 时会得到更新后的值</li>
</ul>
</li>
</ul>
</li>
<li><strong>Passive Effects</strong>（异步）
<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>
<h2 id="来源"><a class="anchor" href="#来源">来源</a></h2>
<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[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>
<hr>
<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>
<hr>
<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 的 25 倍、ESLint 的 15 倍。后文会通过直接对比来看看这些数字在实际中意味着什么。</p>
<p><img src="/content/241201/1.png" alt="1.png" width="2250" height="986" loading="eager" fetchpriority="high" decoding="async"></p>
<p><strong>与现有工具兼容。</strong> Biome 与 Prettier 的格式化兼容度约为 97%，并内置了 ESLint 的主要规则。<code>eslint-plugin-react-hooks</code>、<code>eslint-plugin-jsx-a11y</code> 等常用插件的规则也已集成，因此迁移负担相对较小。</p>
<hr>
<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>
<hr>
<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><img src="/content/241201/biome1.png" alt="biome1.png" width="798" height="330" loading="lazy" fetchpriority="low" decoding="async">  <img src="/content/241201/lint1.png" alt="lint1.png" width="766" height="248" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>Biome 为 <strong>506ms</strong>，ESLint + Prettier 为 <strong>630ms</strong>，运行时间快了约 20%。</p>
<hr>
<h3 id="vite-项目的构建时间"><a class="anchor" href="#vite-项目的构建时间">Vite 项目的构建时间</a></h3>
<p><img src="/content/241201/biome2.png" alt="biome2.png" width="920" height="222" loading="lazy" fetchpriority="low" decoding="async"> <img src="/content/241201/lint2.png" alt="lint2.png" width="1022" height="228" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>Biome 为 <strong>117.13s</strong>，ESLint + Prettier 为 <strong>131.48s</strong>，构建时间快了约 10%。</p>
<hr>
<h3 id="lint-任务"><a class="anchor" href="#lint-任务">Lint 任务</a></h3>
<p><img src="/content/241201/biome3.png" alt="biome3.png" width="894" height="174" loading="lazy" fetchpriority="low" decoding="async"> <img src="/content/241201/lint3.png" alt="lint3.png" width="974" height="186" loading="lazy" fetchpriority="low" decoding="async"></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>
<hr>
<p><img src="/content/241201/3.jpeg" alt="3.jpeg" width="265" height="190" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>嗯……（到这个程度，反而更难找到不用它的理由了。）</p>
<hr>
<h2 id="为什么这么快"><a class="anchor" href="#为什么这么快">为什么这么快？</a></h2>
<p>“因为用 Rust 开发，所以速度快”这句话没错，但仅凭这一点还不足以完整解释。下面来看看造就 Biome 性能优势的具体技术因素。</p>
<hr>
<h3 id="rust-的底层性能"><a class="anchor" href="#rust-的底层性能">Rust 的底层性能</a></h3>
<table>
<thead>
<tr>
<th><img src="/content/241201/5.webp" alt="5.webp" width="1200" height="1000" loading="lazy" fetchpriority="low" decoding="async"></th>
<th><img src="/content/241201/6.webp" alt="6.webp" 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>
<hr>
<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>
<hr>
<h3 id="原生并行处理"><a class="anchor" href="#原生并行处理">原生并行处理</a></h3>
<p><img src="/content/241201/7.png" alt="7.png" width="800" height="514" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>Biome 利用 Rust 的并发模型，在多个线程中并行处理文件。它将任务拆分为较小的单元，并通过 work-stealing scheduler 在各线程间高效分配负载。Rust 的所有权系统会在编译阶段从根本上阻止数据竞争（Data Race），因此 runtime 的同步成本也能降到最低。</p>
<p>Node.js 默认采用基于事件循环的单线程模型。虽然可以使用 Worker Threads 实现并行处理，但线程创建和消息传递会带来额外 overhead。Biome 直接使用操作系统级的原生线程，因此能在没有这类 overhead 的情况下充分利用 CPU 核心。</p>
<hr>
<h3 id="内存高效的-ast-处理"><a class="anchor" href="#内存高效的-ast-处理">内存高效的 AST 处理</a></h3>
<p><img src="/content/241201/4.svg" alt="4.svg" 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>
<hr>
<h2 id="那么应该引入-biome-吗"><a class="anchor" href="#那么应该引入-biome-吗">那么，应该引入 Biome 吗？</a></h2>
<p>Biome 的性能和便利性显然很有吸引力。不过，我并不认为所有项目都应该无条件引入它。下面来看看几个实际需要考虑的因素。</p>
<hr>
<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>
<hr>
<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><img src="/content/241201/8.png" alt="8.png" width="2722" height="1384" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>从 npm trends 来看，Biome 每周约 690 万次的下载量与 ESLint 约 1.2 亿次、Prettier 约 8200 万次相比，仍有很大差距。不过，Biome 的增长速度值得关注。仅仅一年多的时间里，其周下载量便增长到原来的三至四倍以上，尤其是在新项目中的采用率显著上升。</p>
<hr>
<h2 id="写在最后"><a class="anchor" href="#写在最后">写在最后</a></h2>
<p>对于 Biome 能否完全取代 ESLint 和 Prettier 这个问题，我的回答是：<strong>“目前还不能，但它已经是非常有力的替代方案。”</strong></p>
<p>它的性能非常出色，配置简洁，开发速度也很快。不过，插件生态尚不成熟，而且对部分语言的支持有限，这些问题可能会因项目而异，成为实际障碍。最好仔细评估项目的技术栈与团队需求，再决定是否引入。</p>
<p>有一点可以确定：前端工具生态正在朝着“更快、更简洁、更集成”的方向发展。不可否认，Biome 正站在这股潮流的前沿。它无疑是一款值得期待未来发展的工具。</p>
<h2 id="参考资料"><a class="anchor" href="#参考资料">参考资料</a></h2>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>프론트엔드</category>
            <category>자바스크립트</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>
<hr>
<h2 id="状态在-react-中如何流动"><a class="anchor" href="#状态在-react-中如何流动">状态在 React 中如何流动</a></h2>
<p>在一般的 React 应用中，状态会像下图这样运作。</p>
<p><img src="/content/240818/3.png" alt="3.png" width="880" height="509" loading="eager" fetchpriority="high" decoding="async"></p>
<p>组件内部状态使用 React 提供的状态管理 hook（<code>useState</code>、<code>useReducer</code>）进行管理，状态则通过 props 传递给子组件。到这里都很简单。</p>
<p>问题出现在相距较远的组件需要共享状态时。React 为此提供的官方解决方案就是 Context API，但它要求必须用 Provider 组件包裹下层树。</p>
<hr>
<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>
<hr>
<h2 id="zustand-活在-react-外部"><a class="anchor" href="#zustand-活在-react-外部">Zustand 活在 React 外部</a></h2>
<p><img src="/content/240818/4.png" alt="4.png" width="1300" height="393" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>Zustand 基于 Flux 模式运行。闭包内部的 <code>state</code> 扮演 Store，用户定义的函数扮演 Action，<code>set</code> 函数扮演 Dispatcher，React 组件则扮演 View。决定性的差异就在这里。</p>
<p><strong>Zustand 的 Store 存在于 React 组件树之外，也就是 JavaScript 模块的作用域内。</strong></p>
<p>所谓组件树之外，是指与 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>
<hr>
<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>
<hr>
<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>
<hr>
<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>
<ul>
<li>
<p><strong>通过闭包封装状态</strong></p>
<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>
</li>
<li>
<p><strong>利用 <code>Object.is</code> 检测变化</strong></p>
<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>
</li>
<li>
<p><strong>采用 Pub/Sub 模式的 listener 系统</strong></p>
<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>
</li>
<li>
<p><strong>创建初始状态</strong></p>
<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> 的**第三个参数（服务端快照）**传入。</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>
</li>
</ul>
<hr>
<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>
<hr>
<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>
<hr>
<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>
<hr>
<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 变化时，它会进行**自下而上（bottom-up）**的传播，只精确地重新渲染依赖该 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>
<hr>
<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>
<hr>
<h2 id="providerless-的阴影"><a class="anchor" href="#providerless-的阴影">ProviderLess 的阴影</a></h2>
<p>当然，没有 Provider 并不全是优点。下面整理一下我认为需要注意的地方。</p>
<hr>
<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>
<hr>
<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>
<hr>
<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>
<hr>
<p><img src="/content/240818/7.jpeg" alt="7.jpeg" width="216" height="233" loading="lazy" fetchpriority="low" decoding="async"></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><strong>和</strong><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>
<hr>
<h3 id="参考资料"><a class="anchor" href="#参考资料">参考资料</a></h3>
<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[这篇文章想聊聊软件中的压缩算法。 我曾负责改进一个公司内部项目的部署流程。它需要把体积很大的构建产物上传到 S3，我也由此切身体会到：构建目录的大小会直接影响上传时间与存储成本。于是一个问题自然出现了：怎样才能更高效地压缩并上传这些文件？ 真正开始研究后，我发现压缩格式比想象中多得多：zip、gzip、zstd、bzip2、xz 等等。名字都很相似，但要找到一份清楚说明它们有何不同、分别适合什么场...]]></description>
            <content:encoded><![CDATA[<p>这篇文章想聊聊软件中的压缩算法。</p>
<p>我曾负责改进一个公司内部项目的部署流程。它需要把体积很大的构建产物上传到 S3，我也由此切身体会到：构建目录的大小会直接影响上传时间与存储成本。于是一个问题自然出现了：怎样才能更高效地压缩并上传这些文件？</p>
<p>真正开始研究后，我发现压缩格式比想象中多得多：zip、gzip、zstd、bzip2、xz 等等。名字都很相似，但要找到一份清楚说明它们有何不同、分别适合什么场景的资料并不容易。（我原以为压缩都差不多，后来才发现世界很大，让文件变小的方法也很多。）</p>
<p>因此，我想借这个机会比较主要压缩格式的原理和特点，并整理当时为什么选择了其中一种方案。</p>
<hr>
<h2 id="什么是无损压缩"><a class="anchor" href="#什么是无损压缩">什么是无损压缩？</a></h2>
<p>无损压缩（Lossless Compression）是一种能够完整还原原始数据的压缩方式。它不同于图像和音频中常见的有损压缩（Lossy Compression）：解压后的数据与原始数据连一个比特都不会不同。源代码和构建产物对数据完整性要求很高，因此必须使用无损压缩。</p>
<p>无损压缩的核心思想是<strong>利用数据中存在的统计冗余</strong>。把反复出现的模式替换成更短的表示，整体体积就会减小。</p>
<p>其中，<strong>基于字典（Dictionary-Based）的方法</strong>是应用最广泛的无损压缩算法家族之一。这里的“字典”不是解释词义的词典，而是一张把之前出现过的数据片段映射成短代码的查找表。Abraham Lempel 和 Jacob Ziv 在 1977 年的论文 <em>"A Universal Algorithm for Sequential Data Compression"</em>（IEEE Transactions on Information Theory）中提出的 <strong>LZ77</strong>，以及第二年发表的 <strong>LZ78</strong>，正是这个家族的始祖。“LZ”取自两位研究者姓氏的首字母。后来几乎所有基于字典的压缩算法，包括 DEFLATE、LZMA、LZ4 和 Zstd，都能追溯到这两种算法。（说大部分压缩算法的家谱最终都汇聚到这两个人身上，并不夸张。）</p>
<p>举个简单的例子。如果“Linux”这个单词在文本中出现 100 次，压缩器可以在第一次出现时把它登记进字典，后续出现时则替换成表示“字典第 1 项”的短引用，也就是指针。“Linux”占 5 个字节，而指针通常可以用更少的字节表示，整体大小因此下降。</p>
<p>那么，LZ77 和 LZ78 具体有什么区别？</p>
<hr>
<h3 id="lz77滑动窗口方式"><a class="anchor" href="#lz77滑动窗口方式">LZ77：滑动窗口方式</a></h3>
<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>算法会检查前向缓冲区的开头是否曾在搜索缓冲区中出现。如果找到相同模式，就把这次匹配编码为**（距离、长度、下一个字符）**的元组。距离表示要向后退多少字符才能找到匹配的起点，长度表示匹配持续多少字符。</p>
<p>假设用 LZ77 压缩字符串 <code>"banana_banana"</code>。遇到第二个 <code>"banana"</code> 时，算法实际上是在说：<em>“向后退 7 个字符，然后复制 6 个字符。”</em> 原本 6 字节的字符串就可以只用两个数字表示。</p>
<p>这种方式的关键在于<strong>不必另外保存或传输字典</strong>。解码器在解压过程中会自然重建搜索缓冲区，因此字典被隐式地嵌入数据本身。代价是解压必须从数据开头按顺序进行。从原理上说，无法从中间任意位置开始解压。</p>
<p>窗口大小与压缩率之间存在直接的权衡。窗口越大，就越能引用距离更远的模式，压缩率通常也越高；但匹配搜索所需的计算量和内存占用也会随之增加。</p>
<hr>
<h3 id="lz78显式字典方式"><a class="anchor" href="#lz78显式字典方式">LZ78：显式字典方式</a></h3>
<p>与 LZ77 不同，LZ78 会在压缩过程中<strong>构建一份显式字典</strong>，不使用滑动窗口。它把之前见过的模式保存成带索引的字典项，之后遇到相同模式时用索引替换。</p>
<p>LZ78 的输出单位是**（字典索引、下一个字符）**形式的标签。编码器先找到字典中最长的匹配项，再输出该项的索引和打破匹配的下一个字符，随后把 <em>“刚才匹配的项＋新字符”</em> 加入字典。字典会在处理过程中逐步增长。</p>
<p>LZ78 最著名的变体是 <strong>LZW</strong>（Lempel-Ziv-Welch）。Terry Welch 在 1984 年发表了这项改进，它被用于 GIF 图像格式和 Unix 的 <code>compress</code> 工具（扩展名为 <code>.Z</code>）。（LZW 曾处于专利纠纷的中心，这件事也成为 PNG 格式诞生的原因之一。）</p>
<hr>
<h3 id="现代压缩算法属于哪一支"><a class="anchor" href="#现代压缩算法属于哪一支">现代压缩算法属于哪一支？</a></h3>
<p>有趣的是，今天几乎所有主流压缩算法都是 <strong>LZ77 的后代</strong>。</p>
<p>Storer 和 Szymanski 在 1982 年发表的 <strong>LZSS</strong> 是 LZ77 的改进版。它加入一个 1 比特标记，用来区分每次输出是“字面量，也就是原始字符”，还是“长度与距离的组合”。如果匹配太短，引用反而不划算，编码器就直接输出原字符。</p>
<p>1993 年，Phil Katz 把 LZSS 与<strong>霍夫曼编码</strong>结合，创造了 <strong>DEFLATE</strong>。霍夫曼编码是一种为高频符号分配更短比特串的熵编码。ZIP、GZIP 和 PNG 都使用 DEFLATE。也就是说，我们每天接触的 <code>.zip</code>、<code>.gz</code>、<code>.png</code> 文件都是 LZ77 的直系后代。</p>
<p>后来出现的 <strong>LZMA</strong>（7-Zip、XZ）、<strong>LZ4</strong> 和 <strong>Zstd</strong> 也都以 LZ77 的滑动窗口思想为起点，继续改进匹配搜索的数据结构和熵编码方法。相比之下，LZ78 家族在 LZW 之后基本退出了主流舞台。</p>
<p>理论上已经证明，两种算法在 <em>“完整解压全部数据”</em> 时具有等价的能力。LZ77 最终胜出的原因，是<strong>把字典嵌入数据的设计在实现和扩展两方面都更灵活</strong>。滑动窗口的大小、匹配搜索算法以及后续熵编码器都可以自由组合，因此能够随时代需求继续演化。</p>
<p>评价压缩性能时通常要看两个维度：<strong>压缩率</strong>，也就是能缩小多少；以及<strong>压缩速度</strong>，也就是多久能完成。追求更高压缩率通常需要更多计算，压缩时间也会更长。现实中的压缩策略，核心就是在两者之间找到合适的平衡点。</p>
<p>有了这些基础，下面逐一比较几种常见格式。</p>
<hr>
<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>
<hr>
<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>
<hr>
<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）理论的熵编码器，结合了霍夫曼编码与算术编码（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>
<hr>
<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>
<hr>
<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>
<hr>
<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 定义的 **UStar（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>
<hr>
<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>
<hr>
<h2 id="为什么-targz-的压缩率高于-zip"><a class="anchor" href="#为什么-targz-的压缩率高于-zip">为什么 tar.gz 的压缩率高于 ZIP？</a></h2>
<p>原因在于**固实归档（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>
<hr>
<p><strong>2026 年补充</strong></p>
<h2 id="2024-年选择了-targz现在会怎么选"><a class="anchor" href="#2024-年选择了-targz现在会怎么选">2024 年选择了 tar.gz，现在会怎么选？</a></h2>
<p>当时选择 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>
<hr>
<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>
<hr>
<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>
    </channel>
</rss>