<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Optimistic-Update on Tarragon</title><link>https://tarrragon.github.io/blog/tags/optimistic-update/</link><description>Recent content in Optimistic-Update on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 17 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/optimistic-update/index.xml" rel="self" type="application/rss+xml"/><item><title>時間感知與回應策略：Loading 的形式由等待時間門檻決定</title><link>https://tarrragon.github.io/blog/ux-design/06-interaction-feedback/response-time-strategy/</link><pubDate>Mon, 13 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ux-design/06-interaction-feedback/response-time-strategy/</guid><description>&lt;h2 id="核心觀念">核心觀念&lt;/h2>
&lt;p>&lt;strong>回饋的形式取決於等待時間&lt;/strong> — loading spinner 只適合中間帶的等待。100ms 的頁面切換和 30 秒的批次匯出需要的指示完全不同：前者加 spinner 是視覺雜訊、讓操作顯得比實際慢，後者只有 spinner 撐不住使用者的耐心、他需要知道還要等多久。&lt;/p>
&lt;p>設計回饋策略的依據是人類對時間的主觀感知 — 不同的等待長度會觸發不同的心理反應。本篇展開&lt;a href="../feedback-three-layers/">三層回饋模型&lt;/a>第二層「等待指示」（三層＝點擊確認 / 等待指示 / 結果通知）的策略選擇。&lt;/p>
&lt;h2 id="時間門檻">時間門檻&lt;/h2>
&lt;p>這些門檻來自兩個量測對象不同的研究框架：100ms / 1s / 10s 是感知與注意力門檻（Miller、Nielsen）、400ms 是生產力門檻（&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/doherty-threshold/" data-link-title="Doherty Threshold（400ms 門檻）" data-link-desc="說明 400ms 回應時間門檻的出身（IBM 生產力研究）、現代設計慣例的轉譯過程，以及它跟 Nielsen 感知門檻的量測差異">Doherty&lt;/a>）。本篇把兩者合併在同一條時間軸上使用 — 設計決策關心的是「等多久該給什麼回饋」，兩個框架在這個問題上給出互補的分段點。&lt;/p>
&lt;h3 id="100ms--即時感知門檻">100ms — 即時感知門檻&lt;/h3>
&lt;p>來源：人類感知研究（Miller, 1968; Card, Moran &amp;amp; Newell, 1983）。&lt;/p>
&lt;p>100ms 以內的延遲被感知為「即時」。使用者不會注意到任何等待，操作感覺像是直接發生。&lt;/p>
&lt;p>&lt;strong>設計策略&lt;/strong>：第一層的點擊確認（視覺狀態變化）就足夠，無需額外等待指示。&lt;/p>
&lt;p>&lt;strong>適用&lt;/strong>：同步操作（導航、切換、本地計算）。&lt;/p>
&lt;h3 id="400ms--doherty-threshold">400ms — Doherty Threshold&lt;/h3>
&lt;p>來源：Doherty &amp;amp; Thadani, &amp;ldquo;The Economic Value of Rapid Response Time&amp;rdquo;（IBM 技術報告, 1982）。&lt;/p>
&lt;p>原始研究發現：當電腦回應時間低於 400ms 時，使用者的操作效率大幅提升 — 人機互動進入一種「流暢循環」，雙方都不需要等對方。超過 400ms，這個循環被打斷。&lt;/p>
&lt;p>&lt;strong>設計策略&lt;/strong>：400ms 以內完成的操作可省略 loading indicator — 把生產力門檻讀成回饋門檻是 Laws of UX 等現代整理的轉譯、本篇沿用。可以用動畫過渡（fade in、slide）讓結果「出現」而非「替換」，利用動畫時間掩蓋等待感。&lt;/p>
&lt;p>&lt;strong>適用&lt;/strong>：快速 API 回應、本地資料庫查詢、簡單計算。&lt;/p>
&lt;h3 id="1-秒--心流維持門檻">1 秒 — 心流維持門檻&lt;/h3>
&lt;p>來源：Nielsen&amp;rsquo;s response time limits（Usability Engineering, 1993）。&lt;/p>
&lt;p>1 秒以內，使用者的思考流程不會被打斷。雖然能感知到延遲，但不會覺得「系統卡住了」。&lt;/p>
&lt;p>&lt;strong>設計策略&lt;/strong>：開始顯示輕量級等待指示。按鈕進入 loading 狀態（spinner 取代文字）。但不需要進度百分比或預估時間。&lt;/p>
&lt;p>&lt;strong>適用&lt;/strong>：一般 API 請求、檔案讀寫、中等複雜度查詢。&lt;/p>
&lt;h3 id="10-秒--注意力維持上限">10 秒 — 注意力維持上限&lt;/h3>
&lt;p>來源：Nielsen&amp;rsquo;s response time limits（同上）。&lt;/p>
&lt;p>超過 10 秒，使用者開始考慮離開。注意力已經從當前操作轉移到是否放棄的權衡上。&lt;/p>
&lt;p>&lt;strong>設計策略&lt;/strong>：必須提供進度指示（百分比 / 步驟計數 / 預估剩餘時間），讓使用者判斷「還值不值得等」。考慮提供取消操作的選項。操作再長（數十秒以上）考慮轉為背景任務、完成後由通知系統告知 — 通知系統設計在本篇範圍外。&lt;/p>
&lt;p>&lt;strong>適用&lt;/strong>：檔案上傳、批次處理、大型資料同步。&lt;/p>
&lt;h2 id="策略對照表">策略對照表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>等待時間&lt;/th>
 &lt;th>使用者感受&lt;/th>
 &lt;th>回饋策略&lt;/th>
 &lt;th>範例&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>0-100ms&lt;/td>
 &lt;td>即時&lt;/td>
 &lt;td>點擊確認即可&lt;/td>
 &lt;td>頁面導航、開關切換&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>100-400ms&lt;/td>
 &lt;td>幾乎即時&lt;/td>
 &lt;td>動畫過渡掩蓋等待&lt;/td>
 &lt;td>快速搜尋、本地查詢&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>400ms-1s&lt;/td>
 &lt;td>短暫等待&lt;/td>
 &lt;td>Spinner / loading 狀態&lt;/td>
 &lt;td>API 查詢、表單送出&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>1-10s&lt;/td>
 &lt;td>明顯等待&lt;/td>
 &lt;td>進度指示 + spinner&lt;/td>
 &lt;td>檔案上傳、資料匯入&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&amp;gt; 10s&lt;/td>
 &lt;td>長時間等待&lt;/td>
 &lt;td>進度百分比 + 預估時間 + 取消選項&lt;/td>
 &lt;td>批次處理、大型同步&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="門檻怎麼落地延遲是分布不是常數">門檻怎麼落地：延遲是分布不是常數&lt;/h2>
&lt;p>策略對照表把「等待時間」當成單一數值，但真實延遲是分布：同一支 API 平常 300ms，尖峰或弱網下 3s。發出請求前無法知道這一次會落在哪一帶，照表選策略需要兩個補充機制。&lt;/p>
&lt;p>&lt;strong>用高百分位對表，不用平均值&lt;/strong>：對表的統計量取 p90 或 p95（應用效能監控（APM）平台或瀏覽器 DevTools 的 Network 面板都查得到）。平均 300ms、p95 卻 2s 的操作，代表每二十次就有一次使用者盯著沒有任何指示的畫面等 2 秒 — 對表要照顧的是這些請求，不是平均值。&lt;/p>
&lt;h3 id="延遲顯示與最短顯示時間防-spinner-閃爍">延遲顯示與最短顯示時間：防 spinner 閃爍&lt;/h3>
&lt;p>&lt;strong>延遲顯示（delayed spinner）&lt;/strong>：發出請求後先不顯示 spinner，等 300-400ms 才掛上；請求先回來就完全不顯示。快的請求（多數）沒有 spinner 閃爍，慢的請求（少數）在使用者開始懷疑之前得到指示。這是延遲分布橫跨門檻帶時的標準解 — 判準從「這支 API 多快」變成「超過 400ms 才需要告訴使用者」。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心觀念">核心觀念</h2>
<p><strong>回饋的形式取決於等待時間</strong> — loading spinner 只適合中間帶的等待。100ms 的頁面切換和 30 秒的批次匯出需要的指示完全不同：前者加 spinner 是視覺雜訊、讓操作顯得比實際慢，後者只有 spinner 撐不住使用者的耐心、他需要知道還要等多久。</p>
<p>設計回饋策略的依據是人類對時間的主觀感知 — 不同的等待長度會觸發不同的心理反應。本篇展開<a href="../feedback-three-layers/">三層回饋模型</a>第二層「等待指示」（三層＝點擊確認 / 等待指示 / 結果通知）的策略選擇。</p>
<h2 id="時間門檻">時間門檻</h2>
<p>這些門檻來自兩個量測對象不同的研究框架：100ms / 1s / 10s 是感知與注意力門檻（Miller、Nielsen）、400ms 是生產力門檻（<a href="/blog/ux-design/knowledge-cards/doherty-threshold/" data-link-title="Doherty Threshold（400ms 門檻）" data-link-desc="說明 400ms 回應時間門檻的出身（IBM 生產力研究）、現代設計慣例的轉譯過程，以及它跟 Nielsen 感知門檻的量測差異">Doherty</a>）。本篇把兩者合併在同一條時間軸上使用 — 設計決策關心的是「等多久該給什麼回饋」，兩個框架在這個問題上給出互補的分段點。</p>
<h3 id="100ms--即時感知門檻">100ms — 即時感知門檻</h3>
<p>來源：人類感知研究（Miller, 1968; Card, Moran &amp; Newell, 1983）。</p>
<p>100ms 以內的延遲被感知為「即時」。使用者不會注意到任何等待，操作感覺像是直接發生。</p>
<p><strong>設計策略</strong>：第一層的點擊確認（視覺狀態變化）就足夠，無需額外等待指示。</p>
<p><strong>適用</strong>：同步操作（導航、切換、本地計算）。</p>
<h3 id="400ms--doherty-threshold">400ms — Doherty Threshold</h3>
<p>來源：Doherty &amp; Thadani, &ldquo;The Economic Value of Rapid Response Time&rdquo;（IBM 技術報告, 1982）。</p>
<p>原始研究發現：當電腦回應時間低於 400ms 時，使用者的操作效率大幅提升 — 人機互動進入一種「流暢循環」，雙方都不需要等對方。超過 400ms，這個循環被打斷。</p>
<p><strong>設計策略</strong>：400ms 以內完成的操作可省略 loading indicator — 把生產力門檻讀成回饋門檻是 Laws of UX 等現代整理的轉譯、本篇沿用。可以用動畫過渡（fade in、slide）讓結果「出現」而非「替換」，利用動畫時間掩蓋等待感。</p>
<p><strong>適用</strong>：快速 API 回應、本地資料庫查詢、簡單計算。</p>
<h3 id="1-秒--心流維持門檻">1 秒 — 心流維持門檻</h3>
<p>來源：Nielsen&rsquo;s response time limits（Usability Engineering, 1993）。</p>
<p>1 秒以內，使用者的思考流程不會被打斷。雖然能感知到延遲，但不會覺得「系統卡住了」。</p>
<p><strong>設計策略</strong>：開始顯示輕量級等待指示。按鈕進入 loading 狀態（spinner 取代文字）。但不需要進度百分比或預估時間。</p>
<p><strong>適用</strong>：一般 API 請求、檔案讀寫、中等複雜度查詢。</p>
<h3 id="10-秒--注意力維持上限">10 秒 — 注意力維持上限</h3>
<p>來源：Nielsen&rsquo;s response time limits（同上）。</p>
<p>超過 10 秒，使用者開始考慮離開。注意力已經從當前操作轉移到是否放棄的權衡上。</p>
<p><strong>設計策略</strong>：必須提供進度指示（百分比 / 步驟計數 / 預估剩餘時間），讓使用者判斷「還值不值得等」。考慮提供取消操作的選項。操作再長（數十秒以上）考慮轉為背景任務、完成後由通知系統告知 — 通知系統設計在本篇範圍外。</p>
<p><strong>適用</strong>：檔案上傳、批次處理、大型資料同步。</p>
<h2 id="策略對照表">策略對照表</h2>
<table>
  <thead>
      <tr>
          <th>等待時間</th>
          <th>使用者感受</th>
          <th>回饋策略</th>
          <th>範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>0-100ms</td>
          <td>即時</td>
          <td>點擊確認即可</td>
          <td>頁面導航、開關切換</td>
      </tr>
      <tr>
          <td>100-400ms</td>
          <td>幾乎即時</td>
          <td>動畫過渡掩蓋等待</td>
          <td>快速搜尋、本地查詢</td>
      </tr>
      <tr>
          <td>400ms-1s</td>
          <td>短暫等待</td>
          <td>Spinner / loading 狀態</td>
          <td>API 查詢、表單送出</td>
      </tr>
      <tr>
          <td>1-10s</td>
          <td>明顯等待</td>
          <td>進度指示 + spinner</td>
          <td>檔案上傳、資料匯入</td>
      </tr>
      <tr>
          <td>&gt; 10s</td>
          <td>長時間等待</td>
          <td>進度百分比 + 預估時間 + 取消選項</td>
          <td>批次處理、大型同步</td>
      </tr>
  </tbody>
</table>
<h2 id="門檻怎麼落地延遲是分布不是常數">門檻怎麼落地：延遲是分布不是常數</h2>
<p>策略對照表把「等待時間」當成單一數值，但真實延遲是分布：同一支 API 平常 300ms，尖峰或弱網下 3s。發出請求前無法知道這一次會落在哪一帶，照表選策略需要兩個補充機制。</p>
<p><strong>用高百分位對表，不用平均值</strong>：對表的統計量取 p90 或 p95（應用效能監控（APM）平台或瀏覽器 DevTools 的 Network 面板都查得到）。平均 300ms、p95 卻 2s 的操作，代表每二十次就有一次使用者盯著沒有任何指示的畫面等 2 秒 — 對表要照顧的是這些請求，不是平均值。</p>
<h3 id="延遲顯示與最短顯示時間防-spinner-閃爍">延遲顯示與最短顯示時間：防 spinner 閃爍</h3>
<p><strong>延遲顯示（delayed spinner）</strong>：發出請求後先不顯示 spinner，等 300-400ms 才掛上；請求先回來就完全不顯示。快的請求（多數）沒有 spinner 閃爍，慢的請求（少數）在使用者開始懷疑之前得到指示。這是延遲分布橫跨門檻帶時的標準解 — 判準從「這支 API 多快」變成「超過 400ms 才需要告訴使用者」。</p>
<p><strong>最短顯示時間（minimum display time）</strong>：spinner 一旦顯示就至少停留一段時間（常見慣例值 300ms），請求提早完成也不立即移除，消除「spinner 閃一下就消失」的視覺雜訊。</p>
<p>延遲顯示的 300-400ms 與最短顯示的 300ms 都是業界慣例值、不是研究門檻，可依產品實際延遲分布調整。原則是顯示決策跟著使用者的感知走、不跟著單次請求的實際時長走。</p>
<table>
  <thead>
      <tr>
          <th>延遲分布</th>
          <th>策略</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>p90 &lt; 400ms</td>
          <td>延遲顯示 spinner（多數使用者不會看到它）</td>
      </tr>
      <tr>
          <td>p90 400ms - 1s</td>
          <td>按鈕 loading 狀態 + 最短顯示時間</td>
      </tr>
      <tr>
          <td>p90 1 - 10s</td>
          <td>進度指示</td>
      </tr>
      <tr>
          <td>跨帶（p50 與 p95 差一個帶以上）</td>
          <td>以 p95 所在帶設計 UI、加延遲顯示讓快的請求不受擾</td>
      </tr>
  </tbody>
</table>
<p>驗證方式：用瀏覽器 DevTools 的網速節流（或 proxy 限速）模擬慢回應，逐帶確認對應的 UI 真的會出現；沒有節流工具時，暫時在 client 加人工延遲也能達到同樣目的。</p>
<h2 id="感知效能技巧">感知效能技巧</h2>
<p>真實效能無法改變時（伺服器就是要跑 3 秒），可以透過設計降低使用者的等待感受：</p>
<h3 id="骨架畫面skeleton-screen">骨架畫面（Skeleton Screen）</h3>
<p>在資料載入前先顯示內容區域的灰色佔位形狀。使用者看到「畫面結構」比看到空白等待更不焦慮。適合列表、卡片等有明確版面的內容。佔位尺寸要與最終內容一致 — 載入完成瞬間的版面跳動（layout shift）比空白等待更傷體驗，會抵銷 skeleton 的全部收益。</p>
<h3 id="spinner-vs-skeleton形狀已知用-skeleton未知用-spinner">Spinner vs Skeleton：形狀已知用 skeleton、未知用 spinner</h3>
<p>Spinner 和 Skeleton Screen 都是等待指示，但適用場景不同。選錯會降低使用者體驗。</p>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>Spinner</th>
          <th>Skeleton Screen</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>結果形狀已知（列表 / 卡片 / 個人資料）</td>
          <td></td>
          <td>適合：佔位形狀接近最終結果，降低焦慮</td>
      </tr>
      <tr>
          <td>結果形狀不確定（連線結果 / 計算結果）</td>
          <td>適合：無法預畫佔位形狀</td>
          <td></td>
      </tr>
      <tr>
          <td>操作觸發的等待（按下按鈕後）</td>
          <td>適合：按鈕內 spinner</td>
          <td></td>
      </tr>
      <tr>
          <td>頁面首次載入（進入新畫面）</td>
          <td></td>
          <td>適合：給畫面結構預期</td>
      </tr>
      <tr>
          <td>等待時間短且可預測（&lt; 2s）</td>
          <td>適合：輕量指示</td>
          <td></td>
      </tr>
      <tr>
          <td>等待時間較長（2-10s）</td>
          <td></td>
          <td>適合：佔位結構降低空白等待的焦慮</td>
      </tr>
  </tbody>
</table>
<p><strong>判斷口訣</strong>：「知道長什麼樣 → skeleton；不知道 → spinner。」</p>
<p>表中三個條件軸（形狀、觸發方式、時長）可能同時命中不同建議，裁決順序是<strong>形狀 &gt; 觸發 &gt; 時長</strong>。例：按下按鈕載入個人資料、預期 1.5s — 形狀已知（個人資料版面固定）就用 skeleton，即使「操作觸發」那列寫 spinner。觸發方式與時長只在形狀軸分不出勝負（形狀部分已知、或混合內容）時才進入裁決。</p>
<p>表中 2s 的分界是切分用的慣例值、不在研究門檻系裡。1-10s 帶跟策略對照表的「進度指示」建議並不衝突 — 內容載入走 skeleton、操作進度走進度指示，正是形狀軸裁決的結果。</p>
<h3 id="樂觀更新optimistic-update">樂觀更新（Optimistic Update）</h3>
<p>在伺服器回應前先更新 UI，假設操作會成功。如果失敗再回滾。適合高成功率的操作（按讚、收藏、勾選）。風險：失敗回滾時，使用者看到剛完成的操作被悄悄還原、會開始不信任介面 — 回滾因此要走<a href="../feedback-three-layers/">三層回饋模型</a>的失敗通知：明確告知「操作未成功、已還原」加上下一步（重試），讓還原是被告知的結果、不是無聲消失。</p>
<h3 id="漸進式載入progressive-loading">漸進式載入（Progressive Loading）</h3>
<p>先載入最重要的內容（文字），再載入次要內容（圖片、評論）。使用者可以在等待期間開始閱讀，降低「什麼都不能做」的焦慮。</p>
<h3 id="動畫過渡">動畫過渡</h3>
<p>用動畫填充短暫的等待時間。300ms 的 fade-in 動畫可以掩蓋 200ms 的 API 延遲，使用者感知到的是「動畫」而非「等待」。</p>
<h2 id="不要做的事">不要做的事</h2>
<table>
  <thead>
      <tr>
          <th>反模式</th>
          <th>為什麼不好</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>每個操作都顯示全螢幕 loading</td>
          <td>阻斷所有互動，打斷心流</td>
      </tr>
      <tr>
          <td>spinner 顯示後立即消失（閃爍）</td>
          <td>視覺雜訊，比不顯示更差</td>
      </tr>
      <tr>
          <td>進度條從 0% 跳到 100%</td>
          <td>失去進度指示的意義</td>
      </tr>
      <tr>
          <td>假進度條（與實際進度無關）</td>
          <td>被識破後失去信任</td>
      </tr>
      <tr>
          <td>Loading 永不消失（無 timeout）</td>
          <td>介面凍結，使用者只能殺 app</td>
      </tr>
  </tbody>
</table>
<p>進度條類的反模式共享一個根因：進度指示的價值在於讓使用者校準預期，指示與實際脫鉤（假進度、0% 跳 100%）時信任一次性崩壞。拿不到真實進度時，用步驟計數（「3 / 5 個檔案」）或 indeterminate 動畫誠實表達「在跑、但無法預估」，優於編造百分比。</p>
<p>完成宣告的誠實度與進度指示同源。「完成」的判定條件若只是「連續 N 輪沒有變化」，分不出「真的到底了」和「載入通道根本沒打開」— 一個 Chrome extension 從 lazy-load 頁面提取書目，96/928 本就因 count 穩定誤判完成，使用者帶著殘缺資料離開且很難察覺（<a href="/blog/ux-design/cases/lazy-load-premature-completion/" data-link-title="U.C11 抓到 96/928 本就顯示完成 — 完成判定的證據強度不足" data-link-desc="批次 / 遍歷類操作宣告完成、實際只處理了一部分：「連續 N 輪沒有變化」是暫時停滯的訊號、不是窮盡的證據 — 完成判定需要獨立的窮盡證據（總數對照、終止標記）">U.C11</a>）。宣告完成要有窮盡證據（總數對照、終止標記、API 側的分頁 cursor 耗盡）；只有停滯訊號時，改為「已取得 N 筆、可能還有更多」的部分結果表達 — 下游需要全量的操作（備份、匯出）不適用降級、不完整按失敗處理。全螢幕 loading 的問題則是阻斷範圍 — 等待指示只需要覆蓋被等待的區域，不需要凍結整個介面。</p>
<h2 id="設計檢查清單">設計檢查清單</h2>
<ul>
<li><input disabled="" type="checkbox"> 操作的延遲分布（p90 / p95）是多少？對應哪個門檻帶？（跨帶判定另看 p50）</li>
<li><input disabled="" type="checkbox"> 低於 400ms 的操作：不顯示 spinner，用動畫過渡？</li>
<li><input disabled="" type="checkbox"> 400ms-1s 的操作：有按鈕 loading 狀態？</li>
<li><input disabled="" type="checkbox"> 超過 1s 的操作：有進度指示？</li>
<li><input disabled="" type="checkbox"> 超過 10s 的操作：有取消選項？</li>
<li><input disabled="" type="checkbox"> 有考慮感知效能技巧（骨架畫面 / 樂觀更新 / 漸進載入）？</li>
<li><input disabled="" type="checkbox"> spinner 有延遲顯示與最短顯示時間（防閃爍）？</li>
<li><input disabled="" type="checkbox"> 用 DevTools 網速節流（或 proxy 限速）模擬慢回應，驗證各門檻帶的 UI 都會出現？</li>
</ul>
<h2 id="參考來源">參考來源</h2>
<ul>
<li><strong>Doherty, W.J. &amp; Thadani, A.J.</strong> &ldquo;The Economic Value of Rapid Response Time.&rdquo; IBM, 1982（技術報告）— 400ms 門檻的原始研究</li>
<li><strong>Nielsen, J.</strong> Usability Engineering. Academic Press, 1993 — 100ms / 1s / 10s 三門檻模型</li>
<li><strong>Card, S.K., Moran, T.P. &amp; Newell, A.</strong> The Psychology of Human-Computer Interaction. Lawrence Erlbaum Associates, 1983 — 人機互動的認知心理學基礎</li>
<li><strong>Miller, R.B.</strong> &ldquo;Response Time in Man-Computer Conversational Transactions.&rdquo; Proceedings of the AFIPS Fall Joint Computer Conference, 1968 — 回應時間對使用者行為影響的早期研究</li>
<li><strong>Material Design 3: Loading Indicator</strong>（<a href="https://m3.material.io/components/loading-indicator">m3.material.io/components/loading-indicator</a>）— Google 的 loading 設計規範</li>
<li><strong>Laws of UX: Doherty Threshold</strong>（<a href="https://lawsofux.com/doherty-threshold">lawsofux.com/doherty-threshold</a>）— Doherty Threshold 的現代詮釋</li>
</ul>
]]></content:encoded></item><item><title>單調狀態機與樂觀更新的回滾契約：前台不得顯示後端沒記錄的狀態</title><link>https://tarrragon.github.io/blog/work-log/pos_monotonic_status_optimistic_rollback/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/pos_monotonic_status_optimistic_rollback/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：POS App 的品項處理進度（未確認 → 已確認 → 已完成）由員工在前端推進、同步到後端。兩個設計決策被測試逼著說清楚：為什麼狀態只能往前？為什麼後端拒絕時一定要回滾樂觀更新？
&lt;strong>疑問來源&lt;/strong>：狀態遞增的守則寫在模型方法裡、回滾寫在服務層——兩條規則的「為什麼」散在註解裡，直到為它們補測試時才發現各自對應一個業務不變式。
&lt;strong>整理目的&lt;/strong>：把「單調狀態機」與「樂觀更新的回滾契約」整理成判準，並記錄它們與防護鏈的依賴關係。
&lt;strong>本文邊界&lt;/strong>：不變式落點的理論層見 &lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="1-單調狀態機不可逆的現實不可逆的模型">1. 單調狀態機：不可逆的現實、不可逆的模型&lt;/h2>
&lt;p>品項處理狀態的變更方法只接受「更高」的狀態值——同值與回退一律拒絕（回傳 false、不改狀態）。理由不是技術潔癖，是&lt;strong>狀態對應的現實動作不可逆&lt;/strong>：餐點端出去就收不回來。模型層的單調守則把「UI 誤觸」「事件亂序抵達」「重複訊息」全部擋在同一個入口。&lt;/p>
&lt;p>終態的側分支另有一格：「已取消」不在遞增序列上，而是從中途狀態岔出的終點——已完成的品項不可取消（現實上已交付）、已取消的不可再推進。整個狀態機小到一張表就能窮舉：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>從 \ 到&lt;/th>
 &lt;th>已確認&lt;/th>
 &lt;th>已完成&lt;/th>
 &lt;th>已取消&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>未確認&lt;/td>
 &lt;td>可&lt;/td>
 &lt;td>可&lt;/td>
 &lt;td>可&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>已確認&lt;/td>
 &lt;td>不可（同值）&lt;/td>
 &lt;td>可&lt;/td>
 &lt;td>可&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>已完成&lt;/td>
 &lt;td>不可（回退）&lt;/td>
 &lt;td>不可&lt;/td>
 &lt;td>不可（已交付）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>已取消&lt;/td>
 &lt;td>不可&lt;/td>
 &lt;td>不可&lt;/td>
 &lt;td>不可&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>單調＋終態側分支，是「進度追蹤」類狀態機的常見形狀；把它寫成模型方法的入口守則，比散在各呼叫端的 if 檢查可靠一個量級——這正是&lt;a href="https://tarrragon.github.io/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡&lt;/a>講的「領域方法作為唯一變更路徑」。&lt;/p>
&lt;h2 id="2-樂觀更新的回滾契約">2. 樂觀更新的回滾契約&lt;/h2>
&lt;p>推進狀態的流程是樂觀式：先改本地（UI 立即反映）、再同步後端、失敗才回滾。回滾那一步在 code review 裡常被當成「禮貌性的清理」，但這裡它是硬契約，原因在依賴鏈：&lt;/p>
&lt;p>&lt;strong>這個狀態是其他防護規則的資料來源。&lt;/strong>「有品項已完成 → 不可刪除單據」「全部完成 → 才可拆分」——這些守衛讀的就是它。若後端拒絕後前台不回滾，會出現「前台顯示已完成、後端沒有記錄」的分裂狀態：守衛在前台的假象上做防護決策，放行了不該放行的操作，或擋下了不該擋的。&lt;/p>
&lt;p>由此得出樂觀更新的回滾判準：&lt;strong>看這個狀態有沒有下游讀者&lt;/strong>。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>狀態的消費者&lt;/th>
 &lt;th>回滾的必要性&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>只有畫面顯示&lt;/td>
 &lt;td>失敗提示＋下次同步自然修正，可接受&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>有防護規則、流程分支讀它&lt;/td>
 &lt;td>必須立即回滾——分裂狀態會讓規則在錯誤前提上運作&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>測試也照這個契約寫：後端拒絕 → 斷言狀態回到原值，reason 直接寫後果——「前台不得顯示後端沒記錄的狀態，否則守衛判錯」。&lt;/p>
&lt;h2 id="3-批次回標與防禦性空操作">3. 批次回標與「防禦性空操作」&lt;/h2>
&lt;p>同一個狀態機還有一個批次入口：某個重建式操作（拆分單據）會把兩邊的處理記錄洗回初始狀態，流程約定「操作前提是全部完成」，所以操作後要把所有記錄批次回標。實測發現後端在該操作後根本不留記錄——回標實際上是空操作。&lt;/p>
&lt;p>要不要刪掉這段程式？保留，並把理由寫進方法說明：它防的是&lt;strong>後端行為改變的那一天&lt;/strong>——若未來記錄以未完成狀態重生，缺了回標會讓結帳流程被鎖死。這是「防禦性空操作」的合理場景：成本是幾行程式與一條「呼叫次數為零」的測試斷言，保的是上游行為漂移時的降級路徑。判準：防禦性程式碼要嘛有測試證明它在防的情境（哪怕目前不會發生），要嘛刪除——「留著以防萬一」但沒人指得出防什麼的程式碼才是負債。&lt;/p>
&lt;h2 id="4-可複用的判準">4. 可複用的判準&lt;/h2>
&lt;ol>
&lt;li>狀態對應不可逆的現實動作 → 模型入口強制單調，同值與回退一律拒絕；終態側分支明確畫出來。&lt;/li>
&lt;li>樂觀更新是否必須回滾，看狀態的下游讀者：有規則消費它 → 分裂狀態不可容忍。&lt;/li>
&lt;li>回滾測試的 reason 寫依賴鏈的後果，不寫「狀態應該是 X」。&lt;/li>
&lt;li>防禦性空操作要附帶「它在防什麼」的說明與測試錨點，否則刪除。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;ul>
&lt;li>不變式落點的理論層 → &lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a>&lt;/li>
&lt;li>變更路徑收斂的理論層 → &lt;a href="https://tarrragon.github.io/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡&lt;/a>&lt;/li>
&lt;li>這組契約的測試寫法 → &lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/test-comment-and-naming-discipline/" data-link-title="測試註解與命名紀律" data-link-desc="測試註解寫什麼、名稱與 reason 怎麼收斂、分析詞彙與開發過程該不該進程式碼 — 判斷測試文字去留的紀律">測試註解與命名紀律&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：POS App 的品項處理進度（未確認 → 已確認 → 已完成）由員工在前端推進、同步到後端。兩個設計決策被測試逼著說清楚：為什麼狀態只能往前？為什麼後端拒絕時一定要回滾樂觀更新？
<strong>疑問來源</strong>：狀態遞增的守則寫在模型方法裡、回滾寫在服務層——兩條規則的「為什麼」散在註解裡，直到為它們補測試時才發現各自對應一個業務不變式。
<strong>整理目的</strong>：把「單調狀態機」與「樂觀更新的回滾契約」整理成判準，並記錄它們與防護鏈的依賴關係。
<strong>本文邊界</strong>：不變式落點的理論層見 <a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>。</p></blockquote>
<hr>
<h2 id="1-單調狀態機不可逆的現實不可逆的模型">1. 單調狀態機：不可逆的現實、不可逆的模型</h2>
<p>品項處理狀態的變更方法只接受「更高」的狀態值——同值與回退一律拒絕（回傳 false、不改狀態）。理由不是技術潔癖，是<strong>狀態對應的現實動作不可逆</strong>：餐點端出去就收不回來。模型層的單調守則把「UI 誤觸」「事件亂序抵達」「重複訊息」全部擋在同一個入口。</p>
<p>終態的側分支另有一格：「已取消」不在遞增序列上，而是從中途狀態岔出的終點——已完成的品項不可取消（現實上已交付）、已取消的不可再推進。整個狀態機小到一張表就能窮舉：</p>
<table>
  <thead>
      <tr>
          <th>從 \ 到</th>
          <th>已確認</th>
          <th>已完成</th>
          <th>已取消</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>未確認</td>
          <td>可</td>
          <td>可</td>
          <td>可</td>
      </tr>
      <tr>
          <td>已確認</td>
          <td>不可（同值）</td>
          <td>可</td>
          <td>可</td>
      </tr>
      <tr>
          <td>已完成</td>
          <td>不可（回退）</td>
          <td>不可</td>
          <td>不可（已交付）</td>
      </tr>
      <tr>
          <td>已取消</td>
          <td>不可</td>
          <td>不可</td>
          <td>不可</td>
      </tr>
  </tbody>
</table>
<p>單調＋終態側分支，是「進度追蹤」類狀態機的常見形狀；把它寫成模型方法的入口守則，比散在各呼叫端的 if 檢查可靠一個量級——這正是<a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a>講的「領域方法作為唯一變更路徑」。</p>
<h2 id="2-樂觀更新的回滾契約">2. 樂觀更新的回滾契約</h2>
<p>推進狀態的流程是樂觀式：先改本地（UI 立即反映）、再同步後端、失敗才回滾。回滾那一步在 code review 裡常被當成「禮貌性的清理」，但這裡它是硬契約，原因在依賴鏈：</p>
<p><strong>這個狀態是其他防護規則的資料來源。</strong>「有品項已完成 → 不可刪除單據」「全部完成 → 才可拆分」——這些守衛讀的就是它。若後端拒絕後前台不回滾，會出現「前台顯示已完成、後端沒有記錄」的分裂狀態：守衛在前台的假象上做防護決策，放行了不該放行的操作，或擋下了不該擋的。</p>
<p>由此得出樂觀更新的回滾判準：<strong>看這個狀態有沒有下游讀者</strong>。</p>
<table>
  <thead>
      <tr>
          <th>狀態的消費者</th>
          <th>回滾的必要性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>只有畫面顯示</td>
          <td>失敗提示＋下次同步自然修正，可接受</td>
      </tr>
      <tr>
          <td>有防護規則、流程分支讀它</td>
          <td>必須立即回滾——分裂狀態會讓規則在錯誤前提上運作</td>
      </tr>
  </tbody>
</table>
<p>測試也照這個契約寫：後端拒絕 → 斷言狀態回到原值，reason 直接寫後果——「前台不得顯示後端沒記錄的狀態，否則守衛判錯」。</p>
<h2 id="3-批次回標與防禦性空操作">3. 批次回標與「防禦性空操作」</h2>
<p>同一個狀態機還有一個批次入口：某個重建式操作（拆分單據）會把兩邊的處理記錄洗回初始狀態，流程約定「操作前提是全部完成」，所以操作後要把所有記錄批次回標。實測發現後端在該操作後根本不留記錄——回標實際上是空操作。</p>
<p>要不要刪掉這段程式？保留，並把理由寫進方法說明：它防的是<strong>後端行為改變的那一天</strong>——若未來記錄以未完成狀態重生，缺了回標會讓結帳流程被鎖死。這是「防禦性空操作」的合理場景：成本是幾行程式與一條「呼叫次數為零」的測試斷言，保的是上游行為漂移時的降級路徑。判準：防禦性程式碼要嘛有測試證明它在防的情境（哪怕目前不會發生），要嘛刪除——「留著以防萬一」但沒人指得出防什麼的程式碼才是負債。</p>
<h2 id="4-可複用的判準">4. 可複用的判準</h2>
<ol>
<li>狀態對應不可逆的現實動作 → 模型入口強制單調，同值與回退一律拒絕；終態側分支明確畫出來。</li>
<li>樂觀更新是否必須回滾，看狀態的下游讀者：有規則消費它 → 分裂狀態不可容忍。</li>
<li>回滾測試的 reason 寫依賴鏈的後果，不寫「狀態應該是 X」。</li>
<li>防禦性空操作要附帶「它在防什麼」的說明與測試錨點，否則刪除。</li>
</ol>
<h2 id="下一步">下一步</h2>
<ul>
<li>不變式落點的理論層 → <a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a></li>
<li>變更路徑收斂的理論層 → <a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a></li>
<li>這組契約的測試寫法 → <a href="/blog/testing/05-test-design-judgment/test-comment-and-naming-discipline/" data-link-title="測試註解與命名紀律" data-link-desc="測試註解寫什麼、名稱與 reason 怎麼收斂、分析詞彙與開發過程該不該進程式碼 — 判斷測試文字去留的紀律">測試註解與命名紀律</a></li>
</ul>
]]></content:encoded></item></channel></rss>