<?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>JavaScript on Tarragon</title><link>https://tarrragon.github.io/blog/tags/javascript/</link><description>Recent content in JavaScript on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 19 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/javascript/index.xml" rel="self" type="application/rss+xml"/><item><title>JS/TS 平台適配</title><link>https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/js-ts-platform/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/js-ts-platform/</guid><description>&lt;p>瀏覽器環境中的監控 SDK 面臨三個平台特有的限制：跨域請求被 CORS 攔截、Service Worker 可以攔截和修改請求、SPA 的路由變換不觸發頁面載入事件。每個限制需要 SDK 在設計層面做適配。&lt;/p>
&lt;h2 id="cors-限制">CORS 限制&lt;/h2>
&lt;p>瀏覽器的同源政策限制網頁向不同 origin 發送請求。SDK 的 HTTP POST 送到 collector endpoint 時，如果 collector 和網頁不在同一個 origin（protocol + domain + port 都相同），瀏覽器會先發送 preflight OPTIONS 請求確認 server 允許跨域存取。&lt;/p>
&lt;p>SDK 端的適配：&lt;/p>
&lt;p>使用 &lt;code>navigator.sendBeacon(url, data)&lt;/code> 代替 &lt;code>fetch&lt;/code> / &lt;code>XMLHttpRequest&lt;/code>。sendBeacon 不受 CORS 限制（瀏覽器對 beacon 請求不做 preflight），且在頁面 unload 時仍能可靠送出 — 適合 close flush 場景。&lt;/p>
&lt;p>sendBeacon 的限制：payload 大小有上限（通常 64KB），不能自訂 Content-Type header（固定為 &lt;code>text/plain&lt;/code> 或 &lt;code>application/x-www-form-urlencoded&lt;/code>），沒有回應 — 送出後無法知道 server 是否收到。&lt;/p>
&lt;p>如果需要 fetch（例如需要讀取回應或送出大 payload），collector 端需要設定 CORS header：&lt;code>Access-Control-Allow-Origin&lt;/code>、&lt;code>Access-Control-Allow-Methods: POST&lt;/code>、&lt;code>Access-Control-Allow-Headers: Content-Type&lt;/code>。&lt;/p>
&lt;h2 id="service-worker-攔截">Service Worker 攔截&lt;/h2>
&lt;p>Service Worker 可以攔截頁面發出的所有 HTTP 請求（包括 SDK 的 POST 請求到 collector）。如果應用程式的 Service Worker 有 cache 策略（cache-first、network-first），SDK 的監控請求可能被快取而非送到 collector。&lt;/p>
&lt;p>SDK 端的適配：&lt;/p>
&lt;p>在 fetch 請求中加 &lt;code>cache: 'no-store'&lt;/code> 防止 Service Worker 快取監控請求。或在請求 URL 加唯一的 query parameter（&lt;code>?_t=timestamp&lt;/code>）讓每次請求的 URL 都不同，繞過 cache 比對。&lt;/p>
&lt;p>如果 SDK 本身提供 Service Worker 模組（在 Service Worker 內攔截 error），需要注意 Service Worker 的生命週期和頁面不同 — Service Worker 可能在頁面關閉後仍在執行，也可能在空閒時被瀏覽器終止。&lt;/p>
&lt;h2 id="spa-路由變換偵測">SPA 路由變換偵測&lt;/h2>
&lt;p>Single Page Application 的路由變換（React Router、Vue Router、Angular Router）不觸發頁面重新載入。從監控角度看，使用者在不同「頁面」之間切換，但 &lt;code>window.onload&lt;/code> 只在首次載入時觸發一次。&lt;/p>
&lt;p>SDK 需要偵測 SPA 路由變換來記錄 &lt;code>lifecycle.view.change&lt;/code> 事件。偵測方式：&lt;/p>
&lt;p>&lt;code>History API&lt;/code> 攔截：monkey-patch &lt;code>history.pushState&lt;/code> 和 &lt;code>history.replaceState&lt;/code>，在呼叫前後記錄路由變換。同時監聽 &lt;code>popstate&lt;/code> 事件處理瀏覽器的上一頁/下一頁。&lt;/p>
&lt;p>&lt;code>MutationObserver&lt;/code>：監聽 DOM 變化偵測頁面內容更新。但 MutationObserver 觸發頻率高，需要 debounce 並搭配 URL 變化檢查，避免把 DOM 微調誤判為路由變換。&lt;/p>
&lt;p>框架特定的 hook：如果 SDK 提供框架整合套件（React / Vue / Angular plugin），可以用框架的 router 事件（&lt;code>useNavigate&lt;/code> hook、&lt;code>router.afterEach&lt;/code> guard）直接取得路由變換資訊，比 monkey-patch History API 更可靠。&lt;/p>
&lt;p>JS/TS 的平台限制理解後，其他平台各有各的挑戰 — &lt;a href="https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/flutter-platform/" data-link-title="Flutter 平台適配" data-link-desc="Isolate 安全、Platform channel 攔截、app lifecycle 事件 — Flutter SDK 的平台特殊考量">Flutter 平台適配&lt;/a>處理 isolate 和 platform channel 的問題。所有平台共同面對的 &lt;a href="https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/cross-platform-timestamp/" data-link-title="跨平台 timestamp 一致性" data-link-desc="時區、精度、clock drift — 不同平台產生的 timestamp 在 collector 端需要能正確比對和排序">timestamp 一致性&lt;/a>問題（時區、精度、clock drift）在獨立章節中展開。SDK 的跨平台公開 API 設計見&lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/public-api/" data-link-title="SDK 公開 API 設計" data-link-desc="init / event / error / metric / flush / close 六個方法構成 SDK 的完整生命週期 — 跨平台共用相同 API 介面">模組三 SDK 公開 API&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>瀏覽器環境中的監控 SDK 面臨三個平台特有的限制：跨域請求被 CORS 攔截、Service Worker 可以攔截和修改請求、SPA 的路由變換不觸發頁面載入事件。每個限制需要 SDK 在設計層面做適配。</p>
<h2 id="cors-限制">CORS 限制</h2>
<p>瀏覽器的同源政策限制網頁向不同 origin 發送請求。SDK 的 HTTP POST 送到 collector endpoint 時，如果 collector 和網頁不在同一個 origin（protocol + domain + port 都相同），瀏覽器會先發送 preflight OPTIONS 請求確認 server 允許跨域存取。</p>
<p>SDK 端的適配：</p>
<p>使用 <code>navigator.sendBeacon(url, data)</code> 代替 <code>fetch</code> / <code>XMLHttpRequest</code>。sendBeacon 不受 CORS 限制（瀏覽器對 beacon 請求不做 preflight），且在頁面 unload 時仍能可靠送出 — 適合 close flush 場景。</p>
<p>sendBeacon 的限制：payload 大小有上限（通常 64KB），不能自訂 Content-Type header（固定為 <code>text/plain</code> 或 <code>application/x-www-form-urlencoded</code>），沒有回應 — 送出後無法知道 server 是否收到。</p>
<p>如果需要 fetch（例如需要讀取回應或送出大 payload），collector 端需要設定 CORS header：<code>Access-Control-Allow-Origin</code>、<code>Access-Control-Allow-Methods: POST</code>、<code>Access-Control-Allow-Headers: Content-Type</code>。</p>
<h2 id="service-worker-攔截">Service Worker 攔截</h2>
<p>Service Worker 可以攔截頁面發出的所有 HTTP 請求（包括 SDK 的 POST 請求到 collector）。如果應用程式的 Service Worker 有 cache 策略（cache-first、network-first），SDK 的監控請求可能被快取而非送到 collector。</p>
<p>SDK 端的適配：</p>
<p>在 fetch 請求中加 <code>cache: 'no-store'</code> 防止 Service Worker 快取監控請求。或在請求 URL 加唯一的 query parameter（<code>?_t=timestamp</code>）讓每次請求的 URL 都不同，繞過 cache 比對。</p>
<p>如果 SDK 本身提供 Service Worker 模組（在 Service Worker 內攔截 error），需要注意 Service Worker 的生命週期和頁面不同 — Service Worker 可能在頁面關閉後仍在執行，也可能在空閒時被瀏覽器終止。</p>
<h2 id="spa-路由變換偵測">SPA 路由變換偵測</h2>
<p>Single Page Application 的路由變換（React Router、Vue Router、Angular Router）不觸發頁面重新載入。從監控角度看，使用者在不同「頁面」之間切換，但 <code>window.onload</code> 只在首次載入時觸發一次。</p>
<p>SDK 需要偵測 SPA 路由變換來記錄 <code>lifecycle.view.change</code> 事件。偵測方式：</p>
<p><code>History API</code> 攔截：monkey-patch <code>history.pushState</code> 和 <code>history.replaceState</code>，在呼叫前後記錄路由變換。同時監聽 <code>popstate</code> 事件處理瀏覽器的上一頁/下一頁。</p>
<p><code>MutationObserver</code>：監聽 DOM 變化偵測頁面內容更新。但 MutationObserver 觸發頻率高，需要 debounce 並搭配 URL 變化檢查，避免把 DOM 微調誤判為路由變換。</p>
<p>框架特定的 hook：如果 SDK 提供框架整合套件（React / Vue / Angular plugin），可以用框架的 router 事件（<code>useNavigate</code> hook、<code>router.afterEach</code> guard）直接取得路由變換資訊，比 monkey-patch History API 更可靠。</p>
<p>JS/TS 的平台限制理解後，其他平台各有各的挑戰 — <a href="/blog/monitoring/05-platform-adaptation/flutter-platform/" data-link-title="Flutter 平台適配" data-link-desc="Isolate 安全、Platform channel 攔截、app lifecycle 事件 — Flutter SDK 的平台特殊考量">Flutter 平台適配</a>處理 isolate 和 platform channel 的問題。所有平台共同面對的 <a href="/blog/monitoring/05-platform-adaptation/cross-platform-timestamp/" data-link-title="跨平台 timestamp 一致性" data-link-desc="時區、精度、clock drift — 不同平台產生的 timestamp 在 collector 端需要能正確比對和排序">timestamp 一致性</a>問題（時區、精度、clock drift）在獨立章節中展開。SDK 的跨平台公開 API 設計見<a href="/blog/monitoring/03-sdk-design/public-api/" data-link-title="SDK 公開 API 設計" data-link-desc="init / event / error / metric / flush / close 六個方法構成 SDK 的完整生命週期 — 跨平台共用相同 API 介面">模組三 SDK 公開 API</a>。</p>
]]></content:encoded></item><item><title>客製 UI 留 framework 邊界外、用 CSS 控制視覺位置</title><link>https://tarrragon.github.io/blog/report/coexisting-with-framework-managed-dom/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/coexisting-with-framework-managed-dom/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>客製 UI 留在 framework 管轄的 DOM 邊界外、用 CSS（absolute、margin spacer、grid）達成想要的視覺位置。&lt;/strong> 注入 framework 子樹的客製元素會被 reconciliation 清掉、跟渲染週期競爭、行為不可預測。邊界外的客製跟 framework 解耦、命運由我們自己決定。&lt;/p>
&lt;blockquote>
&lt;p>本篇焦點：客製 UI 該放哪。&lt;strong>framework 元件本身需要動（搬節點、改順序、改 attribute）的安全規則&lt;/strong>由 &lt;a href="../component-boundary-and-js-impact/">#13 JS 操作 framework 元件：邊界辨識與安全規則&lt;/a> 處理。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="為什麼-framework-管轄區會清外來節點">為什麼 framework 管轄區會清外來節點&lt;/h2>
&lt;h3 id="reconciliation-機制">Reconciliation 機制&lt;/h3>
&lt;p>Svelte / React 等框架透過「component tree → DOM tree」的 reconciliation 機制保持 UI 與 state 同步。框架在 patch 時：&lt;/p>
&lt;ol>
&lt;li>比對 component tree 的當前狀態與目標狀態&lt;/li>
&lt;li>計算 DOM 需要的最小變動&lt;/li>
&lt;li>套用變動到實際 DOM&lt;/li>
&lt;/ol>
&lt;p>關鍵是步驟 2：&lt;strong>框架只認得自己 create 的節點&lt;/strong>。外來節點（我們手動 appendChild 進去的）不在它的 component tree 裡、被視為「該節點不該存在」、清掉。&lt;/p>
&lt;p>這不是 bug、是 reconciliation 的正常行為 — 框架要保證 DOM 跟 component state 一致、外來節點屬於不一致的部分。&lt;/p>
&lt;h3 id="外來節點的命運是不可預測的">外來節點的命運是不可預測的&lt;/h3>
&lt;p>不同框架 / 不同 reconciliation 策略對外來節點的處理：&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>Svelte&lt;/td>
 &lt;td>多數情境清掉、視 patch 點而定&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>React&lt;/td>
 &lt;td>通常清掉（Virtual DOM diff 時）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Vue&lt;/td>
 &lt;td>通常清掉、但 v-pre 包裹可保留&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Web Components&lt;/td>
 &lt;td>由 component 內部邏輯決定&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>「不可預測」本身就是問題&lt;/strong> — 即使某次測試沒清、下次升級或 patch 時可能清。設計時不該依賴未明確保證的行為。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的具體情境">這次任務的具體情境&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>要把搜尋範圍切換 UI（scope radio group）放在「pagefind 搜尋輸入框與結果之間」 — 視覺上希望它就在 form 與 drawer 中間。&lt;/p>
&lt;p>第一次嘗試：JS 把 scope element 用 &lt;code>form.insertAdjacentElement('afterend', scopeEl)&lt;/code> 注入 &lt;code>.pagefind-ui&lt;/code> 內部。&lt;/p>
&lt;p>結果：使用者打字後 scope 消失。&lt;/p>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>Pagefind 用 svelte 構建 UI、reactivity 監聽 search query 變動。Query 改變時 svelte 會 patch &lt;code>.pagefind-ui&lt;/code> 的子樹 — 我們注入的 scope 不是 svelte 認得的節點、被視為差異清掉。&lt;/p>
&lt;h3 id="執行邊界外--css-控制位置">執行：邊界外 + CSS 控制位置&lt;/h3>
&lt;p>策略改為「scope 留在 &lt;code>.search-shell&lt;/code> 裡（framework 邊界外）、用 CSS absolute 浮在 form 上」：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;search-shell&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">h1&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>...&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">h1&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;search-scope&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>...&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span> &lt;span class="c">&amp;lt;!-- 邊界外、永不被清 --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;search&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>...&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span> &lt;span class="c">&amp;lt;!-- pagefind 進來這裡 --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-shell&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">position&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">relative&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-scope&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">position&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">absolute&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">top&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">calc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">h&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">form&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">h&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="c">/* ... */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-shell&lt;/span> &lt;span class="p">.&lt;/span>&lt;span class="nc">pagefind-ui__drawer&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> &lt;span class="k">margin-top&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">calc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">h&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">8&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c">/* 為 scope 讓位 */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>scope 不在 svelte 管轄區、永遠不被清；視覺位置靠 absolute + drawer 的 margin-top 共同決定。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>客製 UI 留在 framework 管轄的 DOM 邊界外、用 CSS（absolute、margin spacer、grid）達成想要的視覺位置。</strong> 注入 framework 子樹的客製元素會被 reconciliation 清掉、跟渲染週期競爭、行為不可預測。邊界外的客製跟 framework 解耦、命運由我們自己決定。</p>
<blockquote>
<p>本篇焦點：客製 UI 該放哪。<strong>framework 元件本身需要動（搬節點、改順序、改 attribute）的安全規則</strong>由 <a href="../component-boundary-and-js-impact/">#13 JS 操作 framework 元件：邊界辨識與安全規則</a> 處理。</p></blockquote>
<hr>
<h2 id="為什麼-framework-管轄區會清外來節點">為什麼 framework 管轄區會清外來節點</h2>
<h3 id="reconciliation-機制">Reconciliation 機制</h3>
<p>Svelte / React 等框架透過「component tree → DOM tree」的 reconciliation 機制保持 UI 與 state 同步。框架在 patch 時：</p>
<ol>
<li>比對 component tree 的當前狀態與目標狀態</li>
<li>計算 DOM 需要的最小變動</li>
<li>套用變動到實際 DOM</li>
</ol>
<p>關鍵是步驟 2：<strong>框架只認得自己 create 的節點</strong>。外來節點（我們手動 appendChild 進去的）不在它的 component tree 裡、被視為「該節點不該存在」、清掉。</p>
<p>這不是 bug、是 reconciliation 的正常行為 — 框架要保證 DOM 跟 component state 一致、外來節點屬於不一致的部分。</p>
<h3 id="外來節點的命運是不可預測的">外來節點的命運是不可預測的</h3>
<p>不同框架 / 不同 reconciliation 策略對外來節點的處理：</p>
<table>
  <thead>
      <tr>
          <th>框架</th>
          <th>外來節點命運</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Svelte</td>
          <td>多數情境清掉、視 patch 點而定</td>
      </tr>
      <tr>
          <td>React</td>
          <td>通常清掉（Virtual DOM diff 時）</td>
      </tr>
      <tr>
          <td>Vue</td>
          <td>通常清掉、但 v-pre 包裹可保留</td>
      </tr>
      <tr>
          <td>Web Components</td>
          <td>由 component 內部邏輯決定</td>
      </tr>
  </tbody>
</table>
<p><strong>「不可預測」本身就是問題</strong> — 即使某次測試沒清、下次升級或 patch 時可能清。設計時不該依賴未明確保證的行為。</p>
<hr>
<h2 id="這次任務的具體情境">這次任務的具體情境</h2>
<h3 id="觀察">觀察</h3>
<p>要把搜尋範圍切換 UI（scope radio group）放在「pagefind 搜尋輸入框與結果之間」 — 視覺上希望它就在 form 與 drawer 中間。</p>
<p>第一次嘗試：JS 把 scope element 用 <code>form.insertAdjacentElement('afterend', scopeEl)</code> 注入 <code>.pagefind-ui</code> 內部。</p>
<p>結果：使用者打字後 scope 消失。</p>
<h3 id="判讀">判讀</h3>
<p>Pagefind 用 svelte 構建 UI、reactivity 監聽 search query 變動。Query 改變時 svelte 會 patch <code>.pagefind-ui</code> 的子樹 — 我們注入的 scope 不是 svelte 認得的節點、被視為差異清掉。</p>
<h3 id="執行邊界外--css-控制位置">執行：邊界外 + CSS 控制位置</h3>
<p>策略改為「scope 留在 <code>.search-shell</code> 裡（framework 邊界外）、用 CSS absolute 浮在 form 上」：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;search-shell&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">&lt;</span><span class="nt">h1</span><span class="p">&gt;</span>...<span class="p">&lt;/</span><span class="nt">h1</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;search-scope&#34;</span><span class="p">&gt;</span>...<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>      <span class="c">&lt;!-- 邊界外、永不被清 --&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;search&#34;</span><span class="p">&gt;</span>...<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>               <span class="c">&lt;!-- pagefind 進來這裡 --&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span> <span class="p">{</span> <span class="k">position</span><span class="p">:</span> <span class="kc">relative</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">search-scope</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">position</span><span class="p">:</span> <span class="kc">absolute</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">top</span><span class="p">:</span> <span class="nb">calc</span><span class="p">(</span><span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">search</span><span class="o">-</span><span class="n">title</span><span class="o">-</span><span class="n">h</span><span class="p">)</span> <span class="o">+</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">search</span><span class="o">-</span><span class="n">form</span><span class="o">-</span><span class="n">h</span><span class="p">)</span> <span class="o">+</span> <span class="mi">4</span><span class="kt">px</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="c">/* ... */</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span> <span class="p">.</span><span class="nc">pagefind-ui__drawer</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="k">margin-top</span><span class="p">:</span> <span class="nb">calc</span><span class="p">(</span><span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">search</span><span class="o">-</span><span class="n">scope</span><span class="o">-</span><span class="n">h</span><span class="p">)</span> <span class="o">+</span> <span class="mi">8</span><span class="kt">px</span><span class="p">);</span>  <span class="c">/* 為 scope 讓位 */</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>scope 不在 svelte 管轄區、永遠不被清；視覺位置靠 absolute + drawer 的 margin-top 共同決定。</p>
<hr>
<h2 id="css-達成視覺位置的設計工具">CSS 達成視覺位置的設計工具</h2>
<h3 id="工具-1absolute--容器-relative">工具 1：Absolute + 容器 relative</h3>
<p>把客製 UI 設 <code>position: absolute</code>、容器設 <code>position: relative</code> 當定位基準。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span> <span class="p">{</span> <span class="k">position</span><span class="p">:</span> <span class="kc">relative</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">search-custom</span> <span class="p">{</span> <span class="k">position</span><span class="p">:</span> <span class="kc">absolute</span><span class="p">;</span> <span class="k">top</span><span class="p">:</span> <span class="o">...</span><span class="p">;</span> <span class="k">left</span><span class="p">:</span> <span class="o">...</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>客製 UI 跟 framework 元素脫離 layout flow、各自獨立。</p>
<h3 id="工具-2margin-spacer-推開-framework-元素">工具 2：Margin spacer 推開 framework 元素</h3>
<p>要在 framework 元素之間插入空間放客製 UI、改 framework 元素的 margin / padding 推出空間：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">framework-element</span> <span class="p">{</span> <span class="k">margin-top</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">custom</span><span class="o">-</span><span class="n">height</span><span class="p">);</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">custom-ui</span> <span class="p">{</span> <span class="k">position</span><span class="p">:</span> <span class="kc">absolute</span><span class="p">;</span> <span class="k">top</span><span class="p">:</span> <span class="mi">0</span><span class="p">;</span> <span class="k">height</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">custom</span><span class="o">-</span><span class="n">height</span><span class="p">);</span> <span class="p">}</span></span></span></code></pre></div><p>framework 元素留出空間、客製 UI 浮在空間上。</p>
<h3 id="工具-3grid-容器讓-framework-元件當-grid-item">工具 3：Grid 容器讓 framework 元件當 grid item</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">display</span><span class="p">:</span> <span class="k">grid</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">grid-template-rows</span><span class="p">:</span> <span class="kc">auto</span> <span class="kc">auto</span> <span class="mi">1</span><span class="n">fr</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span> <span class="o">&gt;</span> <span class="p">.</span><span class="nc">search-scope</span> <span class="p">{</span> <span class="k">grid-row</span><span class="p">:</span> <span class="mi">2</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span> <span class="o">&gt;</span> <span class="p">#</span><span class="nn">search</span> <span class="p">{</span> <span class="k">grid-row</span><span class="p">:</span> <span class="mi">3</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>把 framework 元件當 grid 的一個 item — grid 控制 layout、framework 不知道有 grid 在外層、繼續管它的子樹。</p>
<h3 id="工具-4用-css-variables-共享尺寸">工具 4：用 CSS variables 共享尺寸</h3>
<p>framework 元素的尺寸需要參考客製 UI 時、用 CSS variable 傳遞：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">:</span><span class="nd">root</span> <span class="p">{</span> <span class="nv">--custom-height</span><span class="p">:</span> <span class="mi">60</span><span class="kt">px</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">framework-element</span> <span class="p">{</span> <span class="k">margin-top</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">custom</span><span class="o">-</span><span class="n">height</span><span class="p">);</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">.</span><span class="nc">custom-ui</span> <span class="p">{</span> <span class="k">height</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">custom</span><span class="o">-</span><span class="n">height</span><span class="p">);</span> <span class="p">}</span></span></span></code></pre></div><p>或用 ResizeObserver 量測寫回 variable（<a href="../runtime-measurement-unification/">#27 runtime 量測模式統一</a>）。</p>
<hr>
<h2 id="設計取捨客製-ui-的位置選擇">設計取捨：客製 UI 的位置選擇</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（邊界外 + CSS）當預設、其他做法在特定情境合理。</p>
<h3 id="aframework-邊界外--css-視覺定位這個專案的預設">A：framework 邊界外 + CSS 視覺定位（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：客製 UI 放在 framework 元件的 sibling 位置、用 CSS absolute / grid / margin spacer 達成視覺位置</li>
<li><strong>選 A 的理由</strong>：跟 framework reconciliation 完全解耦、命運由自己決定、升級不影響</li>
<li><strong>適合</strong>：絕大多數需要在 framework UI 旁 / 上 / 下加客製內容的情境</li>
<li><strong>代價</strong>：CSS 定位邏輯比 DOM 巢狀複雜、需要正確處理 stacking context / z-index</li>
</ul>
<h3 id="bframework-邊界外--js-量測位置">B：framework 邊界外 + JS 量測位置</h3>
<ul>
<li><strong>機制</strong>：用 ResizeObserver 量 framework 元素的 bounding rect、JS 算出客製 UI 該擺哪</li>
<li><strong>跟 A 的取捨</strong>：A 用 CSS 表達靜態關係、B 處理 runtime 才知道的尺寸；B 多一層 JS、但能達成 CSS 表達不出的精確定位</li>
<li><strong>B 比 A 好的情境</strong>：客製 UI 位置依賴 framework 元件的 runtime 尺寸（內容換行、字型變化）</li>
</ul>
<h3 id="cframework-邊界內注入">C：framework 邊界內注入</h3>
<ul>
<li><strong>機制</strong>：JS 把客製 element 直接 appendChild 到 framework 子樹內</li>
<li><strong>跟 A 的取捨</strong>：C 看似省事（少一層 wrapper）、實際把客製命運綁在 framework reconciliation 上</li>
<li><strong>C 才合理的情境</strong>：該 framework 子樹確認「不會被 reconcile」（極罕見、需要讀框架 source 確認）</li>
<li><strong>代價</strong>：客製可能在任何 patch 時消失、需要 MutationObserver 補打、跟渲染週期賽跑</li>
</ul>
<h3 id="dfork-framework-source">D：Fork framework source</h3>
<ul>
<li><strong>機制</strong>：fork 整個 framework、改 reconciliation 行為讓它認得我們的客製</li>
<li><strong>成本特別高的原因</strong>：每次升級都要重新 merge、客製永久綁在 fork 版本</li>
<li><strong>D 才合理的情境</strong>：framework 已停止維護、且客製需求超過所有其他選項</li>
</ul>
<hr>
<h2 id="不該套用邊界外的情境">不該套用「邊界外」的情境</h2>
<p>A 是預設、但不是萬靈丹：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼不適合 A</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>客製內容必須在 framework 元件的內部視覺脈絡內（共享 inline flow）</td>
          <td>Absolute 跳出 flow、達不到 inline 的視覺效果</td>
      </tr>
      <tr>
          <td>Framework 元件本身就是要客製化（改 row、改 cell）</td>
          <td>動的是 framework 本身、不是「在旁邊加東西」</td>
      </tr>
      <tr>
          <td>Framework 提供了官方擴展介面（slot、render prop）</td>
          <td>用官方介面更穩、不需要邊界外 hack</td>
      </tr>
      <tr>
          <td>客製需要訪問 framework 的內部 state</td>
          <td>邊界外的客製跟內部 state 隔離、訪問成本高</td>
      </tr>
  </tbody>
</table>
<p><strong>核心判準</strong>：客製是「在 framework 旁邊加東西」還是「改 framework 本身」？前者用本策略、後者另想辦法。</p>
<hr>
<h2 id="跟其他原則的關係">跟其他原則的關係</h2>
<table>
  <thead>
      <tr>
          <th>抽象層原則</th>
          <th>關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../external-component-collaboration-layers/">#45 跟外部組件合作的層次</a></td>
          <td>「邊界外 + CSS」是「不要挖 framework 內部」的具體應用 — 客製貼著外部介面（DOM sibling）做、不挖內部</td>
      </tr>
      <tr>
          <td><a href="../two-occurrence-threshold/">#42 2 次門檻</a></td>
          <td>第 1 次注入失敗（被清掉）= 第 2 次該換策略到邊界外、不該繼續嘗試「換種方式注入」</td>
      </tr>
      <tr>
          <td><a href="../component-boundary-and-js-impact/">#13 JS 操作 framework 元件：邊界辨識與安全規則</a></td>
          <td>互補關係 — 本篇處理「客製 UI 該放哪」、#13 處理「framework 元件本身要動時怎麼動」</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該怎麼處理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>注入 framework DOM 的元素在使用者互動後消失</td>
          <td>把該元素搬出 framework 邊界、用 CSS 控制視覺位置</td>
      </tr>
      <tr>
          <td>客製 UI 在 framework 更新後 attribute 被 revert</td>
          <td>客製 UI 不該在 framework 內、wrapper 在外、attribute 套 wrapper</td>
      </tr>
      <tr>
          <td>看不出哪些 DOM 是 framework 管的</td>
          <td>讀 framework 的 mount root、從那裡往內都是管轄區</td>
      </tr>
      <tr>
          <td>Stacking context 衝突、z-index 失靈</td>
          <td>確認 absolute 的 containing block 是預期的 relative parent</td>
      </tr>
      <tr>
          <td>Framework 元件位置不固定、客製 UI 對不齊</td>
          <td>用 ResizeObserver 量 framework 元素、寫回 CSS variable</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：客製 UI 的存活壽命 = 「離 framework 管轄區多遠」。最遠 = 永遠不被清；注入內部 = 隨時可能消失。預設選邊界外、不要為了「省一層 wrapper」進入 framework 領地。</p>
]]></content:encoded></item><item><title>排版精度的工具選擇：CSS-only vs JS-assisted</title><link>https://tarrragon.github.io/blog/report/css-only-vs-js-assisted/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/css-only-vs-js-assisted/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>CSS 處理 build-time 可決定的 layout、JS 處理 runtime 才知道的尺寸與 stateful DOM 移動。&lt;/strong> 邊界誤判：硬要 CSS 解決 runtime 問題會反覆試錯；硬要 JS 解決 layout 問題會跟 framework 渲染競爭。&lt;/p>
&lt;p>選擇問題簡化為兩問：&lt;/p>
&lt;ol>
&lt;li>這個值在 build time 能定下來嗎？能 → CSS；不能 → JS 量測寫回 CSS 變數。&lt;/li>
&lt;li>這個 DOM 變動是 framework 管的嗎？是 → 不要動；不是 → JS 可動。&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="為什麼分工是必要的">為什麼分工是必要的&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>CSS 的設計假設是「規則在 build time 決定、瀏覽器渲染時應用」。CSS 沒有 reactive 機制 — 沒辦法「等元素渲染完才知道高度然後對齊」。&lt;/p>
&lt;p>JS 的設計假設是「runtime 可以讀寫 DOM 與 style」。JS 可以在元件渲染後量測尺寸、可以隨 viewport 變動 reparent 節點。&lt;/p>
&lt;p>&lt;strong>用錯工具不只「不太優雅」、是直接做不到&lt;/strong>。要 CSS 解決動態尺寸只能寫 magic number（猜的）；要 JS 解決靜態 layout 寫了一堆 imperative 代碼還可能跟 framework 衝突。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的工具分配">這次任務的工具分配&lt;/h2>
&lt;h3 id="css-處理的部分">CSS 處理的部分&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>任務&lt;/th>
 &lt;th>CSS 寫法&lt;/th>
 &lt;th>為什麼用 CSS&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>H1 / search input 的固定高度&lt;/td>
 &lt;td>&lt;code>height: 64px&lt;/code> 寫死&lt;/td>
 &lt;td>Build time 可決定的設計 token&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>搜尋頁主欄置中、breakpoint 切換&lt;/td>
 &lt;td>&lt;code>@media (min-width: 1400px)&lt;/code>&lt;/td>
 &lt;td>純宣告式 layout&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Filter sidebar absolute 定位&lt;/td>
 &lt;td>&lt;code>position: absolute; right: calc(100% + 2rem)&lt;/code>&lt;/td>
 &lt;td>靜態定位關係&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Drawer 留出 scope 空間&lt;/td>
 &lt;td>&lt;code>margin-top: calc(var(--search-scope-h) + 8px)&lt;/code>&lt;/td>
 &lt;td>引用變數的 calc&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="js-處理的部分">JS 處理的部分&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>任務&lt;/th>
 &lt;th>JS 寫法&lt;/th>
 &lt;th>為什麼用 JS&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>量測 scope 高度寫回 CSS 變數&lt;/td>
 &lt;td>ResizeObserver&lt;/td>
 &lt;td>Runtime 才知道（字型、換行）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Filter sidebar 切換到 mobile drawer&lt;/td>
 &lt;td>matchMedia + appendChild&lt;/td>
 &lt;td>跨 viewport 的 stateful DOM 移動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Scope filter（regex 比對標題 / 內文）&lt;/td>
 &lt;td>event listener + setProperty&lt;/td>
 &lt;td>純 runtime 邏輯、無 build time 解&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Scope UI 寫死值與量測值的橋&lt;/td>
 &lt;td>&lt;code>style.setProperty('--search-scope-h', ...)&lt;/code>&lt;/td>
 &lt;td>JS 寫回讓 CSS 用&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="兩問判斷法">兩問判斷法&lt;/h2>
&lt;h3 id="問-1這個值在-build-time-能定下來嗎">問 1：這個值在 build time 能定下來嗎&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>值&lt;/th>
 &lt;th>Build time 知道嗎&lt;/th>
 &lt;th>工具&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>設計 token（spacing、typography scale）&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>CSS 變數寫死&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元件固定尺寸（icon size、button height）&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>CSS height / width&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>響應式 breakpoint&lt;/td>
 &lt;td>是（設計決定）&lt;/td>
 &lt;td>&lt;code>@media&lt;/code> query&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>動態文字塊高度（受字型 / 換行）&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>JS ResizeObserver&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元件位置（隨 viewport 變化）&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>JS getBoundingClientRect&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>知道 → CSS 解。不知道 → JS 量測寫回 CSS 變數、CSS 從變數計算。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>CSS 處理 build-time 可決定的 layout、JS 處理 runtime 才知道的尺寸與 stateful DOM 移動。</strong> 邊界誤判：硬要 CSS 解決 runtime 問題會反覆試錯；硬要 JS 解決 layout 問題會跟 framework 渲染競爭。</p>
<p>選擇問題簡化為兩問：</p>
<ol>
<li>這個值在 build time 能定下來嗎？能 → CSS；不能 → JS 量測寫回 CSS 變數。</li>
<li>這個 DOM 變動是 framework 管的嗎？是 → 不要動；不是 → JS 可動。</li>
</ol>
<hr>
<h2 id="為什麼分工是必要的">為什麼分工是必要的</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>CSS 的設計假設是「規則在 build time 決定、瀏覽器渲染時應用」。CSS 沒有 reactive 機制 — 沒辦法「等元素渲染完才知道高度然後對齊」。</p>
<p>JS 的設計假設是「runtime 可以讀寫 DOM 與 style」。JS 可以在元件渲染後量測尺寸、可以隨 viewport 變動 reparent 節點。</p>
<p><strong>用錯工具不只「不太優雅」、是直接做不到</strong>。要 CSS 解決動態尺寸只能寫 magic number（猜的）；要 JS 解決靜態 layout 寫了一堆 imperative 代碼還可能跟 framework 衝突。</p>
<hr>
<h2 id="這次任務的工具分配">這次任務的工具分配</h2>
<h3 id="css-處理的部分">CSS 處理的部分</h3>
<table>
  <thead>
      <tr>
          <th>任務</th>
          <th>CSS 寫法</th>
          <th>為什麼用 CSS</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>H1 / search input 的固定高度</td>
          <td><code>height: 64px</code> 寫死</td>
          <td>Build time 可決定的設計 token</td>
      </tr>
      <tr>
          <td>搜尋頁主欄置中、breakpoint 切換</td>
          <td><code>@media (min-width: 1400px)</code></td>
          <td>純宣告式 layout</td>
      </tr>
      <tr>
          <td>Filter sidebar absolute 定位</td>
          <td><code>position: absolute; right: calc(100% + 2rem)</code></td>
          <td>靜態定位關係</td>
      </tr>
      <tr>
          <td>Drawer 留出 scope 空間</td>
          <td><code>margin-top: calc(var(--search-scope-h) + 8px)</code></td>
          <td>引用變數的 calc</td>
      </tr>
  </tbody>
</table>
<h3 id="js-處理的部分">JS 處理的部分</h3>
<table>
  <thead>
      <tr>
          <th>任務</th>
          <th>JS 寫法</th>
          <th>為什麼用 JS</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>量測 scope 高度寫回 CSS 變數</td>
          <td>ResizeObserver</td>
          <td>Runtime 才知道（字型、換行）</td>
      </tr>
      <tr>
          <td>Filter sidebar 切換到 mobile drawer</td>
          <td>matchMedia + appendChild</td>
          <td>跨 viewport 的 stateful DOM 移動</td>
      </tr>
      <tr>
          <td>Scope filter（regex 比對標題 / 內文）</td>
          <td>event listener + setProperty</td>
          <td>純 runtime 邏輯、無 build time 解</td>
      </tr>
      <tr>
          <td>Scope UI 寫死值與量測值的橋</td>
          <td><code>style.setProperty('--search-scope-h', ...)</code></td>
          <td>JS 寫回讓 CSS 用</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="兩問判斷法">兩問判斷法</h2>
<h3 id="問-1這個值在-build-time-能定下來嗎">問 1：這個值在 build time 能定下來嗎</h3>
<table>
  <thead>
      <tr>
          <th>值</th>
          <th>Build time 知道嗎</th>
          <th>工具</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>設計 token（spacing、typography scale）</td>
          <td>是</td>
          <td>CSS 變數寫死</td>
      </tr>
      <tr>
          <td>元件固定尺寸（icon size、button height）</td>
          <td>是</td>
          <td>CSS height / width</td>
      </tr>
      <tr>
          <td>響應式 breakpoint</td>
          <td>是（設計決定）</td>
          <td><code>@media</code> query</td>
      </tr>
      <tr>
          <td>動態文字塊高度（受字型 / 換行）</td>
          <td>否</td>
          <td>JS ResizeObserver</td>
      </tr>
      <tr>
          <td>元件位置（隨 viewport 變化）</td>
          <td>否</td>
          <td>JS getBoundingClientRect</td>
      </tr>
  </tbody>
</table>
<p>知道 → CSS 解。不知道 → JS 量測寫回 CSS 變數、CSS 從變數計算。</p>
<h3 id="問-2這個-dom-變動是-framework-管的嗎">問 2：這個 DOM 變動是 framework 管的嗎</h3>
<table>
  <thead>
      <tr>
          <th>變動</th>
          <th>Framework 管</th>
          <th>工具</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自家 DOM 內元素加 / 移 / 改</td>
          <td>否</td>
          <td>JS 自由動</td>
      </tr>
      <tr>
          <td>Framework 元素的整節點 reparent</td>
          <td>不管內部</td>
          <td>JS 可搬</td>
      </tr>
      <tr>
          <td>Framework 元素內部的子節點</td>
          <td>是</td>
          <td>不要動</td>
      </tr>
      <tr>
          <td>Framework 元素的 attribute</td>
          <td>視 framework 而定</td>
          <td>通常不要動</td>
      </tr>
  </tbody>
</table>
<p>是 → 不要動，用 CSS 視覺解。不是 → JS 可動。</p>
<hr>
<h2 id="內在屬性比較兩種工具的特性">內在屬性比較：兩種工具的特性</h2>
<table>
  <thead>
      <tr>
          <th>屬性</th>
          <th>CSS-only</th>
          <th>JS-assisted</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>知識成本</td>
          <td>低（語言簡單）</td>
          <td>中（需要 DOM API）</td>
      </tr>
      <tr>
          <td>執行時機</td>
          <td>渲染前 / 樣式重新計算</td>
          <td>DOMContentLoaded 後 / 事件觸發</td>
      </tr>
      <tr>
          <td>是否阻塞首次渲染</td>
          <td>是（CSS 是 render-blocking）</td>
          <td>否（async）</td>
      </tr>
      <tr>
          <td>Framework 衝突風險</td>
          <td>無</td>
          <td>有（若動到 framework 管的 DOM）</td>
      </tr>
      <tr>
          <td>可維護性</td>
          <td>高（純 declarative）</td>
          <td>中（imperative）</td>
      </tr>
      <tr>
          <td>跨瀏覽器一致性</td>
          <td>高（CSS 標準清楚）</td>
          <td>中（API 差異）</td>
      </tr>
  </tbody>
</table>
<p>優先 CSS — declarative、無 framework 衝突、首次渲染就生效。JS 補 CSS 做不到的部分。</p>
<hr>
<h2 id="邊界誤判的兩種失敗">邊界誤判的兩種失敗</h2>
<h3 id="css-解-runtime-問題">CSS 解 runtime 問題</h3>
<p>例：用 CSS magic number 寫死 scope-h（猜 56px），實際渲染 73.5px、對齊壞掉。</p>
<p>修法：認知到「scope-h 是 runtime 才能知道的值」、改用 ResizeObserver 量測寫回 CSS 變數。</p>
<h3 id="js-解-framework-managed-layout">JS 解 framework-managed layout</h3>
<p>例：用 JS <code>appendChild</code> 把 scope UI 注入 <code>.pagefind-ui</code> 內、Svelte 重繪時清掉。</p>
<p>修法：認知到「<code>.pagefind-ui</code> 是 framework 邊界內」、改用 CSS absolute 把 scope 浮在外部。</p>
<hr>
<h2 id="設計取捨css--js-工具分配策略">設計取捨：CSS / JS 工具分配策略</h2>
<p>四種做法、各自機會成本不同。這個專案以 A（CSS-first、JS 透過變數補）為主、其他做法在特定情境合理。</p>
<h3 id="acss-firstjs-透過-css-變數補-runtime-值這個專案的預設">A：CSS-first、JS 透過 CSS 變數補 runtime 值（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：CSS 處理 build time 可決定的 layout、JS 量測 runtime 值寫回 CSS 變數、CSS 從變數計算</li>
<li><strong>選 A 的理由</strong>：CSS 是 declarative 不阻塞首次渲染、跟 framework 不衝突；變數是 JS-CSS 介面、單向資料流</li>
<li><strong>適合</strong>：絕大多數客製情境</li>
<li><strong>代價</strong>：需要明確分辨「值能在 build time 定下來嗎」、JS 寫法要是「寫回變數」而不是「直接改 inline style」</li>
</ul>
<h3 id="b純-css-only無-js">B：純 CSS-only（無 JS）</h3>
<ul>
<li><strong>機制</strong>：純宣告式、不寫 JS</li>
<li><strong>跟 A 的取捨</strong>：B 完全無 JS 維護成本、A 多一層 JS；但 B 對 runtime 才知道的尺寸（內容動態）束手無策</li>
<li><strong>B 比 A 好的情境</strong>：所有值都能 build time 確定、layout 完全靜態</li>
</ul>
<h3 id="cjs-imperative-layout每次重算">C：JS-imperative layout（每次重算）</h3>
<ul>
<li><strong>機制</strong>：JS 監聽變動、每次重新計算所有元素位置、寫 inline style</li>
<li><strong>成本特別高的原因</strong>：imperative 代碼難維護、首次渲染慢（要等 JS 跑）、跟 framework 渲染衝突</li>
<li><strong>C 才合理的情境</strong>：CSS 表達不出的複雜 layout（極罕見、現代 CSS 已涵蓋大多數）</li>
</ul>
<h3 id="dcss-magic-number-估算-runtime-值">D：CSS magic number 估算 runtime 值</h3>
<ul>
<li><strong>機制</strong>：CSS 內寫死「應該差不多」的值（不量測）</li>
<li><strong>跟 A 的取捨</strong>：D 看似省 JS、實際對齊在邊界情境（字型 / theme）必壞</li>
<li><strong>D 是反模式</strong>：「runtime 才知道的值」用 CSS 估算是錯誤工具選擇 — 對齊在邊界情境（字型 / theme）必壞</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>工具誤用方向</th>
          <th>修正動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CSS 寫了 magic number、改字型後對不齊</td>
          <td>用 CSS 解 runtime 問題</td>
          <td>量測該值、改 ResizeObserver 寫回變數</td>
      </tr>
      <tr>
          <td>JS 寫了 100+ 行做 layout</td>
          <td>用 JS 解靜態 layout 問題</td>
          <td>退回 CSS、用 grid / flex / absolute 達成</td>
      </tr>
      <tr>
          <td>JS 改 framework DOM 後，framework 更新就失效</td>
          <td>JS 動到 framework 管的領域</td>
          <td>改用 CSS 視覺定位、不動 framework DOM</td>
      </tr>
      <tr>
          <td>Inline style 散落多處難 debug</td>
          <td>JS 直接寫 style 而非透過變數</td>
          <td>重構成「JS 寫 CSS 變數、CSS 從變數計算」</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：選工具不是品味問題、是「值能不能在 build time 定下來」「DOM 是不是我管」兩個技術問題的答案。問清楚再選。</p>
]]></content:encoded></item><item><title>JS 操作 framework 元件：邊界辨識與安全規則</title><link>https://tarrragon.github.io/blog/report/component-boundary-and-js-impact/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/component-boundary-and-js-impact/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>JS 操作 framework 元件前先界定邊界、選對應的安全規則執行。&lt;/strong> 邊界 = 契約 = 安全範圍。整節點搬遷安全、改節點內部不安全、改節點 attribute 是灰區。每類操作有對應的安全規則 — 不是「能不能動」、是「動了之後 framework 會不會 revert」。&lt;/p>
&lt;blockquote>
&lt;p>本篇焦點：&lt;strong>framework 元件本身需要動時的安全規則&lt;/strong>。「客製 UI 該放哪」由 &lt;a href="../coexisting-with-framework-managed-dom/">#5 客製 UI 留 framework 邊界外&lt;/a> 處理 — 預設應該完全不動 framework、需要動時才參考本篇。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="為什麼邊界要先界定">為什麼邊界要先界定&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>每個元件（自家或 framework 提供）有「對外契約」與「內部實作」。對外契約包括：&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>DOM identity&lt;/td>
 &lt;td>哪些 class / id / attribute 是穩定的&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>State 來源&lt;/td>
 &lt;td>元件內部 state 由誰寫、何時改&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>渲染週期&lt;/td>
 &lt;td>元件何時重繪、重繪時影響哪些 DOM&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>對外介面&lt;/td>
 &lt;td>提供哪些 props / events / API hooks&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>JS 操作前不知道這些 = 黑箱操作。動了什麼、會觸發什麼、誰會被影響、不可預測。&lt;/p>
&lt;h3 id="邊界宣告的格式">邊界宣告的格式&lt;/h3>
&lt;p>開始 JS 操作之前、寫一段註解或 mental note：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">動什麼：filter-panel 的 parent
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">邊界：filter-panel 整個節點 OK，內部子節點屬於 pagefind 管
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">State：checkbox 勾選狀態存在 panel 子節點上、由 pagefind 維護
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">動作：appendChild 整節點 reparent
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">為什麼安全：節點 identity 不變、pagefind 在下次 patch 時看到節點還在&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段宣告把「動什麼」「不能動什麼」「為什麼安全」說清楚 — 不是儀式、是強迫自己想清楚再動。&lt;/p>
&lt;hr>
&lt;h2 id="三類操作的安全度">三類操作的安全度&lt;/h2>
&lt;p>從最安全到最不安全：&lt;/p>
&lt;h3 id="1-整節點-reparent安全">1. 整節點 reparent（安全）&lt;/h3>
&lt;p>把 framework 管的整個節點搬到別處 — 節點 identity 不變、framework 在下次 patch 時仍認得它。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 安全 — 整節點搬位置
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">sidebar&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">appendChild&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filterPanel&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nx">drawer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">insertBefore&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filterPanel&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">drawer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">firstChild&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="2-改節點內部子節點不安全">2. 改節點內部子節點（不安全）&lt;/h3>
&lt;p>在 framework 管的節點內 appendChild / removeChild / 改子節點屬性 — framework 會 revert。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 不安全 — 在 framework 子樹內加東西
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">filterPanel&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">appendChild&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">myCustomDiv&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nx">filterPanel&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.x&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">setAttribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;data-y&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;z&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="3-改節點自身的-attribute--inline-style灰區">3. 改節點自身的 attribute / inline style（灰區）&lt;/h3>
&lt;p>改 framework 管的節點本身的 attribute、看 framework 是否認為這屬於 reactive：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 灰區 — 看 framework 怎麼處理
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">style&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">display&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;none&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">classList&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;my-state&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setAttribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;aria-hidden&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;true&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>下節展開三類各自的設計細節。&lt;/p>
&lt;hr>
&lt;h2 id="整節點-reparent為什麼安全怎麼做才安全">整節點 reparent：為什麼安全、怎麼做才安全&lt;/h2>
&lt;h3 id="為什麼安全">為什麼安全&lt;/h3>
&lt;p>Framework 的 reconciliation 通常以「節點 identity」為依據 — &lt;strong>同一個節點在哪裡不重要、節點存不存在才重要&lt;/strong>。&lt;/p>
&lt;p>把 &lt;code>.pagefind-ui__filter-panel&lt;/code> 從 drawer 移到外部 aside：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Framework 看到&lt;/th>
 &lt;th>反應&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>節點還在（identity 沒變）&lt;/td>
 &lt;td>繼續更新它的內部&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>節點的 parent 變了&lt;/td>
 &lt;td>Framework 不關心 — parent 不在 component tree 內&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>節點內的 children 不變&lt;/td>
 &lt;td>Framework 不需要重建&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>reconciliation 不會因為「位置變了」而重建節點 — 重建只發生在「節點消失了 + 新節點出現」的情境。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>JS 操作 framework 元件前先界定邊界、選對應的安全規則執行。</strong> 邊界 = 契約 = 安全範圍。整節點搬遷安全、改節點內部不安全、改節點 attribute 是灰區。每類操作有對應的安全規則 — 不是「能不能動」、是「動了之後 framework 會不會 revert」。</p>
<blockquote>
<p>本篇焦點：<strong>framework 元件本身需要動時的安全規則</strong>。「客製 UI 該放哪」由 <a href="../coexisting-with-framework-managed-dom/">#5 客製 UI 留 framework 邊界外</a> 處理 — 預設應該完全不動 framework、需要動時才參考本篇。</p></blockquote>
<hr>
<h2 id="為什麼邊界要先界定">為什麼邊界要先界定</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>每個元件（自家或 framework 提供）有「對外契約」與「內部實作」。對外契約包括：</p>
<table>
  <thead>
      <tr>
          <th>契約類型</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DOM identity</td>
          <td>哪些 class / id / attribute 是穩定的</td>
      </tr>
      <tr>
          <td>State 來源</td>
          <td>元件內部 state 由誰寫、何時改</td>
      </tr>
      <tr>
          <td>渲染週期</td>
          <td>元件何時重繪、重繪時影響哪些 DOM</td>
      </tr>
      <tr>
          <td>對外介面</td>
          <td>提供哪些 props / events / API hooks</td>
      </tr>
  </tbody>
</table>
<p>JS 操作前不知道這些 = 黑箱操作。動了什麼、會觸發什麼、誰會被影響、不可預測。</p>
<h3 id="邊界宣告的格式">邊界宣告的格式</h3>
<p>開始 JS 操作之前、寫一段註解或 mental note：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">動什麼：filter-panel 的 parent
</span></span><span class="line"><span class="ln">2</span><span class="cl">邊界：filter-panel 整個節點 OK，內部子節點屬於 pagefind 管
</span></span><span class="line"><span class="ln">3</span><span class="cl">State：checkbox 勾選狀態存在 panel 子節點上、由 pagefind 維護
</span></span><span class="line"><span class="ln">4</span><span class="cl">動作：appendChild 整節點 reparent
</span></span><span class="line"><span class="ln">5</span><span class="cl">為什麼安全：節點 identity 不變、pagefind 在下次 patch 時看到節點還在</span></span></code></pre></div><p>這段宣告把「動什麼」「不能動什麼」「為什麼安全」說清楚 — 不是儀式、是強迫自己想清楚再動。</p>
<hr>
<h2 id="三類操作的安全度">三類操作的安全度</h2>
<p>從最安全到最不安全：</p>
<h3 id="1-整節點-reparent安全">1. 整節點 reparent（安全）</h3>
<p>把 framework 管的整個節點搬到別處 — 節點 identity 不變、framework 在下次 patch 時仍認得它。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 安全 — 整節點搬位置
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">sidebar</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">filterPanel</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">drawer</span><span class="p">.</span><span class="nx">insertBefore</span><span class="p">(</span><span class="nx">filterPanel</span><span class="p">,</span> <span class="nx">drawer</span><span class="p">.</span><span class="nx">firstChild</span><span class="p">);</span></span></span></code></pre></div><h3 id="2-改節點內部子節點不安全">2. 改節點內部子節點（不安全）</h3>
<p>在 framework 管的節點內 appendChild / removeChild / 改子節點屬性 — framework 會 revert。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 不安全 — 在 framework 子樹內加東西
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">filterPanel</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">myCustomDiv</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">filterPanel</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.x&#39;</span><span class="p">).</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-y&#39;</span><span class="p">,</span> <span class="s1">&#39;z&#39;</span><span class="p">);</span></span></span></code></pre></div><h3 id="3-改節點自身的-attribute--inline-style灰區">3. 改節點自身的 attribute / inline style（灰區）</h3>
<p>改 framework 管的節點本身的 attribute、看 framework 是否認為這屬於 reactive：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 灰區 — 看 framework 怎麼處理
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">el</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">display</span> <span class="o">=</span> <span class="s1">&#39;none&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">el</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nx">add</span><span class="p">(</span><span class="s1">&#39;my-state&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;aria-hidden&#39;</span><span class="p">,</span> <span class="s1">&#39;true&#39;</span><span class="p">);</span></span></span></code></pre></div><p>下節展開三類各自的設計細節。</p>
<hr>
<h2 id="整節點-reparent為什麼安全怎麼做才安全">整節點 reparent：為什麼安全、怎麼做才安全</h2>
<h3 id="為什麼安全">為什麼安全</h3>
<p>Framework 的 reconciliation 通常以「節點 identity」為依據 — <strong>同一個節點在哪裡不重要、節點存不存在才重要</strong>。</p>
<p>把 <code>.pagefind-ui__filter-panel</code> 從 drawer 移到外部 aside：</p>
<table>
  <thead>
      <tr>
          <th>Framework 看到</th>
          <th>反應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>節點還在（identity 沒變）</td>
          <td>繼續更新它的內部</td>
      </tr>
      <tr>
          <td>節點的 parent 變了</td>
          <td>Framework 不關心 — parent 不在 component tree 內</td>
      </tr>
      <tr>
          <td>節點內的 children 不變</td>
          <td>Framework 不需要重建</td>
      </tr>
  </tbody>
</table>
<p>reconciliation 不會因為「位置變了」而重建節點 — 重建只發生在「節點消失了 + 新節點出現」的情境。</p>
<h3 id="安全-reparent-的-do--dont">安全 reparent 的 do / don&rsquo;t</h3>
<table>
  <thead>
      <tr>
          <th>Do</th>
          <th>Why</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>parent.appendChild(node)</code> 整節點搬</td>
          <td>identity 保留</td>
      </tr>
      <tr>
          <td><code>parent.insertBefore(node, ref)</code> 整節點搬到特定位置</td>
          <td>identity 保留</td>
      </tr>
      <tr>
          <td>搬之前 <code>node.cloneNode(true)</code> 為複本（如果要保留原位）</td>
          <td>複本是新 identity、原節點仍由 framework 管</td>
      </tr>
      <tr>
          <td>搬完後不動 node 內部</td>
          <td>framework 繼續正常更新</td>
      </tr>
  </tbody>
</table>
<table>
  <thead>
      <tr>
          <th>Don&rsquo;t</th>
          <th>Why</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>parent.appendChild(node.firstChild)</code> 搬 framework 子節點</td>
          <td>把節點抽出原 parent、framework 認為消失了</td>
      </tr>
      <tr>
          <td><code>node.innerHTML = node.innerHTML</code> 重設內部</td>
          <td>創造一堆新 identity、framework 認不得</td>
      </tr>
      <tr>
          <td>搬完後在 node 內 appendChild 加東西</td>
          <td>加的東西不在 framework 認知中、被清</td>
      </tr>
      <tr>
          <td>搬完後改 node 內子節點的 text / attribute</td>
          <td>framework 在下次 patch 時 revert</td>
      </tr>
  </tbody>
</table>
<p><strong>核心規則</strong>：搬節點 = 操作 node 本身；不要操作 node 的 children。</p>
<h3 id="跟-framework-reactivity-的對齊">跟 framework reactivity 的對齊</h3>
<p>某些 framework 對節點的「內部值」是 reactive 的（例如 <code>&lt;input&gt;</code> 的 <code>value</code>），改了會被 reconcile 回來。對這類屬性：</p>
<table>
  <thead>
      <tr>
          <th>屬性類型</th>
          <th>操作策略</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Reactive value（input.value、textContent）</td>
          <td>透過 framework API 改、不要直接改 DOM</td>
      </tr>
      <tr>
          <td>純展示 attribute（class、aria-* 多數情境）</td>
          <td>直接改 DOM 通常 OK、但仍是灰區（見下節）</td>
      </tr>
      <tr>
          <td>Layout-relevant style（display、position）</td>
          <td>直接改 DOM 通常 OK、可能需要 fail-safe</td>
      </tr>
  </tbody>
</table>
<p>不確定某屬性是否 reactive：讀框架 source / 文件確認、或加 fail-safe 防意外。</p>
<hr>
<h2 id="改節點內部為什麼不安全有什麼例外">改節點內部：為什麼不安全、有什麼例外</h2>
<h3 id="為什麼不安全">為什麼不安全</h3>
<p>在 framework 管的節點內 appendChild / removeChild — framework 不認得這些操作的結果、下次 patch 時：</p>
<ol>
<li>Framework 比對 component tree 的目標狀態</li>
<li>看到 DOM 多了不該有的節點 → 移除</li>
<li>或看到 DOM 少了該有的節點 → 重建</li>
</ol>
<p>我們手動加的節點屬於前者、被移除。我們手動移除的子節點屬於後者、被重建（且重建的 identity 不同）。</p>
<h3 id="唯一的例外靜態元件--確認-patch-不重設">唯一的例外：靜態元件 + 確認 patch 不重設</h3>
<p>如果該 framework 子樹「初次 mount 後不再 patch」、改內部可能安全。但這是<strong>框架實作細節、隨版本可能變動</strong>。</p>
<p>例：當前 pagefind 的 filter 順序在初次 mount 時生成、後續 patch 不重排 — 所以 reorder filter 子節點實際安全。但這是「當前版本碰巧」、不是「框架保證」。</p>
<p><strong>操作規則</strong>：不要依賴「碰巧安全」。如果必須改內部、加 MutationObserver 監聽 framework 是否 revert、必要時補打。</p>
<hr>
<h2 id="改節點-attribute--inline-style灰區的-fail-safe-設計">改節點 attribute / inline style：灰區的 fail-safe 設計</h2>
<h3 id="為什麼是灰區">為什麼是灰區</h3>
<p>Framework 通常<strong>不主動管理節點的 inline style 與非 reactive attribute</strong> — 但「通常」不是「永遠」。某些 framework 會在 patch 時把 inline style 重設、或把 attribute 跟 component state 強制同步。</p>
<h3 id="fail-safe-工具-1important-提升優先級">Fail-safe 工具 1：<code>!important</code> 提升優先級</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">el</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;display&#39;</span><span class="p">,</span> <span class="s1">&#39;none&#39;</span><span class="p">,</span> <span class="s1">&#39;important&#39;</span><span class="p">);</span></span></span></code></pre></div><p><code>important</code> 把 inline style 的優先級提升 — 即使 framework 套了同屬性的低優先 style、也蓋不過。</p>
<h3 id="fail-safe-工具-2mutationobserver-補打">Fail-safe 工具 2：MutationObserver 補打</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">reapply</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">el</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;display&#39;</span><span class="p">,</span> <span class="s1">&#39;none&#39;</span><span class="p">,</span> <span class="s1">&#39;important&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">reapply</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="nx">reapply</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">parent</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nx">subtree</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>Framework 在重繪後可能把 element 替換成新的 — observer 監聽到變動、立刻補套 style。</p>
<p>詳細設計（observer 範圍 / 觸發頻率 / self-mutation 處理）由 <a href="../mutation-observer-scope/">#29 MutationObserver 範圍與觸發頻率</a> 處理。</p>
<h3 id="fail-safe-工具-3css-class-toggle-取代-inline-style">Fail-safe 工具 3：CSS class toggle 取代 inline style</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 不用 inline style
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">el</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nx">toggle</span><span class="p">(</span><span class="s1">&#39;is-hidden&#39;</span><span class="p">);</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">/* CSS 內定義行為、layered CSS 不需要 important */</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">@</span><span class="k">layer</span> <span class="nt">base</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">.</span><span class="nc">is-hidden</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">none</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>詳細展開由 <a href="../class-toggle-over-important/">#28 class toggle 取代 important</a> 處理。</p>
<p><strong>選擇順序</strong>：能用 class toggle 就用（最乾淨）；framework 會清 class 才用 inline + important + observer。</p>
<hr>
<h2 id="這次任務的邊界辨識實例">這次任務的邊界辨識實例</h2>
<p>四個 JS 操作場景、各有不同邊界：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>動的對象</th>
          <th>操作類別</th>
          <th>安全度</th>
          <th>處理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>把 filter-panel 從 drawer 搬到 sidebar</td>
          <td>整節點 reparent</td>
          <td>1（安全）</td>
          <td>高</td>
          <td>直接搬、不動內部</td>
      </tr>
      <tr>
          <td>Reorder type / tag filter</td>
          <td>filter 子節點順序</td>
          <td>2（不安全）</td>
          <td>中 — 視 framework 而定</td>
          <td>確認框架不 reset 順序、加 observer 防護</td>
      </tr>
      <tr>
          <td>注入 scope UI</td>
          <td>自家新元件</td>
          <td>N/A（自家領域）</td>
          <td>高</td>
          <td>放 framework 邊界外（<a href="../coexisting-with-framework-managed-dom/">#5</a>）</td>
      </tr>
      <tr>
          <td>Filter 結果 hide / show</td>
          <td>pagefind 結果元素的 display</td>
          <td>3（灰區）</td>
          <td>中</td>
          <td>inline + important + observer 補打</td>
      </tr>
  </tbody>
</table>
<p>每個場景操作前的 mental check：「這是哪一類？該用什麼安全規則？」</p>
<hr>
<h2 id="設計取捨操作-framework-元件的策略">設計取捨：操作 framework 元件的策略</h2>
<p>四種策略、各自機會成本不同。預設追求「最高安全度的方式達成需求」、成本太高再降級。</p>
<h3 id="a完全不動-framework客製留邊界外這個專案的預設">A：完全不動 framework、客製留邊界外（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：把客製 UI 放在 framework sibling 位置、用 CSS 達成視覺效果</li>
<li><strong>選 A 的理由</strong>：跟 framework 完全解耦、命運自主</li>
<li><strong>適合</strong>：需求是「在 framework 旁加東西」（多數情境）</li>
<li><strong>代價</strong>：CSS 定位可能複雜</li>
<li><strong>詳細</strong>：<a href="../coexisting-with-framework-managed-dom/">#5 客製 UI 留 framework 邊界外</a></li>
</ul>
<h3 id="b整節點-reparent">B：整節點 reparent</h3>
<ul>
<li><strong>機制</strong>：把 framework 管的節點搬位置、不動內部</li>
<li><strong>跟 A 的取捨</strong>：A 不動 framework、B 搬 framework 元件本身；B 換到的是「能改變 framework 元件位置」、付出的是「節點內部仍由 framework 管、外部行為仍可能變」</li>
<li><strong>B 比 A 好的情境</strong>：framework 元件位置決定權需要奪回（例如 sidebar 切換）</li>
</ul>
<h3 id="c改節點-attribute--fail-safe">C：改節點 attribute + fail-safe</h3>
<ul>
<li><strong>機制</strong>：改 inline style / class、加 important + observer 補打</li>
<li><strong>跟 A/B 的取捨</strong>：A 不碰 framework、C 介入 framework 元件本身的視覺行為；C 比 A 侵入性高、但比直接改內部安全</li>
<li><strong>C 比 B 好的情境</strong>：需要的不是搬位置、是改顯隱 / 顏色 / state</li>
</ul>
<h3 id="d改節點內部最後手段">D：改節點內部（最後手段）</h3>
<ul>
<li><strong>機制</strong>：在 framework 子樹內 appendChild、改子節點屬性</li>
<li><strong>成本特別高的原因</strong>：跟 framework reconciliation 直接競爭、bug 不可預測、升級可能徹底打破</li>
<li><strong>D 才合理的情境</strong>：當前 framework 確認「該子樹不 reconcile」+ 升級時會重新驗證 — 通常不值得</li>
</ul>
<hr>
<h2 id="邊界宣告的實踐">邊界宣告的實踐</h2>
<h3 id="寫成-jsdoc-或-inline-註解">寫成 JSDoc 或 inline 註解</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="cm">/**
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="cm"> * 把 .pagefind-ui__filter-panel 從 drawer 搬到外部 sidebar。
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="cm"> *
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="cm"> * 邊界：
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="cm"> *   - 動：filter-panel 整節點的 parent
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="cm"> *   - 不動：filter-panel 內部子節點（由 pagefind 管）
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="cm"> *   - State：checkbox 勾選由 pagefind 維護、跟著節點走
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="cm"> *
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="cm"> * 為什麼安全：節點 identity 不變、pagefind 在下次 patch
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="cm"> * 時看到節點還在、繼續更新內部。
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="cm"> */</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">function</span> <span class="nx">place</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">mql</span><span class="p">.</span><span class="nx">matches</span><span class="p">)</span> <span class="nx">sidebar</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">filter</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="k">else</span> <span class="nx">drawer</span><span class="p">.</span><span class="nx">insertBefore</span><span class="p">(</span><span class="nx">filter</span><span class="p">,</span> <span class="nx">drawer</span><span class="p">.</span><span class="nx">firstChild</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>註解是給未來的自己 / 同事看的「契約備忘」 — 看到操作時知道為什麼安全。</p>
<hr>
<h2 id="跟其他原則的關係">跟其他原則的關係</h2>
<table>
  <thead>
      <tr>
          <th>抽象層原則</th>
          <th>關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../external-component-collaboration-layers/">#45 跟外部組件合作的層次</a></td>
          <td>本篇是「邊界內 DOM 層」操作的具體規則 — 接受要進入這層、用本篇規則限制傷害</td>
      </tr>
      <tr>
          <td><a href="../minimum-necessary-scope-is-sanity-defense/">#43 最小必要範圍</a></td>
          <td>操作範圍越小越安全 — 整節點 reparent 比改內部範圍小、改 attribute 比改子樹範圍小</td>
      </tr>
      <tr>
          <td><a href="../coexisting-with-framework-managed-dom/">#5 客製 UI 留邊界外</a></td>
          <td>互補關係 — #5 處理「不動 framework 的策略」、本篇處理「必須動 framework 時的安全規則」</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>邊界問題</th>
          <th>第一個該檢查的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>JS 操作後 framework 行為異常</td>
          <td>動到內部子節點</td>
          <td>確認操作只動「整節點 identity」、不動內部</td>
      </tr>
      <tr>
          <td>Inline style 在某些互動後消失</td>
          <td>動到 framework 管的 attribute</td>
          <td>加 observer 補打、或改用 CSS class toggle</td>
      </tr>
      <tr>
          <td>reparent 後 framework state 重置</td>
          <td>整節點移動但 framework 看作刪除</td>
          <td>確認框架對節點 identity 的追蹤機制（少數框架不靠 identity）</td>
      </tr>
      <tr>
          <td>某些 querySelector 命中不該命中的元素</td>
          <td>Selector 範圍超過自家元件</td>
          <td>把 query 限縮到 self 元件根節點下（<a href="../dom-selector-precision/">#14 selector 精準度</a>）</td>
      </tr>
      <tr>
          <td>「再加一段防禦邏輯應該就好了」第 2 次</td>
          <td>整體策略可能該換層級（從 D 升到 C 或 B）</td>
          <td><a href="../two-occurrence-threshold/">#42 2 次門檻</a>、考慮換策略</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：JS 動 framework 元件前、邊界先界定、選對應的安全規則。預設追求「完全不動 framework」(A)、必須動時用層級遞減的策略（B / C / D）— 每往下一層付的是「跟 framework 競爭」的成本。</p>
]]></content:encoded></item><item><title>Selector 精準度：讓 query 只命中你想要的元素</title><link>https://tarrragon.github.io/blog/report/dom-selector-precision/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/dom-selector-precision/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>JS 的 DOM query 從具體開始、發現不夠用再放寬。&lt;/strong> Selector 涵蓋「最少必要範圍」、避免誤命中其他元素、避免未來頁面結構變動讓 query 撈到不該撈的東西。精準度有三個收斂維度：起點（從哪開始找）、範圍（找多深）、過濾（哪些不要）— 三者一起設計才完整。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼精準度是-default">為什麼精準度是 default&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>DOM selector 的範圍越寬、被誤命中的可能性越高。寬泛 selector 像「網撈」 — 當下頁面只有一個目標元素時看不出問題、未來頁面結構變動（加第二個同類元件、加 demo 區塊、加 widget）就壞。&lt;/p>
&lt;p>精準度的成本是「寫 selector 時多想一點」、收益是「行為可預測、不會被未來變動打破」。&lt;strong>這不是優化、是 sanity 防線&lt;/strong>。&lt;/p>
&lt;h3 id="寬泛-selector-的失敗模式">寬泛 selector 的失敗模式&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &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>沒指定 ancestor scope&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同名 class 誤命中&lt;/td>
 &lt;td>demo 區塊 / 文檔截圖也被處理&lt;/td>
 &lt;td>沒過濾「處於展示用途」的元素&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>未初始化元素被處理&lt;/td>
 &lt;td>元件還沒 mount 完就被操作&lt;/td>
 &lt;td>沒過濾「狀態未就緒」的元素&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>已處理元素重複處理&lt;/td>
 &lt;td>apply 被 observer 觸發又處理一次&lt;/td>
 &lt;td>沒標記「已處理」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>四種失敗都來自「query 範圍 &amp;gt; 真實需要的範圍」。從具體開始就避免。&lt;/p>
&lt;hr>
&lt;h2 id="三層收斂維度">三層收斂維度&lt;/h2>
&lt;p>Selector 精準度不是單一參數、是三個維度的組合。每個維度都該設計、不能只想其中一個。&lt;/p>
&lt;h3 id="維度-1起點從哪個-root-開始找">維度 1：起點（從哪個 root 開始找）&lt;/h3>
&lt;p>&lt;strong>核心定義&lt;/strong>：query 的起點決定「最大可能範圍」。從 &lt;code>document&lt;/code> 起 = 全頁面；從元件根起 = 子樹內。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 寬：全頁面搜尋
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__result&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">// 收斂：從元件根開始
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">var&lt;/span> &lt;span class="nx">shell&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-shell&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__result&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>從元件根開始等於把 selector 的作用範圍收斂到「我管的子樹」 — 即使未來頁面其他地方出現同名元素、跟我無關。&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;code>document&lt;/code>&lt;/td>
 &lt;td>確定全頁只有一個目標、且未來不會增加同類&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元件根（變數存好）&lt;/td>
 &lt;td>一般情境（推薦預設）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>函式參數傳入根&lt;/td>
 &lt;td>同頁面有多個元件實例、各自獨立 setup&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事件 &lt;code>closest&lt;/code> 反向找根&lt;/td>
 &lt;td>動態多實例、用事件驅動&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>多元件 setup pattern&lt;/strong>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">setupSearchShell&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">ui&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">input&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">drawer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__drawer&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ... 其他 setup
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelectorAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-shell&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">setupSearchShell&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>頁面有 N 個 shell、自動 setup N 次、各自獨立。當前只一個也適用、未來加更多無痛 — 這是「起點當參數」帶來的擴展性。&lt;/p>
&lt;p>&lt;strong>例外處理&lt;/strong>：當目標元素不在元件子樹內（例如同層的 sibling），保留 &lt;code>document.querySelector&lt;/code> 但加註解說明：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// slot 是 main 的子節點、跟 shell 同層、不能從 shell 找
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">var&lt;/span> &lt;span class="nx">slot&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-filter-slot&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>註解讓未來維護者知道這是「明知故為」的例外、不是疏忽。&lt;/p>
&lt;h3 id="維度-2範圍找多深">維度 2：範圍（找多深）&lt;/h3>
&lt;p>&lt;strong>核心定義&lt;/strong>：起點確定後、要找直接子、特定層、還是任意深度。&lt;/p>
&lt;p>&lt;code>querySelector&lt;/code> 預設找任意深度 — 大部分情況沒問題、但結構穩定時可以更精準：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 預設：任意深度
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">// 限縮：只找直接子
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;:scope &amp;gt; .pagefind-ui&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1">// 限縮：只找特定層
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;:scope &amp;gt; div &amp;gt; .pagefind-ui&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>:scope&lt;/code> 在 querySelector 內表示 query 的起始元素 — 配合 &lt;code>&amp;gt;&lt;/code> 就能精準匹配「直接子」。&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;code>:scope &amp;gt; X&lt;/code>&lt;/td>
 &lt;td>結構穩定、避免深層誤命中&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>特定路徑 &lt;code>:scope &amp;gt; A &amp;gt; B&lt;/code>&lt;/td>
 &lt;td>結構非常穩定、想要結構變動立即察覺&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>選太寬未來誤命中、選太窄未來結構微調就壞 — 預設選任意深度、結構穩定的關鍵 query 才用 &lt;code>:scope &amp;gt;&lt;/code>。&lt;/p>
&lt;h3 id="維度-3過濾哪些元素不要">維度 3：過濾（哪些元素不要）&lt;/h3>
&lt;p>&lt;strong>核心定義&lt;/strong>：起點 + 範圍確定後、可能還是命中過多 — 用 attribute filter 與否定 selector 排除不要的。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>JS 的 DOM query 從具體開始、發現不夠用再放寬。</strong> Selector 涵蓋「最少必要範圍」、避免誤命中其他元素、避免未來頁面結構變動讓 query 撈到不該撈的東西。精準度有三個收斂維度：起點（從哪開始找）、範圍（找多深）、過濾（哪些不要）— 三者一起設計才完整。</p>
<hr>
<h2 id="為什麼精準度是-default">為什麼精準度是 default</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>DOM selector 的範圍越寬、被誤命中的可能性越高。寬泛 selector 像「網撈」 — 當下頁面只有一個目標元素時看不出問題、未來頁面結構變動（加第二個同類元件、加 demo 區塊、加 widget）就壞。</p>
<p>精準度的成本是「寫 selector 時多想一點」、收益是「行為可預測、不會被未來變動打破」。<strong>這不是優化、是 sanity 防線</strong>。</p>
<h3 id="寬泛-selector-的失敗模式">寬泛 selector 的失敗模式</h3>
<table>
  <thead>
      <tr>
          <th>失敗模式</th>
          <th>表現</th>
          <th>根因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>跨元件誤命中</td>
          <td>該動的動了、不該動的也動了</td>
          <td>沒指定 ancestor scope</td>
      </tr>
      <tr>
          <td>同名 class 誤命中</td>
          <td>demo 區塊 / 文檔截圖也被處理</td>
          <td>沒過濾「處於展示用途」的元素</td>
      </tr>
      <tr>
          <td>未初始化元素被處理</td>
          <td>元件還沒 mount 完就被操作</td>
          <td>沒過濾「狀態未就緒」的元素</td>
      </tr>
      <tr>
          <td>已處理元素重複處理</td>
          <td>apply 被 observer 觸發又處理一次</td>
          <td>沒標記「已處理」</td>
      </tr>
  </tbody>
</table>
<p>四種失敗都來自「query 範圍 &gt; 真實需要的範圍」。從具體開始就避免。</p>
<hr>
<h2 id="三層收斂維度">三層收斂維度</h2>
<p>Selector 精準度不是單一參數、是三個維度的組合。每個維度都該設計、不能只想其中一個。</p>
<h3 id="維度-1起點從哪個-root-開始找">維度 1：起點（從哪個 root 開始找）</h3>
<p><strong>核心定義</strong>：query 的起點決定「最大可能範圍」。從 <code>document</code> 起 = 全頁面；從元件根起 = 子樹內。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 寬：全頁面搜尋
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__result&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// 收斂：從元件根開始
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="kd">var</span> <span class="nx">shell</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__result&#39;</span><span class="p">);</span></span></span></code></pre></div><p>從元件根開始等於把 selector 的作用範圍收斂到「我管的子樹」 — 即使未來頁面其他地方出現同名元素、跟我無關。</p>
<p><strong>起點選擇的決策</strong>：</p>
<table>
  <thead>
      <tr>
          <th>起點</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>document</code></td>
          <td>確定全頁只有一個目標、且未來不會增加同類</td>
      </tr>
      <tr>
          <td>元件根（變數存好）</td>
          <td>一般情境（推薦預設）</td>
      </tr>
      <tr>
          <td>函式參數傳入根</td>
          <td>同頁面有多個元件實例、各自獨立 setup</td>
      </tr>
      <tr>
          <td>事件 <code>closest</code> 反向找根</td>
          <td>動態多實例、用事件驅動</td>
      </tr>
  </tbody>
</table>
<p><strong>多元件 setup pattern</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">setupSearchShell</span><span class="p">(</span><span class="nx">shell</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">var</span> <span class="nx">ui</span>     <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">var</span> <span class="nx">input</span>  <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="kd">var</span> <span class="nx">drawer</span> <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="c1">// ... 其他 setup
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">setupSearchShell</span><span class="p">);</span></span></span></code></pre></div><p>頁面有 N 個 shell、自動 setup N 次、各自獨立。當前只一個也適用、未來加更多無痛 — 這是「起點當參數」帶來的擴展性。</p>
<p><strong>例外處理</strong>：當目標元素不在元件子樹內（例如同層的 sibling），保留 <code>document.querySelector</code> 但加註解說明：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// slot 是 main 的子節點、跟 shell 同層、不能從 shell 找
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">var</span> <span class="nx">slot</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-filter-slot&#39;</span><span class="p">);</span></span></span></code></pre></div><p>註解讓未來維護者知道這是「明知故為」的例外、不是疏忽。</p>
<h3 id="維度-2範圍找多深">維度 2：範圍（找多深）</h3>
<p><strong>核心定義</strong>：起點確定後、要找直接子、特定層、還是任意深度。</p>
<p><code>querySelector</code> 預設找任意深度 — 大部分情況沒問題、但結構穩定時可以更精準：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 預設：任意深度
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// 限縮：只找直接子
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;:scope &gt; .pagefind-ui&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">// 限縮：只找特定層
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;:scope &gt; div &gt; .pagefind-ui&#39;</span><span class="p">);</span></span></span></code></pre></div><p><code>:scope</code> 在 querySelector 內表示 query 的起始元素 — 配合 <code>&gt;</code> 就能精準匹配「直接子」。</p>
<p><strong>範圍選擇的決策</strong>：</p>
<table>
  <thead>
      <tr>
          <th>範圍</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>任意深度（預設）</td>
          <td>結構可能變動、目標可能搬位置</td>
      </tr>
      <tr>
          <td>直接子 <code>:scope &gt; X</code></td>
          <td>結構穩定、避免深層誤命中</td>
      </tr>
      <tr>
          <td>特定路徑 <code>:scope &gt; A &gt; B</code></td>
          <td>結構非常穩定、想要結構變動立即察覺</td>
      </tr>
  </tbody>
</table>
<p>選太寬未來誤命中、選太窄未來結構微調就壞 — 預設選任意深度、結構穩定的關鍵 query 才用 <code>:scope &gt;</code>。</p>
<h3 id="維度-3過濾哪些元素不要">維度 3：過濾（哪些元素不要）</h3>
<p><strong>核心定義</strong>：起點 + 範圍確定後、可能還是命中過多 — 用 attribute filter 與否定 selector 排除不要的。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 寬：所有 result
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">shell</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__result&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// 過濾：只取已 rank 過的（排除初始化中的）
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="nx">shell</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__result[data-pagefind-rank]&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">// 過濾：排除已處理過的
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="nx">shell</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__result:not([data-scoped])&#39;</span><span class="p">);</span></span></span></code></pre></div><p><strong>過濾技巧</strong>：</p>
<table>
  <thead>
      <tr>
          <th>技巧</th>
          <th>用法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Attribute filter</td>
          <td><code>[data-state=&quot;ready&quot;]</code> 只取狀態就緒的</td>
      </tr>
      <tr>
          <td><code>:not()</code> 排除</td>
          <td><code>:not([data-scoped])</code> 排除已處理</td>
      </tr>
      <tr>
          <td>Attribute exists</td>
          <td><code>[data-pagefind-rank]</code> 只取有特定屬性的</td>
      </tr>
      <tr>
          <td>處理後標記</td>
          <td>處理完 <code>el.setAttribute('data-scoped', 'true')</code> 避免重複處理</td>
      </tr>
  </tbody>
</table>
<p><strong>「處理後標記」是 idempotency 工具</strong>：apply 函式可能被多次呼叫（observer 觸發、event 觸發），標記 + <code>:not()</code> 過濾確保每個元素只處理一次。</p>
<hr>
<h2 id="三維度的組合範例">三維度的組合範例</h2>
<p>完整的精準 selector 設計：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">var</span> <span class="nx">shell</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">);</span>           <span class="c1">// 維度 1：起點
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">shell</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kd">var</span> <span class="nx">results</span> <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span>                          <span class="c1">// 維度 2：任意深度
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span>  <span class="s1">&#39;.pagefind-ui__result[data-pagefind-rank]:not([data-scoped])&#39;</span>  <span class="c1">// 維度 3：過濾
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="nx">results</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="c1">// ... 處理
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>  <span class="nx">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-scoped&#39;</span><span class="p">,</span> <span class="s1">&#39;true&#39;</span><span class="p">);</span>                      <span class="c1">// 處理後標記
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p>每個維度都有意識地選擇 — 不是把所有預設值疊一起。</p>
<hr>
<h2 id="內在屬性比較四種-selector-設計">內在屬性比較：四種 selector 設計</h2>
<table>
  <thead>
      <tr>
          <th>設計</th>
          <th>誤命中風險</th>
          <th>未來結構變動的容忍度</th>
          <th>多元件支援</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>document.querySelector('.x')</code></td>
          <td>高</td>
          <td>低 — 任何同名出現就壞</td>
          <td>否（只取第一個）</td>
      </tr>
      <tr>
          <td><code>shell.querySelector('.x')</code></td>
          <td>低</td>
          <td>中 — shell 內變動才影響</td>
          <td>部分</td>
      </tr>
      <tr>
          <td><code>shell.querySelector(':scope &gt; .x')</code></td>
          <td>最低</td>
          <td>低 — 結構微調就壞</td>
          <td>部分</td>
      </tr>
      <tr>
          <td>起點當參數 + 過濾 + 標記</td>
          <td>最低</td>
          <td>高 — 顯式聲明所有假設</td>
          <td>完整</td>
      </tr>
  </tbody>
</table>
<p><strong>推薦</strong>：起點當參數 + 過濾。<code>:scope &gt;</code> 只在「結構保證穩定」的關鍵 query 用。</p>
<hr>
<h2 id="進階技巧">進階技巧</h2>
<h3 id="1-把元件根存成變數一次">1. 把元件根存成變數一次</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">shell</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">shell</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">// 之後所有 query 都從 shell 開始
</span></span></span></code></pre></div><p>避免每次 query 都重新從 document 找元件根 — 一是效能（小）、二是 query 範圍仍維持在 shell 內。</p>
<h3 id="2-用-closest-反向找根">2. 用 closest 反向找根</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">getShell</span><span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">return</span> <span class="nx">el</span><span class="p">.</span><span class="nx">closest</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;click&#39;</span><span class="p">,</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="kd">var</span> <span class="nx">shell</span> <span class="o">=</span> <span class="nx">getShell</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">shell</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="c1">// 在這個 shell 內處理
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p>事件委派 + closest 適合「多元件實例 + 動態事件處理」 — 各 shell 不需要各自綁 listener、共用一個 listener 用 closest 區分。</p>
<h3 id="3-起點不存在時提早-return">3. 起點不存在時提早 return</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">shell</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">shell</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span></span></span></code></pre></div><p>頁面可能沒有 shell（不是搜尋頁），所有後續 query 都會失敗。提早 return 比後續一連串 null check 乾淨。</p>
<h3 id="4-weakmap-替代-attribute-標記">4. WeakMap 替代 attribute 標記</h3>
<p>當不想污染 DOM attribute 時、用 WeakMap 紀錄已處理的元素：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">processed</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">WeakMap</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">shell</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__result&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">processed</span><span class="p">.</span><span class="nx">has</span><span class="p">(</span><span class="nx">el</span><span class="p">))</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="c1">// ... 處理
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>  <span class="nx">processed</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>WeakMap 在元素 GC 時自動清理、不留下 DOM 痕跡。適合短生命週期的 idempotency。</p>
<hr>
<h2 id="設計取捨起點選擇">設計取捨：起點選擇</h2>
<p>Selector 的「起點」有四種做法、各自機會成本不同。這個專案選 B（元件根存變數）當預設、其他做法在特定情境也合理。每張卡片獨立展開該做法的設計細節。</p>
<h3 id="adocumentqueryselector">A：<a href="../pattern-document-query/"><code>document.querySelector</code> 全文件搜</a></h3>
<ul>
<li><strong>機制</strong>：每處 query 都從 document 開始、靠 class name 唯一性命中目標</li>
<li><strong>適合</strong>：原型階段、demo 程式碼、確定全頁只有一個目標且未來不會變</li>
<li><strong>代價</strong>：未來頁面結構變動（加同類 widget、加 demo 區塊）就壞、且失敗模式是安靜地操作錯元素、不報錯</li>
<li><strong>選 A 的時機</strong>：「快速看會不會動」的探索期</li>
</ul>
<h3 id="b元件根存變數之後從變數-query這個專案的預設">B：<a href="../pattern-component-root/">元件根存變數、之後從變數 query</a>（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：<code>var shell = document.querySelector('.search-shell')</code> 一次、之後所有 query 用 <code>shell.querySelector(...)</code></li>
<li><strong>選 B 的理由</strong>：當前頁面只有一個 shell、未來可能加（站內搜尋 widget、相關搜尋）— 用變數隔離成本低、提早預防</li>
<li><strong>適合</strong>：一般客製情境、預期未來結構可能擴展</li>
<li><strong>代價</strong>：多一個變數、多一次 query;函式內邏輯變得依賴外部變數</li>
</ul>
<h3 id="c函式接受元件根當參數">C：<a href="../pattern-root-as-parameter/">函式接受元件根當參數</a></h3>
<ul>
<li><strong>機制</strong>：<code>function setup(shell) { shell.querySelector(...) }</code>、外部呼叫 <code>document.querySelectorAll('.shell').forEach(setup)</code></li>
<li><strong>跟 B 的取捨</strong>：B 假設只有一個 shell、C 直接支援多 shell；C 的設計成本前期較高（每函式多一個參數）、但多實例支援是免費的</li>
<li><strong>C 比 B 好的情境</strong>：頁面同時有多個 shell（例如多語切換頁面）、或計劃中要重用組件到不同頁面</li>
</ul>
<h3 id="d事件-">D：<a href="../pattern-closest-lookup/">事件 + <code>closest</code> 反向找根</a></h3>
<ul>
<li><strong>機制</strong>：監聽全域事件、事件處理時 <code>e.target.closest('.shell')</code> 反向找元件根</li>
<li><strong>跟 B/C 的取捨</strong>：B/C 是「初始化時綁定」、D 是「事件發生時動態判斷」— D 適合元件動態出現 / 消失（SPA 路由切換、AJAX 注入）</li>
<li><strong>D 比 C 好的情境</strong>：元件實例在 runtime 動態增減、用 mutation observer 補打成本反而更高</li>
<li><strong>代價</strong>：事件委派的調試比直接綁定難（不知道事件實際從哪傳上來）</li>
</ul>
<hr>
<h2 id="設計取捨範圍深度">設計取捨：範圍深度</h2>
<p><code>querySelector</code> 預設找任意深度、可以收緊到直接子。三種做法：</p>
<h3 id="a任意深度這個專案的預設">A：任意深度（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：<code>shell.querySelector('.target')</code> — 子樹任何深度都接受</li>
<li><strong>選 A 的理由</strong>：結構可能因 framework 升級微調、容忍微調換取維護彈性</li>
<li><strong>代價</strong>：深層結構意外多出同名元素時可能誤命中</li>
</ul>
<h3 id="b直接子-scope--x">B：直接子 <code>:scope &gt; X</code></h3>
<ul>
<li><strong>機制</strong>：<code>shell.querySelector(':scope &gt; .target')</code> — 只找直接子</li>
<li><strong>跟 A 的取捨</strong>：A 容忍結構微調、B 強制結構穩定 — B 帶來「結構變動立即報錯」的早期偵測</li>
<li><strong>B 比 A 好的情境</strong>：自家完全控制的結構、想用 selector 失敗當回歸測試訊號</li>
</ul>
<h3 id="c特定路徑-scope--a--b">C：特定路徑 <code>:scope &gt; A &gt; B</code></h3>
<ul>
<li><strong>機制</strong>：強制一條精確路徑</li>
<li><strong>代價</strong>：結構任何微調都壞、維護成本高</li>
<li><strong>C 才合理的情境</strong>：寫整合測試的結構斷言、不是 production query</li>
</ul>
<hr>
<h2 id="設計取捨過濾與-idempotency">設計取捨：過濾與 idempotency</h2>
<p>apply 函式可能被多次觸發（observer / event / 初始化）、過濾保證每元素只處理一次。三種做法：</p>
<h3 id="adom-attribute-標記這個專案的預設">A：<a href="../pattern-attribute-idempotency-marker/">DOM attribute 標記</a>（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：<code>:not([data-scoped])</code> 過濾 + 處理後 <code>el.setAttribute('data-scoped', 'true')</code></li>
<li><strong>選 A 的理由</strong>：標記跟著 DOM 元素走、元素被移除時自動清理；標記在 devtools 可見、debug 直接</li>
<li><strong>代價</strong>：DOM 上多了一個自家用的 attribute（命名衝突風險小）</li>
</ul>
<h3 id="bweakmap-紀錄">B：<a href="../pattern-weakmap-idempotency-record/">WeakMap 紀錄</a></h3>
<ul>
<li><strong>機制</strong>：<code>var processed = new WeakMap(); processed.set(el, true)</code></li>
<li><strong>跟 A 的取捨</strong>：B 不污染 DOM、適合「不想留 attribute 痕跡」的場景；A 在 devtools 可見、debug 較直接</li>
<li><strong>B 比 A 好的情境</strong>：寫成第三方函式庫、不想對使用者 DOM 加屬性</li>
</ul>
<h3 id="c依賴外部呼叫者保證只呼叫一次">C：依賴外部呼叫者保證只呼叫一次</h3>
<ul>
<li><strong>機制</strong>：apply 內不防護、依賴 init 時只綁一次 listener</li>
<li><strong>成本特別高的原因</strong>：observer 觸發 / 事件觸發 / 初始化任一處多呼叫、就產生重複處理 bug；錯誤難以追蹤</li>
<li><strong>C 才合理的情境</strong>：apply 本身是 idempotent 的（例如 set class 設成已是的值、無副作用）— 此時不需過濾</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>Selector 精準度問題</th>
          <th>修正動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>多處 <code>document.querySelector</code> 同類元素</td>
          <td>起點太寬</td>
          <td>把元件根存變數、之後 query 從變數開始</td>
      </tr>
      <tr>
          <td>同頁加第二個元件實例後行為錯亂</td>
          <td>起點 hardcode</td>
          <td>改「起點當參數」pattern</td>
      </tr>
      <tr>
          <td>Selector 命中了不該命中的元素</td>
          <td>範圍 / 過濾不足</td>
          <td>加 ancestor scope、或加 attribute filter</td>
      </tr>
      <tr>
          <td>Apply 被多次呼叫產生重複處理</td>
          <td>沒 idempotency 防線</td>
          <td>加 <code>:not([data-flag])</code> + 處理後標記</td>
      </tr>
      <tr>
          <td>結構微調後 selector 失效</td>
          <td><code>:scope &gt;</code> 用得太死</td>
          <td>換成任意深度（預設）</td>
      </tr>
      <tr>
          <td>事件處理時不知是哪個元件實例</td>
          <td>沒反向找根機制</td>
          <td>用 <code>closest</code></td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：Selector 精準度不是極致最佳化、是 sanity 防線。三維度（起點 / 範圍 / 過濾）一起設計、每個維度都顯式選擇 — 比從寬泛開始一路追 bug 容易得多。</p>
<p>寬 selector（<code>querySelectorAll('.title')</code>）是「便利位置」、窄 selector 是「對齊位置」 — 這個反相關的更高層原則見 <a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關</a>。</p>
]]></content:encoded></item><item><title>runtime 量測模式統一</title><link>https://tarrragon.github.io/blog/report/runtime-measurement-unification/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/runtime-measurement-unification/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>對齊基準上的尺寸值、要嘛統一寫死、要嘛統一 runtime 量測 — 不要混搭。&lt;/strong> 混搭時某些變化（字型替換、scale 改變、theme 切換）會打破對齊、且問題只在特定情境出現、難以重現。選一邊走到底。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼混搭會不穩">為什麼混搭會不穩&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>對齊問題本質是「方程組」 — 每個變數的值都要正確、結果才對。&lt;/p>
&lt;p>寫死值的特徵：&lt;/p>
&lt;ul>
&lt;li>來源是 build time 設計決定&lt;/li>
&lt;li>變動需要手動改 CSS&lt;/li>
&lt;li>假設某個渲染條件成立（特定字型、特定 scale）&lt;/li>
&lt;/ul>
&lt;p>量測值的特徵：&lt;/p>
&lt;ul>
&lt;li>來源是 runtime DOM 量測&lt;/li>
&lt;li>自動跟著實際渲染走&lt;/li>
&lt;li>不依賴特定渲染條件&lt;/li>
&lt;/ul>
&lt;p>混搭時的失敗模式：寫死值依賴的渲染條件變了、但量測值跟著變、寫死值沒跟 — 兩者錯位、對齊壞掉。&lt;/p>
&lt;h3 id="統一往一邊靠的選擇">統一往一邊靠的選擇&lt;/h3>
&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>設計 token 穩定、組件提供 scale hook 可鎖定&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>全部量測（runtime 同步）&lt;/td>
 &lt;td>內容動態、字型 / 排版可能變動&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>選擇看「願意接受多少不確定性」 — 全寫死要鎖很多條件、全量測要寫多個 ResizeObserver。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的混搭問題">這次任務的混搭問題&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>對齊基準上四個值的處理：&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;code>--search-title-h&lt;/code> (H1)&lt;/td>
 &lt;td>寫死 64px&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>--search-form-h&lt;/code> (input)&lt;/td>
 &lt;td>寫死 68px、靠 &lt;code>--pagefind-ui-scale: 1.0&lt;/code> 鎖定&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>--search-gap&lt;/code> (drawer 上方)&lt;/td>
 &lt;td>寫死 20px&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>--search-scope-h&lt;/code>&lt;/td>
 &lt;td>ResizeObserver 量測寫回&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>混搭：前三個寫死、第四個量測。&lt;/p>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>當前情境穩定 — pagefind scale 鎖在 1.0、theme h1 高度可預測。但若：&lt;/p>
&lt;ul>
&lt;li>Theme 升級改 h1 line-height → 寫死 64px 不準&lt;/li>
&lt;li>使用者裝置字型不同 → form 內容寬度變動可能間接影響高度&lt;/li>
&lt;li>pagefind 升級 input 高度算法 → 寫死 68px 不準&lt;/li>
&lt;/ul>
&lt;p>寫死值「假設某些條件成立」、條件變了寫死值就錯。&lt;/p>
&lt;h3 id="執行兩種統一方向">執行：兩種統一方向&lt;/h3>
&lt;h4 id="方向-1全部寫死鎖更多渲染條件">方向 1：全部寫死、鎖更多渲染條件&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="nt">body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nc">page-search&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-title-h&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">64&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-form-h&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">68&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-gap&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">20&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-scope-h&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">56&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c">/* 不再 JS 量測 */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nv">--pagefind-ui-scale&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">1.0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-title&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">height&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">h&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">line-height&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">h&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">margin&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c">/* 鎖 H1 margin */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-scope&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">height&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">h&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c">/* 鎖 scope 高度、超過裁掉 */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="k">overflow&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">hidden&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>代價：scope 內容超過時被裁、UI 可能不適合動態內容。&lt;/p>
&lt;h4 id="方向-2全部量測resizeobserver-同步所有">方向 2：全部量測、ResizeObserver 同步所有&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">measureAll&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">setVar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;--search-title-h&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">titleEl&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">offsetHeight&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">setVar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;--search-form-h&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">formEl&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">offsetHeight&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">setVar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;--search-scope-h&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">scopeEl&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">offsetHeight&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="c1">// gap 是 pagefind drawer 內建、無法從外部量測
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">setVar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">val&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">style&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setProperty&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">val&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s1">&amp;#39;px&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">[&lt;/span>&lt;span class="nx">titleEl&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">formEl&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">scopeEl&lt;/span>&lt;span class="p">].&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">new&lt;/span> &lt;span class="nx">ResizeObserver&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">measureAll&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">observe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>代價：JS 多了一層、初始載入時 fallback 值不對齊（直到 JS 跑完）。&lt;/p>
&lt;h3 id="推薦">推薦&lt;/h3>
&lt;p>&lt;strong>這個專案選方向 1（全寫死）&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>Pagefind scale 已能鎖定&lt;/li>
&lt;li>Theme 由本人控制、h1 變動可預期&lt;/li>
&lt;li>Scope UI 設計成單行、不需要動態高度&lt;/li>
&lt;/ul>
&lt;p>把當前 scope-h 從量測改寫死、移除 ResizeObserver。混搭問題消失。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>對齊基準上的尺寸值、要嘛統一寫死、要嘛統一 runtime 量測 — 不要混搭。</strong> 混搭時某些變化（字型替換、scale 改變、theme 切換）會打破對齊、且問題只在特定情境出現、難以重現。選一邊走到底。</p>
<hr>
<h2 id="為什麼混搭會不穩">為什麼混搭會不穩</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>對齊問題本質是「方程組」 — 每個變數的值都要正確、結果才對。</p>
<p>寫死值的特徵：</p>
<ul>
<li>來源是 build time 設計決定</li>
<li>變動需要手動改 CSS</li>
<li>假設某個渲染條件成立（特定字型、特定 scale）</li>
</ul>
<p>量測值的特徵：</p>
<ul>
<li>來源是 runtime DOM 量測</li>
<li>自動跟著實際渲染走</li>
<li>不依賴特定渲染條件</li>
</ul>
<p>混搭時的失敗模式：寫死值依賴的渲染條件變了、但量測值跟著變、寫死值沒跟 — 兩者錯位、對齊壞掉。</p>
<h3 id="統一往一邊靠的選擇">統一往一邊靠的選擇</h3>
<table>
  <thead>
      <tr>
          <th>統一策略</th>
          <th>適合</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>全部寫死（鎖渲染條件）</td>
          <td>設計 token 穩定、組件提供 scale hook 可鎖定</td>
      </tr>
      <tr>
          <td>全部量測（runtime 同步）</td>
          <td>內容動態、字型 / 排版可能變動</td>
      </tr>
  </tbody>
</table>
<p>選擇看「願意接受多少不確定性」 — 全寫死要鎖很多條件、全量測要寫多個 ResizeObserver。</p>
<hr>
<h2 id="這次任務的混搭問題">這次任務的混搭問題</h2>
<h3 id="觀察">觀察</h3>
<p>對齊基準上四個值的處理：</p>
<table>
  <thead>
      <tr>
          <th>值</th>
          <th>來源</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>--search-title-h</code> (H1)</td>
          <td>寫死 64px</td>
      </tr>
      <tr>
          <td><code>--search-form-h</code> (input)</td>
          <td>寫死 68px、靠 <code>--pagefind-ui-scale: 1.0</code> 鎖定</td>
      </tr>
      <tr>
          <td><code>--search-gap</code> (drawer 上方)</td>
          <td>寫死 20px</td>
      </tr>
      <tr>
          <td><code>--search-scope-h</code></td>
          <td>ResizeObserver 量測寫回</td>
      </tr>
  </tbody>
</table>
<p>混搭：前三個寫死、第四個量測。</p>
<h3 id="判讀">判讀</h3>
<p>當前情境穩定 — pagefind scale 鎖在 1.0、theme h1 高度可預測。但若：</p>
<ul>
<li>Theme 升級改 h1 line-height → 寫死 64px 不準</li>
<li>使用者裝置字型不同 → form 內容寬度變動可能間接影響高度</li>
<li>pagefind 升級 input 高度算法 → 寫死 68px 不準</li>
</ul>
<p>寫死值「假設某些條件成立」、條件變了寫死值就錯。</p>
<h3 id="執行兩種統一方向">執行：兩種統一方向</h3>
<h4 id="方向-1全部寫死鎖更多渲染條件">方向 1：全部寫死、鎖更多渲染條件</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">body</span><span class="p">.</span><span class="nc">page-search</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nv">--search-title-h</span><span class="p">:</span> <span class="mi">64</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nv">--search-form-h</span><span class="p">:</span> <span class="mi">68</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nv">--search-gap</span><span class="p">:</span> <span class="mi">20</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nv">--search-scope-h</span><span class="p">:</span> <span class="mi">56</span><span class="kt">px</span><span class="p">;</span>            <span class="c">/* 不再 JS 量測 */</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="nv">--pagefind-ui-scale</span><span class="p">:</span> <span class="mf">1.0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">.</span><span class="nc">search-title</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="k">height</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">search</span><span class="o">-</span><span class="n">title</span><span class="o">-</span><span class="n">h</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="k">line-height</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">search</span><span class="o">-</span><span class="n">title</span><span class="o">-</span><span class="n">h</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="k">margin</span><span class="p">:</span> <span class="mi">0</span><span class="p">;</span>                         <span class="c">/* 鎖 H1 margin */</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">.</span><span class="nc">search-scope</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="k">height</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">search</span><span class="o">-</span><span class="n">scope</span><span class="o">-</span><span class="n">h</span><span class="p">);</span>     <span class="c">/* 鎖 scope 高度、超過裁掉 */</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="k">overflow</span><span class="p">:</span> <span class="kc">hidden</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>代價：scope 內容超過時被裁、UI 可能不適合動態內容。</p>
<h4 id="方向-2全部量測resizeobserver-同步所有">方向 2：全部量測、ResizeObserver 同步所有</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">measureAll</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nx">setVar</span><span class="p">(</span><span class="s1">&#39;--search-title-h&#39;</span><span class="p">,</span> <span class="nx">titleEl</span><span class="p">.</span><span class="nx">offsetHeight</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">setVar</span><span class="p">(</span><span class="s1">&#39;--search-form-h&#39;</span><span class="p">,</span> <span class="nx">formEl</span><span class="p">.</span><span class="nx">offsetHeight</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nx">setVar</span><span class="p">(</span><span class="s1">&#39;--search-scope-h&#39;</span><span class="p">,</span> <span class="nx">scopeEl</span><span class="p">.</span><span class="nx">offsetHeight</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="c1">// gap 是 pagefind drawer 內建、無法從外部量測
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kd">function</span> <span class="nx">setVar</span><span class="p">(</span><span class="nx">name</span><span class="p">,</span> <span class="nx">val</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="nx">name</span><span class="p">,</span> <span class="nx">val</span> <span class="o">+</span> <span class="s1">&#39;px&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">[</span><span class="nx">titleEl</span><span class="p">,</span> <span class="nx">formEl</span><span class="p">,</span> <span class="nx">scopeEl</span><span class="p">].</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="k">new</span> <span class="nx">ResizeObserver</span><span class="p">(</span><span class="nx">measureAll</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">el</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>代價：JS 多了一層、初始載入時 fallback 值不對齊（直到 JS 跑完）。</p>
<h3 id="推薦">推薦</h3>
<p><strong>這個專案選方向 1（全寫死）</strong>：</p>
<ul>
<li>Pagefind scale 已能鎖定</li>
<li>Theme 由本人控制、h1 變動可預期</li>
<li>Scope UI 設計成單行、不需要動態高度</li>
</ul>
<p>把當前 scope-h 從量測改寫死、移除 ResizeObserver。混搭問題消失。</p>
<hr>
<h2 id="內在屬性比較四種對齊值來源策略">內在屬性比較：四種對齊值來源策略</h2>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>穩定性</th>
          <th>維護成本</th>
          <th>對動態內容適應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>全寫死 + 鎖渲染條件</td>
          <td>高 — 條件鎖死後值穩定</td>
          <td>低 — 純 CSS</td>
          <td>低 — 動態內容超過值會裁</td>
      </tr>
      <tr>
          <td>全量測 ResizeObserver</td>
          <td>高 — 值跟著實際走</td>
          <td>中 — JS 多一層</td>
          <td>高</td>
      </tr>
      <tr>
          <td>混搭（部分寫死、部分量測）</td>
          <td>中 — 邊界 case 壞</td>
          <td>中</td>
          <td>中</td>
      </tr>
      <tr>
          <td>Magic number 估算</td>
          <td>低</td>
          <td>不適用</td>
          <td>低</td>
      </tr>
  </tbody>
</table>
<p>選擇順序：<strong>內容靜態 → 全寫死；內容動態 → 全量測；不要混搭</strong>。</p>
<hr>
<h2 id="鎖定渲染條件的具體技巧">鎖定渲染條件的具體技巧</h2>
<h3 id="1-使用組件提供的-scale-hook">1. 使用組件提供的 scale hook</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span> <span class="p">{</span> <span class="nv">--pagefind-ui-scale</span><span class="p">:</span> <span class="mf">1.0</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>讓組件按我們指定的 scale 渲染、寫死值才有意義。</p>
<h3 id="2-寫死-h1-height--line-height--margin">2. 寫死 H1 height + line-height + margin</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">search-title</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">height</span><span class="p">:</span> <span class="mi">64</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">line-height</span><span class="p">:</span> <span class="mi">64</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">margin</span><span class="p">:</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="c">/* 確保 box height 永遠是 64、不受 font / padding 影響 */</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>不留任何「看 box-sizing 與 inheritance 決定」的空間。</p>
<h3 id="3-用-box-sizing-border-box-確保-padding-不影響-box-height">3. 用 box-sizing: border-box 確保 padding 不影響 box height</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">search-scope</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">box-sizing</span><span class="p">:</span> <span class="kc">border-box</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">height</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">search</span><span class="o">-</span><span class="n">scope</span><span class="o">-</span><span class="n">h</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">padding</span><span class="p">:</span> <span class="mi">8</span><span class="kt">px</span> <span class="mi">16</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="c">/* total height 還是 var(--search-scope-h)、padding 算在內 */</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><hr>
<h2 id="設計取捨對齊基準上來源機制的選擇">設計取捨：對齊基準上來源機制的選擇</h2>
<p>四種做法、各自機會成本不同。預設依內容性質選 — 內容靜態 → A、內容動態 → B、混搭 / 估算永遠不是答案。</p>
<blockquote>
<p>本篇是 <a href="../single-source-of-truth/">#44 SSoT</a> 抽象原則在「來源機制統一」這個面向的應用。</p></blockquote>
<h3 id="a全寫死--鎖渲染條件內容靜態的預設">A：全寫死 + 鎖渲染條件（內容靜態的預設）</h3>
<ul>
<li><strong>機制</strong>：所有對齊基準值用 CSS 變數寫死、同時鎖定相關渲染條件（pagefind scale、H1 line-height、box-sizing）</li>
<li><strong>選 A 的理由</strong>：純 CSS 不依 JS、值 build time 確定、改 token 自動跟上</li>
<li><strong>適合</strong>：對齊內容靜態、可預測（設計穩定的搜尋頁、文章頁）</li>
<li><strong>代價</strong>：需要鎖很多渲染條件（scale / line-height / box-sizing 等）、scope 內容超過寫死值會被裁</li>
</ul>
<h3 id="b全量測-resizeobserver-寫回變數內容動態的預設">B：全量測 ResizeObserver 寫回變數（內容動態的預設）</h3>
<ul>
<li><strong>機制</strong>：所有對齊基準值用 ResizeObserver 量、寫回 CSS 變數、其他元素引用</li>
<li><strong>跟 A 的取捨</strong>：B 自動跟著實際渲染、A 假設條件穩定；B 多 JS 一層、初始 fallback 值不對齊（直到 JS 跑完）</li>
<li><strong>B 比 A 好的情境</strong>：內容動態（字型可能變、theme 切換、跨環境部署）</li>
</ul>
<h3 id="c混搭部分寫死部分量測">C：混搭（部分寫死、部分量測）</h3>
<ul>
<li><strong>機制</strong>：「主要值寫死、邊界值量測」混合策略</li>
<li><strong>C 是反模式</strong>：邊界情境（字型變、scale 變、theme 切換）下兩者錯位、對齊在某些 case 壞、難以重現</li>
<li><strong>看起來吸引人的原因</strong>：「主要情境寫死、邊界情境量測」聽起來合理、實際「主要 vs 邊界」判斷不可靠</li>
<li><strong>實際發生的代價</strong>：邊界常常變主要、混搭策略下 debug 範圍從「某情境」擴張到「整套是不是錯」</li>
</ul>
<h3 id="dmagic-number-估算">D：Magic number 估算</h3>
<ul>
<li><strong>機制</strong>：執行者依感覺給數字、不寫變數、不量測</li>
<li><strong>D 是反模式</strong>：任何「沒來源」的值都會在邊界情境爆掉 — 跨情境（字型 / scale / theme）必壞</li>
<li><strong>看起來吸引人的原因</strong>：執行者依感覺給數字最快、不用 query 也不用 query DevTools</li>
<li><strong>實際發生的代價</strong>：估錯時錯誤被視覺接受、ship 後在邊界情境暴露、信任損失</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>Refactor 動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>對齊在某些字型 / 主題 / 縮放下壞掉</td>
          <td>找出依賴的渲染條件、鎖定或改量測</td>
      </tr>
      <tr>
          <td>改了某個 token 要去多處驗證對齊</td>
          <td>統一來源（全寫死 or 全量測）</td>
      </tr>
      <tr>
          <td>ResizeObserver 量了 A、B 卻寫死</td>
          <td>評估 B 是否也需要量、避免混搭</td>
      </tr>
      <tr>
          <td>寫死值跟實際渲染差距 &gt; 2px</td>
          <td>該值依賴的條件沒鎖、改量測或鎖條件</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：對齊問題的根因常常是「混搭」 — 用統一策略消除這個根因、debug 範圍從「某個情境」縮到「整套策略對嗎」。</p>
<p>混搭通常是便利驅動的結果（每處用最快的方式）、統一策略需要先對齊原則 — 同 <a href="../single-source-of-truth/">#44 SSOT</a> 跟 <a href="../ease-of-writing-vs-intent-alignment/">#67 便利 vs 對齊反相關</a>。</p>
]]></content:encoded></item><item><title>以 class toggle 取代 inline `display: none !important`</title><link>https://tarrragon.github.io/blog/report/class-toggle-over-important/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/class-toggle-over-important/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>JS 改 DOM 元素的視覺狀態、用 class toggle、不用 inline style.setProperty important hack。&lt;/strong> Class toggle 的好處：CSS 規則集中可讀、devtools 看到語意化的 class 名而非神秘 inline style、未來改視覺只動 CSS 不動 JS。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼-class-toggle-比-inline-style-好">為什麼 class toggle 比 inline style 好&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>兩種方式都能達成「JS 控制視覺」、差別在「視覺規則的家在哪」：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>方式&lt;/th>
 &lt;th>視覺規則住址&lt;/th>
 &lt;th>維護成本&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>el.style.display = 'none'&lt;/code>&lt;/td>
 &lt;td>散在 JS 各處&lt;/td>
 &lt;td>高 — 改視覺要找 JS、不在 CSS&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>el.classList.toggle('is-hidden')&lt;/code> + &lt;code>.is-hidden { display: none }&lt;/code>&lt;/td>
 &lt;td>集中在 CSS&lt;/td>
 &lt;td>低 — 改視覺改 CSS&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>集中在 CSS = 設計系統的單一來源、devtools Element 面板看 class 知道狀態、code review 容易理解。&lt;/p>
&lt;p>&lt;code>!important&lt;/code> 的需求消失：只要 CSS Layers 把 vendor CSS 包進低權層、自家 unlayered CSS 自然贏、不需要 important。&lt;/p>
&lt;h3 id="何時-inline-style-是必要的">何時 inline style 是必要的&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>inline style 必要&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>動態值（隨 runtime 計算）&lt;/td>
 &lt;td>是 — 如 &lt;code>el.style.top = scrollY + 'px'&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>動畫起點 / 終點&lt;/td>
 &lt;td>是 — &lt;code>el.style.transform = ...&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>切換 boolean 狀態（顯示/隱藏）&lt;/td>
 &lt;td>否 — 用 class toggle&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>套用設計系統一致樣式&lt;/td>
 &lt;td>否 — 用 class toggle&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「狀態切換」是 class toggle 的場景、不是 inline style 的場景。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的重構機會">這次任務的重構機會&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>Scope filter 用：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">items&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">show&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">scope&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s1">&amp;#39;title&amp;#39;&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="nx">re&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">title&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">:&lt;/span> &lt;span class="nx">re&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">excerpt&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">show&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">style&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">removeProperty&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;display&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">style&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setProperty&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;display&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;none&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;important&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>setProperty important&lt;/code> 是為了壓過 Svelte 重繪可能 reset 的 inline style。CSS Layers 之後、important 不再必要。&lt;/p>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>改用 class toggle + layered CSS：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c">/* assets/search.css，unlayered（pagefind 在 layer(pagefind) 內） */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">pagefind-ui__result&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nc">is-scope-filtered&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">display&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">none&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">items&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">show&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">scope&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s1">&amp;#39;title&amp;#39;&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="nx">re&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">title&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">:&lt;/span> &lt;span class="nx">re&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">excerpt&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">classList&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">toggle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;is-scope-filtered&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">!&lt;/span>&lt;span class="nx">show&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>更乾淨：&lt;/p>
&lt;ul>
&lt;li>CSS 規則集中&lt;/li>
&lt;li>DevTools Element 面板看到 &lt;code>.is-scope-filtered&lt;/code> 就知道為什麼隱藏&lt;/li>
&lt;li>JS 邏輯簡化（&lt;code>classList.toggle&lt;/code> 一行解兩種狀態）&lt;/li>
&lt;li>不需要 &lt;code>!important&lt;/code>&lt;/li>
&lt;/ul>
&lt;h3 id="執行-prerequisite">執行 prerequisite&lt;/h3>
&lt;p>要這個 refactor 生效、先做 #24（CSS Layers）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">@&lt;/span>&lt;span class="k">import&lt;/span> &lt;span class="nt">url&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="s2">&amp;#34;/blog/pagefind/pagefind-ui.css&amp;#34;&lt;/span>&lt;span class="o">)&lt;/span> &lt;span class="nt">layer&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nt">pagefind&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c">/* unlayered，自動勝過 pagefind 的所有 specificity */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">pagefind-ui__result&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nc">is-scope-filtered&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">display&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">none&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>否則 layered 的 pagefind CSS 可能用 specificity 30 蓋過 &lt;code>.is-scope-filtered&lt;/code>（specificity 20）。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>JS 改 DOM 元素的視覺狀態、用 class toggle、不用 inline style.setProperty important hack。</strong> Class toggle 的好處：CSS 規則集中可讀、devtools 看到語意化的 class 名而非神秘 inline style、未來改視覺只動 CSS 不動 JS。</p>
<hr>
<h2 id="為什麼-class-toggle-比-inline-style-好">為什麼 class toggle 比 inline style 好</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>兩種方式都能達成「JS 控制視覺」、差別在「視覺規則的家在哪」：</p>
<table>
  <thead>
      <tr>
          <th>方式</th>
          <th>視覺規則住址</th>
          <th>維護成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>el.style.display = 'none'</code></td>
          <td>散在 JS 各處</td>
          <td>高 — 改視覺要找 JS、不在 CSS</td>
      </tr>
      <tr>
          <td><code>el.classList.toggle('is-hidden')</code> + <code>.is-hidden { display: none }</code></td>
          <td>集中在 CSS</td>
          <td>低 — 改視覺改 CSS</td>
      </tr>
  </tbody>
</table>
<p>集中在 CSS = 設計系統的單一來源、devtools Element 面板看 class 知道狀態、code review 容易理解。</p>
<p><code>!important</code> 的需求消失：只要 CSS Layers 把 vendor CSS 包進低權層、自家 unlayered CSS 自然贏、不需要 important。</p>
<h3 id="何時-inline-style-是必要的">何時 inline style 是必要的</h3>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>inline style 必要</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>動態值（隨 runtime 計算）</td>
          <td>是 — 如 <code>el.style.top = scrollY + 'px'</code></td>
      </tr>
      <tr>
          <td>動畫起點 / 終點</td>
          <td>是 — <code>el.style.transform = ...</code></td>
      </tr>
      <tr>
          <td>切換 boolean 狀態（顯示/隱藏）</td>
          <td>否 — 用 class toggle</td>
      </tr>
      <tr>
          <td>套用設計系統一致樣式</td>
          <td>否 — 用 class toggle</td>
      </tr>
  </tbody>
</table>
<p>「狀態切換」是 class toggle 的場景、不是 inline style 的場景。</p>
<hr>
<h2 id="這次任務的重構機會">這次任務的重構機會</h2>
<h3 id="觀察">觀察</h3>
<p>Scope filter 用：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">items</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">var</span> <span class="nx">show</span> <span class="o">=</span> <span class="nx">scope</span> <span class="o">===</span> <span class="s1">&#39;title&#39;</span> <span class="o">?</span> <span class="nx">re</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">title</span><span class="p">)</span> <span class="o">:</span> <span class="nx">re</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">excerpt</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">show</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">el</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">removeProperty</span><span class="p">(</span><span class="s1">&#39;display&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">el</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;display&#39;</span><span class="p">,</span> <span class="s1">&#39;none&#39;</span><span class="p">,</span> <span class="s1">&#39;important&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p><code>setProperty important</code> 是為了壓過 Svelte 重繪可能 reset 的 inline style。CSS Layers 之後、important 不再必要。</p>
<h3 id="判讀">判讀</h3>
<p>改用 class toggle + layered CSS：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">/* assets/search.css，unlayered（pagefind 在 layer(pagefind) 內） */</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">pagefind-ui__result</span><span class="p">.</span><span class="nc">is-scope-filtered</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">display</span><span class="p">:</span> <span class="kc">none</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">items</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">var</span> <span class="nx">show</span> <span class="o">=</span> <span class="nx">scope</span> <span class="o">===</span> <span class="s1">&#39;title&#39;</span> <span class="o">?</span> <span class="nx">re</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">title</span><span class="p">)</span> <span class="o">:</span> <span class="nx">re</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">excerpt</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">el</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nx">toggle</span><span class="p">(</span><span class="s1">&#39;is-scope-filtered&#39;</span><span class="p">,</span> <span class="o">!</span><span class="nx">show</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>更乾淨：</p>
<ul>
<li>CSS 規則集中</li>
<li>DevTools Element 面板看到 <code>.is-scope-filtered</code> 就知道為什麼隱藏</li>
<li>JS 邏輯簡化（<code>classList.toggle</code> 一行解兩種狀態）</li>
<li>不需要 <code>!important</code></li>
</ul>
<h3 id="執行-prerequisite">執行 prerequisite</h3>
<p>要這個 refactor 生效、先做 #24（CSS Layers）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">@</span><span class="k">import</span> <span class="nt">url</span><span class="o">(</span><span class="s2">&#34;/blog/pagefind/pagefind-ui.css&#34;</span><span class="o">)</span> <span class="nt">layer</span><span class="o">(</span><span class="nt">pagefind</span><span class="o">)</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c">/* unlayered，自動勝過 pagefind 的所有 specificity */</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">.</span><span class="nc">pagefind-ui__result</span><span class="p">.</span><span class="nc">is-scope-filtered</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">none</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>否則 layered 的 pagefind CSS 可能用 specificity 30 蓋過 <code>.is-scope-filtered</code>（specificity 20）。</p>
<hr>
<h2 id="內在屬性比較四種-js-控制視覺方式">內在屬性比較：四種 JS 控制視覺方式</h2>
<table>
  <thead>
      <tr>
          <th>方式</th>
          <th>維護成本</th>
          <th>DevTools 可讀性</th>
          <th>Important 需求</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>el.style.display = 'none'</code></td>
          <td>中 — 規則散在 JS</td>
          <td>中 — 看到 inline style</td>
          <td>否</td>
      </tr>
      <tr>
          <td><code>el.style.setProperty('display','none','important')</code></td>
          <td>高 — important 散在 JS</td>
          <td>中</td>
          <td>是 — 跟 framework 競爭</td>
      </tr>
      <tr>
          <td><code>el.classList.toggle('is-hidden')</code> + CSS</td>
          <td>低 — 規則在 CSS</td>
          <td>高 — 看 class 知狀態</td>
          <td>否（CSS Layers 環境下）</td>
      </tr>
      <tr>
          <td><code>el.dataset.state = 'hidden'</code> + <code>[data-state=hidden]</code> CSS</td>
          <td>低 — 規則在 CSS</td>
          <td>高 — attribute 也表達狀態</td>
          <td>否</td>
      </tr>
  </tbody>
</table>
<p>優先選 class toggle（或 dataset） — CSS-first、可讀、可維護。</p>
<hr>
<h2 id="class-toggle-的命名慣例">Class toggle 的命名慣例</h2>
<h3 id="用-is-x--has-x-表狀態">用 <code>is-X</code> / <code>has-X</code> 表狀態</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">is-scope-filtered</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">none</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">is-loading</span> <span class="p">{</span> <span class="k">opacity</span><span class="p">:</span> <span class="mf">0.5</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">.</span><span class="nc">has-error</span> <span class="p">{</span> <span class="k">border-color</span><span class="p">:</span> <span class="kc">red</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p><code>is-</code> / <code>has-</code> 前綴讓「狀態 class」跟「結構 class」（如 <code>.search-shell</code>）視覺區分、code review 一眼看出哪些是動態狀態。</p>
<h3 id="用-bem-modifier">用 BEM modifier</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">search-result--filtered</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">none</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>BEM 風格也可以、看專案 convention。重點是有規律、不要混雜。</p>
<hr>
<h2 id="devtools-可讀性的具體差異">DevTools 可讀性的具體差異</h2>
<h3 id="inline-style-視角">Inline style 視角</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;pagefind-ui__result svelte-j9e30&#34;</span> <span class="na">style</span><span class="o">=</span><span class="s">&#34;display: none !important;&#34;</span><span class="p">&gt;</span></span></span></code></pre></div><p>DevTools 顯示「inline style 設了 important」 — 但不知道為什麼。要 grep JS 找出哪段邏輯設的。</p>
<h3 id="class-toggle-視角">Class toggle 視角</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;pagefind-ui__result svelte-j9e30 is-scope-filtered&#34;</span><span class="p">&gt;</span></span></span></code></pre></div><p>DevTools 顯示「有 <code>.is-scope-filtered</code> class」 — class 名本身解釋為什麼隱藏。CSS 面板也直接顯示對應規則。</p>
<hr>
<h2 id="設計取捨js-控制視覺狀態的策略">設計取捨：JS 控制視覺狀態的策略</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（class toggle）當預設、其他做法在特定情境合理。</p>
<h3 id="aclass-toggle--css-規則這個專案的預設">A：Class toggle + CSS 規則（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：<code>el.classList.toggle('is-scope-filtered')</code>、CSS 內定義 <code>.is-scope-filtered { display: none }</code></li>
<li><strong>選 A 的理由</strong>：CSS 規則集中、devtools 看 class 知狀態、改視覺只動 CSS、配 CSS Layers 不需 <code>!important</code></li>
<li><strong>適合</strong>：布林狀態切換（顯示 / 隱藏 / 啟用 / 停用）</li>
<li><strong>代價</strong>：需要在 CSS 預先定義 class 規則（多一份 CSS）</li>
</ul>
<h3 id="binline-stylex--">B：Inline <code>style.X = ...</code></h3>
<ul>
<li><strong>機制</strong>：<code>el.style.display = 'none'</code></li>
<li><strong>跟 A 的取捨</strong>：B 一行 JS、A 需要 CSS 規則；但 B 規則散在 JS 各處、devtools 看到 <code>display: none</code> inline 不知道為什麼</li>
<li><strong>B 比 A 好的情境</strong>：runtime 計算的動態值（<code>el.style.top = scrollY + 'px'</code>）— 這類值無法預先寫進 CSS</li>
</ul>
<h3 id="cinline--setpropertyimportant">C：Inline + <code>setProperty('important')</code></h3>
<ul>
<li><strong>機制</strong>：<code>el.style.setProperty('display', 'none', 'important')</code></li>
<li><strong>跟 A/B 的取捨</strong>：C 比 B 多 important、為了壓過 framework 重繪 reset 的 inline；但 C 進入 <code>!important</code> 戰、未來新 important 對撞 debug 困難</li>
<li><strong>C 才合理的情境</strong>：framework 強制 reset 自家 inline style、且不能用 layered CSS（極罕見）</li>
<li><strong>更好的解</strong>：用 <a href="../css-layers-over-specificity/">#24 CSS Layers</a> 解 specificity 戰、本卡片 A 即可</li>
</ul>
<h3 id="ddataset-attribute--css-attribute-selector">D：Dataset attribute + CSS attribute selector</h3>
<ul>
<li><strong>機制</strong>：<code>el.dataset.state = 'hidden'</code>、CSS <code>[data-state=&quot;hidden&quot;] { display: none }</code></li>
<li><strong>跟 A 的取捨</strong>：D 用 attribute 表狀態、A 用 class；D 在「狀態值多種」時更合適（例如 <code>data-state=&quot;loading|ready|error&quot;</code>）</li>
<li><strong>D 比 A 好的情境</strong>：狀態有多個值（不只 boolean）、需要 CSS attribute selector 表達多分支</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>Refactor 動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>JS 用 <code>style.setProperty(..., 'important')</code></td>
          <td>改用 class toggle、用 CSS Layers 解決 specificity</td>
      </tr>
      <tr>
          <td><code>el.style.display = 'none'</code> 散落多處</td>
          <td>集中為 <code>.is-X</code> class、JS 只 toggle</td>
      </tr>
      <tr>
          <td>DevTools 看到 inline style 不知為什麼</td>
          <td>改用語意化 class、devtools 看 class 自帶解釋</td>
      </tr>
      <tr>
          <td>視覺改動要改 JS（不是 CSS）</td>
          <td>Refactor 為 class toggle、視覺改動只動 CSS</td>
      </tr>
      <tr>
          <td>改視覺需要對抗 framework reset</td>
          <td>用 CSS Layers 把 framework 規則降層、自家規則不需 important</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：CSS 是視覺規則的家、JS 控制狀態 - 兩者透過 class toggle 介面共處、不互相侵入。</p>
<p>Inline style + !important 是「立刻生效」的便利、class toggle 是「樣式留 CSS」的對齊 — 這個反相關的更高層原則見 <a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關</a>。</p>
]]></content:encoded></item><item><title>MutationObserver 範圍與觸發頻率：監聽最少必要的變動</title><link>https://tarrragon.github.io/blog/report/mutation-observer-scope/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/mutation-observer-scope/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>MutationObserver 監聽最少必要的變動 — 從「監聽哪個 root」「觀察什麼類型」「多久觸發一次」三維度收斂。&lt;/strong> 範圍寬會頻繁觸發、option 勾多會在不關心的變動上跑邏輯、apply 自己改 DOM 會引發無限循環。三維度都該顯式設計、不能只丟預設。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼-observer-需要獨立議題">為什麼 observer 需要獨立議題&lt;/h2>
&lt;h3 id="跟-selector-的差異">跟 selector 的差異&lt;/h3>
&lt;p>Observer 與 selector 都涉及「DOM 範圍」、機制完全不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Selector&lt;/th>
 &lt;th>Observer&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &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>隨 DOM 變動自動觸發&lt;/td>
 &lt;/tr>
 &lt;tr>
 &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>監聽範圍 + option + 頻率&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>把 selector 與 observer 綁同一篇討論會混淆 — 兩者解決的是不同問題、有不同失敗模式、需要不同的設計工具。&lt;/p>
&lt;h3 id="observer-寬範圍的失敗模式">Observer 寬範圍的失敗模式&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &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>subtree 太深 + option 太多&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>在錯時機跑&lt;/td>
 &lt;td>layout 還沒穩就跑 apply&lt;/td>
 &lt;td>沒等 framework patch 結束&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>無限循環&lt;/td>
 &lt;td>apply 自己改 DOM 又觸發 observer&lt;/td>
 &lt;td>沒 disconnect/observe 保護&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>漏掉變動&lt;/td>
 &lt;td>預期會觸發但沒觸發&lt;/td>
 &lt;td>option 沒勾對、或 root 選錯&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>四種都來自「沒精細設計 observer 的監聽形狀」。&lt;/p>
&lt;hr>
&lt;h2 id="三維度收斂">三維度收斂&lt;/h2>
&lt;h3 id="維度-1監聽哪個-root範圍">維度 1：監聽哪個 root（範圍）&lt;/h3>
&lt;p>&lt;strong>核心定義&lt;/strong>：observer 的 root 元素決定「哪些範圍內的變動會被看到」。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 寬：監聽整個 .pagefind-ui
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">new&lt;/span> &lt;span class="nx">MutationObserver&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">apply&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">observe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ui&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">childList&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">subtree&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">// 收斂：只監聽結果列表
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">var&lt;/span> &lt;span class="nx">results&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__results&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="k">new&lt;/span> &lt;span class="nx">MutationObserver&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">apply&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">observe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">results&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">childList&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span> &lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>寬範圍把無關變動也帶進來 — pagefind 重繪 input、調整 filter、重排 chip 都會觸發 apply、但 apply 只關心結果變動。&lt;/p>
&lt;p>&lt;strong>Root 選擇的決策&lt;/strong>：找到「&lt;strong>包含所有目標變動、但不包含其他無關變動&lt;/strong>的最小元素」。&lt;/p>
&lt;ul>
&lt;li>太大 → 帶進無關變動、過度觸發&lt;/li>
&lt;li>太小 → 漏掉真正關心的變動&lt;/li>
&lt;li>剛好 → 只關心的變動觸發&lt;/li>
&lt;/ul>
&lt;p>問自己：「我關心的變動發生在哪些元素？這些元素的最小共同 ancestor 是誰？」答案就是 observer root。&lt;/p>
&lt;h3 id="維度-2觀察什麼類型option-flag">維度 2：觀察什麼類型（option flag）&lt;/h3>
&lt;p>&lt;strong>核心定義&lt;/strong>：MutationObserver 提供四種 option、每種對應不同類型變動：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">childList&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 子節點增 / 減 / 重排
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">attributes&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 屬性變動
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">attributeFilter&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;data-state&amp;#39;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="c1">// 只看特定屬性
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">characterData&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 文字內容變動
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">subtree&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 上面三種往子樹深處看
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">attributeOldValue&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 屬性變動時記錄舊值
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">characterDataOldValue&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>預設只勾需要的、不要全部 true：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Option&lt;/th>
 &lt;th>用途&lt;/th>
 &lt;th>觸發頻率&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>childList: true&lt;/code>&lt;/td>
 &lt;td>子節點增減&lt;/td>
 &lt;td>中&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>childList + subtree&lt;/code>&lt;/td>
 &lt;td>任何深度的子節點增減&lt;/td>
 &lt;td>高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>attributes&lt;/code> 全屬性&lt;/td>
 &lt;td>任何屬性變動&lt;/td>
 &lt;td>最高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>attributes + attributeFilter&lt;/code>&lt;/td>
 &lt;td>只特定屬性&lt;/td>
 &lt;td>低&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>characterData&lt;/code>&lt;/td>
 &lt;td>文字內容（少用）&lt;/td>
 &lt;td>低&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>避免勾 subtree&lt;/strong>：subtree 把監聽從「直接子」擴展到「整個子樹」、觸發頻率可能爆炸。只在「真的需要看深層變動」時用。&lt;/p>
&lt;p>&lt;strong>避免無 filter 的 attributes&lt;/strong>：DOM 屬性變動很頻繁（class 改、style 改、aria-* 改），不過濾會被淹沒。用 &lt;code>attributeFilter: [...]&lt;/code> 縮到只看你關心的屬性。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>MutationObserver 監聽最少必要的變動 — 從「監聽哪個 root」「觀察什麼類型」「多久觸發一次」三維度收斂。</strong> 範圍寬會頻繁觸發、option 勾多會在不關心的變動上跑邏輯、apply 自己改 DOM 會引發無限循環。三維度都該顯式設計、不能只丟預設。</p>
<hr>
<h2 id="為什麼-observer-需要獨立議題">為什麼 observer 需要獨立議題</h2>
<h3 id="跟-selector-的差異">跟 selector 的差異</h3>
<p>Observer 與 selector 都涉及「DOM 範圍」、機制完全不同：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Selector</th>
          <th>Observer</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>時機</td>
          <td>同步、當下查詢</td>
          <td>非同步、回應未來變動</td>
      </tr>
      <tr>
          <td>執行頻率</td>
          <td>一次或顯式重呼叫</td>
          <td>隨 DOM 變動自動觸發</td>
      </tr>
      <tr>
          <td>失敗模式</td>
          <td>撈太多 / 撈太少</td>
          <td>觸發太頻繁 / 漏觸發 / 無限循環</td>
      </tr>
      <tr>
          <td>設計重點</td>
          <td>起點 + 範圍 + 過濾</td>
          <td>監聽範圍 + option + 頻率</td>
      </tr>
  </tbody>
</table>
<p>把 selector 與 observer 綁同一篇討論會混淆 — 兩者解決的是不同問題、有不同失敗模式、需要不同的設計工具。</p>
<h3 id="observer-寬範圍的失敗模式">Observer 寬範圍的失敗模式</h3>
<table>
  <thead>
      <tr>
          <th>失敗模式</th>
          <th>表現</th>
          <th>根因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>過度觸發</td>
          <td>短時間觸發數十次</td>
          <td>subtree 太深 + option 太多</td>
      </tr>
      <tr>
          <td>在錯時機跑</td>
          <td>layout 還沒穩就跑 apply</td>
          <td>沒等 framework patch 結束</td>
      </tr>
      <tr>
          <td>無限循環</td>
          <td>apply 自己改 DOM 又觸發 observer</td>
          <td>沒 disconnect/observe 保護</td>
      </tr>
      <tr>
          <td>漏掉變動</td>
          <td>預期會觸發但沒觸發</td>
          <td>option 沒勾對、或 root 選錯</td>
      </tr>
  </tbody>
</table>
<p>四種都來自「沒精細設計 observer 的監聽形狀」。</p>
<hr>
<h2 id="三維度收斂">三維度收斂</h2>
<h3 id="維度-1監聽哪個-root範圍">維度 1：監聽哪個 root（範圍）</h3>
<p><strong>核心定義</strong>：observer 的 root 元素決定「哪些範圍內的變動會被看到」。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 寬：監聽整個 .pagefind-ui
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="nx">apply</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">ui</span><span class="p">,</span> <span class="p">{</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nx">subtree</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// 收斂：只監聽結果列表
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="kd">var</span> <span class="nx">results</span> <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__results&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="nx">apply</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">results</span><span class="p">,</span> <span class="p">{</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span></span></span></code></pre></div><p>寬範圍把無關變動也帶進來 — pagefind 重繪 input、調整 filter、重排 chip 都會觸發 apply、但 apply 只關心結果變動。</p>
<p><strong>Root 選擇的決策</strong>：找到「<strong>包含所有目標變動、但不包含其他無關變動</strong>的最小元素」。</p>
<ul>
<li>太大 → 帶進無關變動、過度觸發</li>
<li>太小 → 漏掉真正關心的變動</li>
<li>剛好 → 只關心的變動觸發</li>
</ul>
<p>問自己：「我關心的變動發生在哪些元素？這些元素的最小共同 ancestor 是誰？」答案就是 observer root。</p>
<h3 id="維度-2觀察什麼類型option-flag">維度 2：觀察什麼類型（option flag）</h3>
<p><strong>核心定義</strong>：MutationObserver 提供四種 option、每種對應不同類型變動：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span>        <span class="c1">// 子節點增 / 減 / 重排
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="nx">attributes</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span>       <span class="c1">// 屬性變動
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="nx">attributeFilter</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;data-state&#39;</span><span class="p">],</span>  <span class="c1">// 只看特定屬性
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>  <span class="nx">characterData</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span>    <span class="c1">// 文字內容變動
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>  <span class="nx">subtree</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span>          <span class="c1">// 上面三種往子樹深處看
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span>  <span class="nx">attributeOldValue</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span>  <span class="c1">// 屬性變動時記錄舊值
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span>  <span class="nx">characterDataOldValue</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>預設只勾需要的、不要全部 true：</p>
<table>
  <thead>
      <tr>
          <th>Option</th>
          <th>用途</th>
          <th>觸發頻率</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>childList: true</code></td>
          <td>子節點增減</td>
          <td>中</td>
      </tr>
      <tr>
          <td><code>childList + subtree</code></td>
          <td>任何深度的子節點增減</td>
          <td>高</td>
      </tr>
      <tr>
          <td><code>attributes</code> 全屬性</td>
          <td>任何屬性變動</td>
          <td>最高</td>
      </tr>
      <tr>
          <td><code>attributes + attributeFilter</code></td>
          <td>只特定屬性</td>
          <td>低</td>
      </tr>
      <tr>
          <td><code>characterData</code></td>
          <td>文字內容（少用）</td>
          <td>低</td>
      </tr>
  </tbody>
</table>
<p><strong>避免勾 subtree</strong>：subtree 把監聽從「直接子」擴展到「整個子樹」、觸發頻率可能爆炸。只在「真的需要看深層變動」時用。</p>
<p><strong>避免無 filter 的 attributes</strong>：DOM 屬性變動很頻繁（class 改、style 改、aria-* 改），不過濾會被淹沒。用 <code>attributeFilter: [...]</code> 縮到只看你關心的屬性。</p>
<h3 id="維度-3多久觸發一次頻率">維度 3：多久觸發一次（頻率）</h3>
<p><strong>核心定義</strong>：observer 的回呼可能短時間內被連續呼叫、用 debounce 把多次合併成一次。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">timer</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">function</span> <span class="nx">schedule</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">clearTimeout</span><span class="p">(</span><span class="nx">timer</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">timer</span> <span class="o">=</span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">apply</span><span class="p">,</span> <span class="mi">80</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="nx">schedule</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">root</span><span class="p">,</span> <span class="p">{</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span></span></span></code></pre></div><p>Debounce 80ms 表示「最後一次變動後 80ms 沒再變、才跑 apply」 — 把連續變動合併。</p>
<p><strong>Debounce vs Throttle</strong>：</p>
<table>
  <thead>
      <tr>
          <th>機制</th>
          <th>行為</th>
          <th>適合</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Debounce</td>
          <td>安靜後執行</td>
          <td>等 framework 連續 patch 結束</td>
      </tr>
      <tr>
          <td>Throttle</td>
          <td>固定頻率執行</td>
          <td>UI 同步要立即反應、但限速</td>
      </tr>
      <tr>
          <td>立即執行</td>
          <td>每次都跑</td>
          <td>變動頻率本來就低、且每次都要處理</td>
      </tr>
  </tbody>
</table>
<p>大部分 observer 場景適合 debounce — framework patch 是突發性、不是持續的。</p>
<p><strong>Debounce 時間選擇</strong>：</p>
<table>
  <thead>
      <tr>
          <th>時間</th>
          <th>適合</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>16ms（一個 frame）</td>
          <td>跟 paint 同步、最即時</td>
      </tr>
      <tr>
          <td>50-100ms</td>
          <td>一般 UI 反應、肉眼感受不到延遲</td>
      </tr>
      <tr>
          <td>200-300ms</td>
          <td>等使用者輸入結束</td>
      </tr>
      <tr>
          <td>1000ms+</td>
          <td>後台處理、不影響 UI</td>
      </tr>
  </tbody>
</table>
<p>預設 50-100ms — 比一個 frame 寬、又不會讓使用者感受延遲。</p>
<hr>
<h2 id="self-mutation-循環的處理">Self-mutation 循環的處理</h2>
<h3 id="問題場景">問題場景</h3>
<p>apply 函式自己也改 DOM 時、會再次觸發 observer：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">apply</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="c1">// 改了某個元素的 class（attribute 變動）
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="nx">someEl</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nx">add</span><span class="p">(</span><span class="s1">&#39;processed&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="nx">apply</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">root</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nx">attributes</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nx">subtree</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">// → apply 改 class 觸發 observer → observer 又呼叫 apply → 無限循環
</span></span></span></code></pre></div><p><strong>這不是邏輯錯、是 observer 機制的特性</strong>：observer 不會區分「是不是 apply 自己改的」。</p>
<h3 id="解法disconnect--observe-配對">解法：disconnect / observe 配對</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">observer</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="kd">function</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">observer</span><span class="p">.</span><span class="nx">disconnect</span><span class="p">();</span>      <span class="c1">// 暫停監聽
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="nx">apply</span><span class="p">();</span>                    <span class="c1">// 自己改 DOM 不會觸發
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="nx">observer</span><span class="p">.</span><span class="nx">observe</span><span class="p">(</span><span class="nx">root</span><span class="p">,</span> <span class="nx">options</span><span class="p">);</span>  <span class="c1">// 恢復監聽
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="p">});</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">observer</span><span class="p">.</span><span class="nx">observe</span><span class="p">(</span><span class="nx">root</span><span class="p">,</span> <span class="nx">options</span><span class="p">);</span></span></span></code></pre></div><p>apply 期間 observer 暫停、apply 結束後恢復 — 自己的改動不會觸發自己。</p>
<h3 id="解法替代用-attribute-標記區分">解法替代：用 attribute 標記區分</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">apply</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">isApplying</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">someEl</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nx">add</span><span class="p">(</span><span class="s1">&#39;processed&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">isApplying</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="kd">function</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">isApplying</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="nx">apply</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">root</span><span class="p">,</span> <span class="nx">options</span><span class="p">);</span></span></span></code></pre></div><p>但這個解法有時序風險 — observer 是非同步、<code>isApplying</code> 可能在錯時間被讀。<strong>disconnect/observe 配對更穩</strong>。</p>
<h3 id="解法替代root-與目標分離">解法替代：root 與目標分離</h3>
<p>如果 apply 改的是 A、observer 監聽的是 B（A 跟 B 沒交集），自然不循環：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="nx">apply</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">resultsEl</span><span class="p">,</span> <span class="p">{</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">function</span> <span class="nx">apply</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="c1">// 改的是 input 而不是 results — 不會觸發 observer
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="nx">inputEl</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="s1">&#39;...&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>設計時讓 observer 看的範圍跟 apply 改的範圍<strong>結構上分離</strong> — 是最乾淨的解法、不需要 disconnect 配對。</p>
<hr>
<h2 id="觀察的時機問題">觀察的時機問題</h2>
<h3 id="observer-跟-framework-渲染週期競爭">Observer 跟 framework 渲染週期競爭</h3>
<p>Observer 在 framework 連續 patch 中段觸發、可能在 layout 還沒穩時就跑 apply、造成短暫視覺錯位：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// framework 連續 patch：
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">//   patch 1 → observer 觸發 → apply 跑 → 視覺 A
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">//   patch 2 → observer 觸發 → apply 跑 → 視覺 B
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">//   patch 3 → observer 觸發 → apply 跑 → 視覺 C（最終）
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">// 使用者看到 A → B → C 的閃爍
</span></span></span></code></pre></div><p>Debounce 是這個問題的解 — 讓 observer 等 patch 完成才跑 apply。</p>
<h3 id="確認時機正確">確認時機正確</h3>
<p>寫 observer 時自問：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>答案決定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Apply 跑的時候 layout 是否已穩定？</td>
          <td>是否需要 debounce</td>
      </tr>
      <tr>
          <td>Apply 自己改 DOM 嗎？</td>
          <td>是否需要 disconnect 配對</td>
      </tr>
      <tr>
          <td>我關心的變動類型是什麼？</td>
          <td>option flag 怎麼勾</td>
      </tr>
      <tr>
          <td>變動發生在哪一層？</td>
          <td>是否需要 subtree</td>
      </tr>
      <tr>
          <td>Framework 的渲染週期會干擾嗎？</td>
          <td>debounce 時間取多久</td>
      </tr>
  </tbody>
</table>
<p>每個問題都該有顯式答案、不能丟預設。</p>
<hr>
<h2 id="內在屬性比較四種-observer-設計">內在屬性比較：四種 observer 設計</h2>
<table>
  <thead>
      <tr>
          <th>設計</th>
          <th>觸發頻率</th>
          <th>Layout 穩定性</th>
          <th>維護成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>全勾 + subtree + 無 debounce</td>
          <td>最高</td>
          <td>低 — patch 中段觸發</td>
          <td>低（短期）/ 高（debug 噩夢）</td>
      </tr>
      <tr>
          <td>收斂 root + 必要 option + 無 debounce</td>
          <td>中</td>
          <td>中</td>
          <td>中</td>
      </tr>
      <tr>
          <td>收斂 root + 必要 option + debounce</td>
          <td>低</td>
          <td>高</td>
          <td>中</td>
      </tr>
      <tr>
          <td>結構分離 + 收斂 + debounce</td>
          <td>最低</td>
          <td>最高</td>
          <td>中（前期設計成本）</td>
      </tr>
  </tbody>
</table>
<p><strong>推薦</strong>：收斂 root + 必要 option + debounce。<code>apply</code> 不改 DOM 時不需要 disconnect；改的話用結構分離優先、退而求其次用 disconnect。</p>
<hr>
<h2 id="進階技巧">進階技巧</h2>
<h3 id="1-動態調整-observer-範圍">1. 動態調整 observer 範圍</h3>
<p>當監聽目標可能還沒 mount 時、用兩階段 observer：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 階段 1：等目標 mount
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kd">var</span> <span class="nx">bootstrap</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="kd">function</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kd">var</span> <span class="nx">target</span> <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__results&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">target</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nx">bootstrap</span><span class="p">.</span><span class="nx">disconnect</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="c1">// 階段 2：mount 後監聽目標
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>  <span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="nx">apply</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">target</span><span class="p">,</span> <span class="p">{</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="nx">bootstrap</span><span class="p">.</span><span class="nx">observe</span><span class="p">(</span><span class="nx">shell</span><span class="p">,</span> <span class="p">{</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nx">subtree</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span></span></span></code></pre></div><p>階段 1 用寬範圍找到目標、階段 2 切到精準範圍 — 把寬範圍的觸發限制在「找目標」這個短時間。</p>
<h3 id="2-用-takerecords-主動取出累積變動">2. 用 <code>takeRecords</code> 主動取出累積變動</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">observer</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="kd">function</span> <span class="p">()</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">observer</span><span class="p">.</span><span class="nx">observe</span><span class="p">(</span><span class="nx">root</span><span class="p">,</span> <span class="nx">options</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// 之後某時間點、想立刻處理累積的變動
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="kd">var</span> <span class="nx">records</span> <span class="o">=</span> <span class="nx">observer</span><span class="p">.</span><span class="nx">takeRecords</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">processRecords</span><span class="p">(</span><span class="nx">records</span><span class="p">);</span></span></span></code></pre></div><p><code>takeRecords</code> 取出尚未觸發回呼的變動記錄、主動處理 — 適合「我想在某時間點同步處理累積變動」場景。</p>
<h3 id="3-多-observer-各管一塊">3. 多 observer 各管一塊</h3>
<p>不要用一個 observer 監聽全部、各分一個：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="nx">applyA</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">elA</span><span class="p">,</span> <span class="p">{</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="nx">applyB</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">elB</span><span class="p">,</span> <span class="p">{</span> <span class="nx">attributes</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span></span></span></code></pre></div><p>各自獨立 — 一個 observer 出錯不影響另一個、debug 範圍小、option 各自最佳化。</p>
<hr>
<h2 id="設計取捨mutationobserver-的設計策略">設計取捨：MutationObserver 的設計策略</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（收斂 root + 必要 option + debounce）當預設、其他做法在特定情境合理。</p>
<h3 id="a收斂-root--必要-option--debounce--結構分離這個專案的預設">A：收斂 root + 必要 option + debounce + 結構分離（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：root 取最小共同 ancestor、option 只勾真正關心的變動、加 50-100ms debounce、apply 改的範圍跟 observer 看的範圍結構上分離</li>
<li><strong>選 A 的理由</strong>：觸發頻率最低、layout 穩定、無 self-mutation 循環風險</li>
<li><strong>適合</strong>：絕大多數 observer 設計</li>
<li><strong>代價</strong>：前期設計成本中（要思考 root / option / 結構）</li>
</ul>
<h3 id="b收斂-root--必要-option無-debounce">B：收斂 root + 必要 option（無 debounce）</h3>
<ul>
<li><strong>機制</strong>：縮範圍與 option、但不加 debounce</li>
<li><strong>跟 A 的取捨</strong>：B 即時反應、A 等 debounce；但 B 在 framework patch 中段觸發、layout 不穩時跑 apply 結果不可靠</li>
<li><strong>B 比 A 好的情境</strong>：apply 不依賴 layout（純改 attribute、不讀 bounding rect）</li>
</ul>
<h3 id="c寬範圍--subtree--全勾-option預設配置">C：寬範圍 + subtree + 全勾 option（預設配置）</h3>
<ul>
<li><strong>機制</strong>：observe(elem, { childList: true, subtree: true, attributes: true, &hellip;})</li>
<li><strong>C 是反模式</strong>：「以防萬一全勾」會觸發數十倍頻率的 callback、framework 環境必撞效能 / 競態 bug</li>
<li><strong>看起來吸引人的原因</strong>：寫法簡單、不用想要監聽什麼、「全部都看就不會漏」</li>
<li><strong>實際發生的代價</strong>：CPU 100%、layout thrashing、self-mutation 引發無限迴圈</li>
</ul>
<h3 id="ddisconnect--observe-配對處理-self-mutation">D：disconnect / observe 配對處理 self-mutation</h3>
<ul>
<li><strong>機制</strong>：apply 前 disconnect、apply 後 reconnect</li>
<li><strong>跟 A（結構分離）的取捨</strong>：D 處理 callback 必須改 observer 監聽範圍的情境、A 從設計上避免；A 更乾淨</li>
<li><strong>D 比 A 好的情境</strong>：無法做結構分離（apply 必須改 observer 看的範圍）— 唯一情境</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>Observer 問題</th>
          <th>修正動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>短時間觸發數十次</td>
          <td>範圍 / option 太寬</td>
          <td>縮 root、移除不需要的 option、加 debounce</td>
      </tr>
      <tr>
          <td>Apply 跑時 layout 抖動</td>
          <td>在 framework patch 中段觸發</td>
          <td>加 debounce 50-100ms</td>
      </tr>
      <tr>
          <td>Apply 內改 DOM 進入無限循環</td>
          <td>沒處理 self-mutation</td>
          <td>用結構分離 / disconnect 配對</td>
      </tr>
      <tr>
          <td>預期變動沒觸發</td>
          <td>option 沒勾對、root 選錯</td>
          <td>對照變動類型確認 option</td>
      </tr>
      <tr>
          <td>Subtree 用了但只關心直接子</td>
          <td>過度監聽深度</td>
          <td>移除 subtree、改用直接子監聽</td>
      </tr>
      <tr>
          <td>屬性監聽觸發太頻繁</td>
          <td>沒用 attributeFilter</td>
          <td>加 filter 限縮屬性</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：MutationObserver 是非同步監聽、跟同步 selector 設計工具完全不同。範圍 / option / 頻率三維度都要顯式設計 — 預設組合會在 framework 環境中過度觸發、且難以 debug。</p>
<p><code>subtree: true</code> + <code>attributes: true</code> 是「監聽全部」的便利、窄 root + 最少 option 是「精準監聽」的對齊 — 同 <a href="../minimum-necessary-scope-is-sanity-defense/">#43 最小必要範圍</a> 跟 <a href="../ease-of-writing-vs-intent-alignment/">#67 便利 vs 對齊反相關</a>。</p>
]]></content:encoded></item><item><title>setTimeout 輪詢換 MutationObserver</title><link>https://tarrragon.github.io/blog/report/mutationobserver-over-polling/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/mutationobserver-over-polling/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>等待元素出現用 MutationObserver、不用 setTimeout 輪詢。&lt;/strong> Observer 是 event-driven、元素出現的瞬間觸發、無延遲；輪詢是 time-based、最快回應時間 = 輪詢間隔、且 CPU 一直跑。&lt;/p>
&lt;p>輪詢只在「沒有事件可監聽」時才用。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼-observer-比輪詢好">為什麼 observer 比輪詢好&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>「等待某個 DOM 元素出現」這件事的本質是「監聽 DOM 變化、出現時觸發」 — 完全是 event-driven 場景。&lt;/p>
&lt;p>&lt;code>setTimeout&lt;/code> 輪詢的特徵：&lt;/p>
&lt;ul>
&lt;li>每隔 N ms 檢查一次、最快 N ms 才能回應&lt;/li>
&lt;li>即使元素已經出現、要等到下次檢查才知道&lt;/li>
&lt;li>CPU 持續被佔用（即使元素永遠不出現）&lt;/li>
&lt;/ul>
&lt;p>&lt;code>MutationObserver&lt;/code> 的特徵：&lt;/p>
&lt;ul>
&lt;li>元素出現的瞬間觸發 callback&lt;/li>
&lt;li>0 延遲&lt;/li>
&lt;li>DOM 沒變動時 observer 不耗 CPU&lt;/li>
&lt;/ul>
&lt;p>兩者效能差異在現代設備上不明顯、但設計上 observer 才是「適合這個場景」的工具。&lt;/p>
&lt;h3 id="何時輪詢是必要的">何時輪詢是必要的&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>輪詢必要&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>等待 DOM 元素出現&lt;/td>
 &lt;td>否 — 用 MutationObserver&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>等待元素尺寸變化&lt;/td>
 &lt;td>否 — 用 ResizeObserver&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>等待元素進入 viewport&lt;/td>
 &lt;td>否 — 用 IntersectionObserver&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>等待外部 API 結果&lt;/td>
 &lt;td>否 — 用 promise / async&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>等待全局變數出現（無事件）&lt;/td>
 &lt;td>是 — 必要時輪詢&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「無事件可監聽」時才輪詢 — 這類場景在現代 Web 開發少見。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的輪詢">這次任務的輪詢&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>&lt;code>search.html&lt;/code> 用 setTimeout 等 pagefind UI mount：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">waitAndInit&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">filter&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__filter-panel&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">drawer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__drawer&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nx">filter&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="o">!&lt;/span>&lt;span class="nx">drawer&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">setTimeout&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">waitAndInit&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">100&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 找到了、開始 setup
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">place&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">reorderFilters&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">setupScopeFilter&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">mql&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;change&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">place&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="nx">waitAndInit&lt;/span>&lt;span class="p">();&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每 100ms 檢查一次、有延遲、CPU 一直跑（雖然輕）。&lt;/p>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>改用 MutationObserver 監聽 &lt;code>#search&lt;/code>（pagefind mount target）的子節點變化：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">waitForPagefind&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">searchRoot&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">onReady&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 已經存在則立即觸發
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">searchRoot&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__drawer&amp;#39;&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">onReady&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 否則 observe DOM 變動
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">var&lt;/span> &lt;span class="nx">observer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">MutationObserver&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">searchRoot&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__drawer&amp;#39;&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">observer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">disconnect&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">onReady&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">observer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">observe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">searchRoot&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">childList&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">subtree&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="nx">waitForPagefind&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getElementById&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;search&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">filter&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__filter-panel&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="nx">drawer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__drawer&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">place&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="nx">reorderFilters&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="nx">setupScopeFilter&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="nx">mql&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;change&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">place&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>特性：&lt;/p>
&lt;ul>
&lt;li>pagefind 渲染完瞬間觸發、無延遲&lt;/li>
&lt;li>&lt;code>disconnect()&lt;/code> 後 observer 不再耗資源&lt;/li>
&lt;li>已存在時 fast path 直接觸發&lt;/li>
&lt;/ul>
&lt;h3 id="執行通用-helper">執行：通用 helper&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="cm">/**
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="cm"> * 等待 selector 在 root 內出現、觸發 callback。
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="cm"> * 已存在則 sync 觸發；不存在則用 MutationObserver 等待。
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="cm"> */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">waitForElement&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">root&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">selector&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">callback&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">existing&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">root&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">selector&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">existing&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">callback&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">existing&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">observer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">MutationObserver&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">el&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">root&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">selector&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">observer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">disconnect&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">callback&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">observer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">observe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">root&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">childList&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">subtree&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="c1">// 用法
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">waitForElement&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">searchRoot&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;.pagefind-ui__drawer&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">drawer&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 開始 setup
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>把 wait 抽成 helper、setup code 變得更簡潔。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>等待元素出現用 MutationObserver、不用 setTimeout 輪詢。</strong> Observer 是 event-driven、元素出現的瞬間觸發、無延遲；輪詢是 time-based、最快回應時間 = 輪詢間隔、且 CPU 一直跑。</p>
<p>輪詢只在「沒有事件可監聽」時才用。</p>
<hr>
<h2 id="為什麼-observer-比輪詢好">為什麼 observer 比輪詢好</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>「等待某個 DOM 元素出現」這件事的本質是「監聽 DOM 變化、出現時觸發」 — 完全是 event-driven 場景。</p>
<p><code>setTimeout</code> 輪詢的特徵：</p>
<ul>
<li>每隔 N ms 檢查一次、最快 N ms 才能回應</li>
<li>即使元素已經出現、要等到下次檢查才知道</li>
<li>CPU 持續被佔用（即使元素永遠不出現）</li>
</ul>
<p><code>MutationObserver</code> 的特徵：</p>
<ul>
<li>元素出現的瞬間觸發 callback</li>
<li>0 延遲</li>
<li>DOM 沒變動時 observer 不耗 CPU</li>
</ul>
<p>兩者效能差異在現代設備上不明顯、但設計上 observer 才是「適合這個場景」的工具。</p>
<h3 id="何時輪詢是必要的">何時輪詢是必要的</h3>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>輪詢必要</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>等待 DOM 元素出現</td>
          <td>否 — 用 MutationObserver</td>
      </tr>
      <tr>
          <td>等待元素尺寸變化</td>
          <td>否 — 用 ResizeObserver</td>
      </tr>
      <tr>
          <td>等待元素進入 viewport</td>
          <td>否 — 用 IntersectionObserver</td>
      </tr>
      <tr>
          <td>等待外部 API 結果</td>
          <td>否 — 用 promise / async</td>
      </tr>
      <tr>
          <td>等待全局變數出現（無事件）</td>
          <td>是 — 必要時輪詢</td>
      </tr>
  </tbody>
</table>
<p>「無事件可監聽」時才輪詢 — 這類場景在現代 Web 開發少見。</p>
<hr>
<h2 id="這次任務的輪詢">這次任務的輪詢</h2>
<h3 id="觀察">觀察</h3>
<p><code>search.html</code> 用 setTimeout 等 pagefind UI mount：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">waitAndInit</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nx">filter</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__filter-panel&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">drawer</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">filter</span> <span class="o">||</span> <span class="o">!</span><span class="nx">drawer</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">waitAndInit</span><span class="p">,</span> <span class="mi">100</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="c1">// 找到了、開始 setup
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span>  <span class="nx">place</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="nx">reorderFilters</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="nx">setupScopeFilter</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="nx">mql</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;change&#39;</span><span class="p">,</span> <span class="nx">place</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="nx">waitAndInit</span><span class="p">();</span></span></span></code></pre></div><p>每 100ms 檢查一次、有延遲、CPU 一直跑（雖然輕）。</p>
<h3 id="判讀">判讀</h3>
<p>改用 MutationObserver 監聽 <code>#search</code>（pagefind mount target）的子節點變化：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">waitForPagefind</span><span class="p">(</span><span class="nx">searchRoot</span><span class="p">,</span> <span class="nx">onReady</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="c1">// 已經存在則立即觸發
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>  <span class="k">if</span> <span class="p">(</span><span class="nx">searchRoot</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">onReady</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="c1">// 否則 observe DOM 變動
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>  <span class="kd">var</span> <span class="nx">observer</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="kd">function</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">searchRoot</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <span class="nx">observer</span><span class="p">.</span><span class="nx">disconnect</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">      <span class="nx">onReady</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="nx">observer</span><span class="p">.</span><span class="nx">observe</span><span class="p">(</span><span class="nx">searchRoot</span><span class="p">,</span> <span class="p">{</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nx">subtree</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="nx">waitForPagefind</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="s1">&#39;search&#39;</span><span class="p">),</span> <span class="kd">function</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="nx">filter</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__filter-panel&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">  <span class="nx">drawer</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">  <span class="nx">place</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">  <span class="nx">reorderFilters</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  <span class="nx">setupScopeFilter</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">  <span class="nx">mql</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;change&#39;</span><span class="p">,</span> <span class="nx">place</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>特性：</p>
<ul>
<li>pagefind 渲染完瞬間觸發、無延遲</li>
<li><code>disconnect()</code> 後 observer 不再耗資源</li>
<li>已存在時 fast path 直接觸發</li>
</ul>
<h3 id="執行通用-helper">執行：通用 helper</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="cm">/**
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="cm"> * 等待 selector 在 root 內出現、觸發 callback。
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="cm"> * 已存在則 sync 觸發；不存在則用 MutationObserver 等待。
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="cm"> */</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">function</span> <span class="nx">waitForElement</span><span class="p">(</span><span class="nx">root</span><span class="p">,</span> <span class="nx">selector</span><span class="p">,</span> <span class="nx">callback</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="kd">var</span> <span class="nx">existing</span> <span class="o">=</span> <span class="nx">root</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="nx">selector</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">existing</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">callback</span><span class="p">(</span><span class="nx">existing</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="kd">var</span> <span class="nx">observer</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="kd">function</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="kd">var</span> <span class="nx">el</span> <span class="o">=</span> <span class="nx">root</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="nx">selector</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">      <span class="nx">observer</span><span class="p">.</span><span class="nx">disconnect</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">      <span class="nx">callback</span><span class="p">(</span><span class="nx">el</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="nx">observer</span><span class="p">.</span><span class="nx">observe</span><span class="p">(</span><span class="nx">root</span><span class="p">,</span> <span class="p">{</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nx">subtree</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="c1">// 用法
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="c1"></span><span class="nx">waitForElement</span><span class="p">(</span><span class="nx">searchRoot</span><span class="p">,</span> <span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">,</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">drawer</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">  <span class="c1">// 開始 setup
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p>把 wait 抽成 helper、setup code 變得更簡潔。</p>
<hr>
<h2 id="內在屬性比較四種等待機制">內在屬性比較：四種等待機制</h2>
<table>
  <thead>
      <tr>
          <th>機制</th>
          <th>延遲</th>
          <th>CPU 使用</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>setTimeout</code> 單次</td>
          <td>固定延遲</td>
          <td>0</td>
          <td>等已知時間</td>
      </tr>
      <tr>
          <td><code>setTimeout</code> 輪詢</td>
          <td>平均 = 間隔 / 2</td>
          <td>持續低使用</td>
          <td>沒事件可監聽</td>
      </tr>
      <tr>
          <td><code>MutationObserver</code></td>
          <td>0 — 變動瞬間</td>
          <td>DOM 變動時短暫</td>
          <td>等待 DOM 元素</td>
      </tr>
      <tr>
          <td>Promise / async</td>
          <td>0 — resolve 瞬間</td>
          <td>0</td>
          <td>等待 async 操作</td>
      </tr>
  </tbody>
</table>
<p>優先順序：<strong>event-driven &gt; async &gt; polling &gt; timeout</strong>。輪詢是最後選擇。</p>
<hr>
<h2 id="mutationobserver-的細節">MutationObserver 的細節</h2>
<h3 id="observe-option-選對">Observe option 選對</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">observer</span><span class="p">.</span><span class="nx">observe</span><span class="p">(</span><span class="nx">root</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span>    <span class="c1">// 直接子節點增減
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="nx">subtree</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span>      <span class="c1">// 包含深層子節點
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="nx">attributes</span><span class="o">:</span> <span class="kc">false</span><span class="p">,</span>  <span class="c1">// 不看 attribute 變動
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>  <span class="nx">characterData</span><span class="o">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>只勾必要的、不要全部勾 — 觸發頻率影響效能。</p>
<h3 id="找到目標後-disconnect">找到目標後 disconnect</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">observer</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="kd">function</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">found</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">observer</span><span class="p">.</span><span class="nx">disconnect</span><span class="p">();</span>   <span class="c1">// 立刻停、不要繼續監聽
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>    <span class="nx">callback</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>不 disconnect 的話、observer 一直 active、未來任何 DOM 變動都觸發 callback。</p>
<h3 id="已存在的-fast-path">已存在的 fast path</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">root</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="nx">selector</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">callback</span><span class="p">();</span>   <span class="c1">// 已存在則直接觸發、不需 observer
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>避免「元素已經存在但還是要等下次變動才觸發」的延遲。</p>
<hr>
<h2 id="設計取捨等待-dom-元素出現的策略">設計取捨：等待 DOM 元素出現的策略</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（MutationObserver + fast path）當預設、其他做法在特定情境合理。</p>
<h3 id="amutationobserver--already-exists-fast-path這個專案的預設">A：MutationObserver + already-exists fast path（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：先檢查目標是否已存在（直接觸發）、否則 observe DOM 變動、找到後 disconnect</li>
<li><strong>選 A 的理由</strong>：0 延遲、CPU 不被輪詢吃、找到後立即停</li>
<li><strong>適合</strong>：等待 framework / 第三方 library 動態 mount 的元素</li>
<li><strong>代價</strong>：需要寫 fast path + observer + disconnect 三段邏輯（用 helper 包裝即可一行調用）</li>
</ul>
<h3 id="bsettimeout-輪詢">B：<code>setTimeout</code> 輪詢</h3>
<ul>
<li><strong>機制</strong>：每隔 N ms 檢查、找到就停</li>
<li><strong>跟 A 的取捨</strong>：B 寫法簡單、A 設計嚴謹；但 B 有最快回應 = N ms 的延遲、CPU 一直跑</li>
<li><strong>B 比 A 好的情境</strong>：等待對象是無事件可監聽的狀態（全局變數出現、外部 API 結果且無 promise 介面），MutationObserver 無處掛載</li>
</ul>
<h3 id="cpromise--async如果-api-提供">C：Promise / async（如果 API 提供）</h3>
<ul>
<li><strong>機制</strong>：<code>await framework.ready()</code> 等 framework 提供的 promise</li>
<li><strong>跟 A 的取捨</strong>：C 是最乾淨的解、但需要 framework / library 提供 promise API</li>
<li><strong>C 比 A 好的情境</strong>：等的目標有官方 promise 介面（避免自行 observe 內部 DOM）</li>
</ul>
<h3 id="drequestanimationframe-迴圈">D：<code>requestAnimationFrame</code> 迴圈</h3>
<ul>
<li><strong>機制</strong>：每個 frame 檢查一次</li>
<li><strong>跟 B 的取捨</strong>：D 跟著 frame、不會在 idle 時跑；但仍是輪詢、延遲 16ms</li>
<li><strong>D 才合理的情境</strong>：等待動畫 frame 相關狀態（罕見）— 純等 DOM 元素仍應用 A</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>Refactor 動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>setTimeout</code> 用來等 DOM 元素</td>
          <td>改 <code>MutationObserver</code> + disconnect</td>
      </tr>
      <tr>
          <td><code>setInterval</code> 不停跑檢查元素狀態</td>
          <td>改 <code>MutationObserver</code> 或 <code>ResizeObserver</code></td>
      </tr>
      <tr>
          <td>等待邏輯有「最快 X ms 才回應」的延遲</td>
          <td>改 event-driven 機制、消除延遲</td>
      </tr>
      <tr>
          <td>Observer 找到目標後沒 disconnect</td>
          <td>加 disconnect、避免繼續觸發</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：DOM 變動有對應的 event 機制可監聽 — 用對機制就有 0 延遲、無 CPU 浪費。輪詢是「沒辦法的辦法」、不是 default。</p>
]]></content:encoded></item><item><title>Init function 是 orchestrator、職責拆出獨立 function</title><link>https://tarrragon.github.io/blog/report/split-setup-by-responsibility/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/split-setup-by-responsibility/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>單一函式做 ≥ 3 件無關的事就拆。&lt;/strong> 每個函式只負責一個職責、有明確的 input / output、可以獨立 debug 與測試。Init function 變成「組合各職責 function 的 orchestrator」。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼拆函式">為什麼拆函式&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>單一函式做多件事的成本：&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>一函式 50 行做 1 件事&lt;/td>
 &lt;td>低 — 容易讀、職責清楚&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>一函式 100 行做 3 件事&lt;/td>
 &lt;td>中 — 邏輯交織、debug 要分辨哪段&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>一函式 200 行做 5+ 件事&lt;/td>
 &lt;td>高 — 沒人想動、改一處可能影響別處&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>拆函式的成本是「多寫幾個函式名與簽名」、收益是「每個函式範圍小、debug 容易、可單獨重用」。&lt;/p>
&lt;h3 id="拆的依據是職責不是行數">拆的依據是「職責」、不是行數&lt;/h3>
&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;tr>
 &lt;td>按 input / output 拆&lt;/td>
 &lt;td>函式變得 testable、可組合&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>按職責拆的判斷：能不能用一個動詞片語描述函式做什麼？做不到 → 多個職責、該拆。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的拆分機會">這次任務的拆分機會&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>&lt;code>setupScopeFilter()&lt;/code> 現況做 5 件事：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">setupScopeFilter&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 1. 找元素
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">var&lt;/span> &lt;span class="nx">scopeEl&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-scope&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">input&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 2. 量測 scope 高度寫回 CSS 變數
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">syncScopeHeight&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">syncScopeHeight&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">new&lt;/span> &lt;span class="nx">ResizeObserver&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">syncScopeHeight&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">observe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scopeEl&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 3. 把 filter-panel 搬到 sidebar (position function)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">place&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 4. 註冊 scope filter listener + apply
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">apply&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">scopeEl&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;change&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">apply&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 5. Reorder filter blocks
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">reorderFilters&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="nx">reorderFilters&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>5 個職責塞在一個函式：找元素、量高度、搬 slot、scope filter、reorder filter。&lt;/p>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>按職責拆成獨立函式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">findSearchElements&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">shell&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">ui&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">input&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">drawer&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__drawer&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">filter&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__filter-panel&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">scope&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-scope&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">};&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">syncScopeHeight&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scopeEl&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="kd">function&lt;/span> &lt;span class="nx">update&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">h&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">scopeEl&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">offsetHeight&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="mi">56&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">style&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setProperty&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;--search-scope-h&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">h&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s1">&amp;#39;px&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">update&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="k">new&lt;/span> &lt;span class="nx">ResizeObserver&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">update&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">observe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scopeEl&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">setupFilterSlotSwap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">drawer&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">slot&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">breakpoint&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">mql&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">window&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">matchMedia&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;(min-width: &amp;#39;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">breakpoint&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s1">&amp;#39;px)&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="kd">function&lt;/span> &lt;span class="nx">place&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">mql&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">matches&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">slot&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">appendChild&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="k">else&lt;/span> &lt;span class="nx">drawer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">insertBefore&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">drawer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">firstChild&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="nx">place&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl"> &lt;span class="nx">mql&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;change&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">place&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">reorderFilters&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filterPanel&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">desiredOrder&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">blocks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">filterPanel&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelectorAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__filter-block&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">33&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">byKey&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{};&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">34&lt;/span>&lt;span class="cl"> &lt;span class="nx">blocks&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">b&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">35&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">key&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">b&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__filter-name&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">textContent&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">trim&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">toLowerCase&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">36&lt;/span>&lt;span class="cl"> &lt;span class="nx">byKey&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">b&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">37&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">38&lt;/span>&lt;span class="cl"> &lt;span class="nx">desiredOrder&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">k&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">39&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">byKey&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">k&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="nx">filterPanel&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">appendChild&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">byKey&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">k&lt;/span>&lt;span class="p">]);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">40&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">41&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">42&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">43&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">setupScopeFilter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scopeEl&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ui&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">44&lt;/span>&lt;span class="cl"> &lt;span class="kd">function&lt;/span> &lt;span class="nx">getScope&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">45&lt;/span>&lt;span class="cl"> &lt;span class="kd">function&lt;/span> &lt;span class="nx">apply&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">46&lt;/span>&lt;span class="cl"> &lt;span class="kd">function&lt;/span> &lt;span class="nx">schedule&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">47&lt;/span>&lt;span class="cl"> &lt;span class="nx">scopeEl&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;change&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">schedule&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">48&lt;/span>&lt;span class="cl"> &lt;span class="nx">input&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;input&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">schedule&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">49&lt;/span>&lt;span class="cl"> &lt;span class="k">new&lt;/span> &lt;span class="nx">MutationObserver&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">schedule&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">observe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ui&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">childList&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">subtree&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">50&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>init()&lt;/code> 變成 orchestrator：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">init&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">shell&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-shell&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">waitForElement&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;.pagefind-ui__drawer&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">els&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">findSearchElements&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">syncScopeHeight&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">els&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">scope&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">setupFilterSlotSwap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">els&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">els&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">drawer&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-filter-slot&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="mi">1400&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">reorderFilters&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">els&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;type&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;tag&amp;#39;&lt;/span>&lt;span class="p">]);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">setupScopeFilter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">els&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">scope&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">els&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">els&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ui&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="nx">init&lt;/span>&lt;span class="p">();&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個拆出的函式：&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>單一函式做 ≥ 3 件無關的事就拆。</strong> 每個函式只負責一個職責、有明確的 input / output、可以獨立 debug 與測試。Init function 變成「組合各職責 function 的 orchestrator」。</p>
<hr>
<h2 id="為什麼拆函式">為什麼拆函式</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>單一函式做多件事的成本：</p>
<table>
  <thead>
      <tr>
          <th>規模</th>
          <th>維護痛點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一函式 50 行做 1 件事</td>
          <td>低 — 容易讀、職責清楚</td>
      </tr>
      <tr>
          <td>一函式 100 行做 3 件事</td>
          <td>中 — 邏輯交織、debug 要分辨哪段</td>
      </tr>
      <tr>
          <td>一函式 200 行做 5+ 件事</td>
          <td>高 — 沒人想動、改一處可能影響別處</td>
      </tr>
  </tbody>
</table>
<p>拆函式的成本是「多寫幾個函式名與簽名」、收益是「每個函式範圍小、debug 容易、可單獨重用」。</p>
<h3 id="拆的依據是職責不是行數">拆的依據是「職責」、不是行數</h3>
<table>
  <thead>
      <tr>
          <th>拆法</th>
          <th>結果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>按行數機械拆</td>
          <td>切出沒邏輯意義的片段、更亂</td>
      </tr>
      <tr>
          <td>按職責拆</td>
          <td>每個函式名能描述「做什麼」</td>
      </tr>
      <tr>
          <td>按 input / output 拆</td>
          <td>函式變得 testable、可組合</td>
      </tr>
  </tbody>
</table>
<p>按職責拆的判斷：能不能用一個動詞片語描述函式做什麼？做不到 → 多個職責、該拆。</p>
<hr>
<h2 id="這次任務的拆分機會">這次任務的拆分機會</h2>
<h3 id="觀察">觀察</h3>
<p><code>setupScopeFilter()</code> 現況做 5 件事：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">setupScopeFilter</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="c1">// 1. 找元素
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>  <span class="kd">var</span> <span class="nx">scopeEl</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-scope&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">var</span> <span class="nx">input</span>   <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="c1">// ...
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="c1">// 2. 量測 scope 高度寫回 CSS 變數
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>  <span class="kd">function</span> <span class="nx">syncScopeHeight</span><span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="nx">syncScopeHeight</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="k">new</span> <span class="nx">ResizeObserver</span><span class="p">(</span><span class="nx">syncScopeHeight</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">scopeEl</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="c1">// 3. 把 filter-panel 搬到 sidebar (position function)
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span>  <span class="kd">function</span> <span class="nx">place</span><span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="c1">// 4. 註冊 scope filter listener + apply
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1"></span>  <span class="kd">function</span> <span class="nx">apply</span><span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="nx">scopeEl</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;change&#39;</span><span class="p">,</span> <span class="nx">apply</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="c1">// ...
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">  <span class="c1">// 5. Reorder filter blocks
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="c1"></span>  <span class="kd">function</span> <span class="nx">reorderFilters</span><span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  <span class="nx">reorderFilters</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>5 個職責塞在一個函式：找元素、量高度、搬 slot、scope filter、reorder filter。</p>
<h3 id="判讀">判讀</h3>
<p>按職責拆成獨立函式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">findSearchElements</span><span class="p">(</span><span class="nx">shell</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">shell</span><span class="o">:</span>  <span class="nx">shell</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">ui</span><span class="o">:</span>     <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">input</span><span class="o">:</span>  <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">drawer</span><span class="o">:</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">filter</span><span class="o">:</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__filter-panel&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">scope</span><span class="o">:</span>  <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-scope&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">function</span> <span class="nx">syncScopeHeight</span><span class="p">(</span><span class="nx">scopeEl</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="kd">function</span> <span class="nx">update</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="kd">var</span> <span class="nx">h</span> <span class="o">=</span> <span class="nx">scopeEl</span><span class="p">.</span><span class="nx">offsetHeight</span> <span class="o">||</span> <span class="mi">56</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;--search-scope-h&#39;</span><span class="p">,</span> <span class="nx">h</span> <span class="o">+</span> <span class="s1">&#39;px&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="nx">update</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="k">new</span> <span class="nx">ResizeObserver</span><span class="p">(</span><span class="nx">update</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">scopeEl</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="kd">function</span> <span class="nx">setupFilterSlotSwap</span><span class="p">(</span><span class="nx">filter</span><span class="p">,</span> <span class="nx">drawer</span><span class="p">,</span> <span class="nx">slot</span><span class="p">,</span> <span class="nx">breakpoint</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  <span class="kd">var</span> <span class="nx">mql</span> <span class="o">=</span> <span class="nb">window</span><span class="p">.</span><span class="nx">matchMedia</span><span class="p">(</span><span class="s1">&#39;(min-width: &#39;</span> <span class="o">+</span> <span class="nx">breakpoint</span> <span class="o">+</span> <span class="s1">&#39;px)&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">  <span class="kd">function</span> <span class="nx">place</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">mql</span><span class="p">.</span><span class="nx">matches</span><span class="p">)</span> <span class="nx">slot</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">filter</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="k">else</span> <span class="nx">drawer</span><span class="p">.</span><span class="nx">insertBefore</span><span class="p">(</span><span class="nx">filter</span><span class="p">,</span> <span class="nx">drawer</span><span class="p">.</span><span class="nx">firstChild</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">  <span class="nx">place</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">  <span class="nx">mql</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;change&#39;</span><span class="p">,</span> <span class="nx">place</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">
</span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="kd">function</span> <span class="nx">reorderFilters</span><span class="p">(</span><span class="nx">filterPanel</span><span class="p">,</span> <span class="nx">desiredOrder</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">  <span class="kd">var</span> <span class="nx">blocks</span> <span class="o">=</span> <span class="nx">filterPanel</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__filter-block&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">  <span class="kd">var</span> <span class="nx">byKey</span> <span class="o">=</span> <span class="p">{};</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">  <span class="nx">blocks</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">b</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">    <span class="kd">var</span> <span class="nx">key</span> <span class="o">=</span> <span class="nx">b</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__filter-name&#39;</span><span class="p">).</span><span class="nx">textContent</span><span class="p">.</span><span class="nx">trim</span><span class="p">().</span><span class="nx">toLowerCase</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">    <span class="nx">byKey</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="o">=</span> <span class="nx">b</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">  <span class="nx">desiredOrder</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">k</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">byKey</span><span class="p">[</span><span class="nx">k</span><span class="p">])</span> <span class="nx">filterPanel</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">byKey</span><span class="p">[</span><span class="nx">k</span><span class="p">]);</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">
</span></span><span class="line"><span class="ln">43</span><span class="cl"><span class="kd">function</span> <span class="nx">setupScopeFilter</span><span class="p">(</span><span class="nx">scopeEl</span><span class="p">,</span> <span class="nx">input</span><span class="p">,</span> <span class="nx">ui</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">  <span class="kd">function</span> <span class="nx">getScope</span><span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">  <span class="kd">function</span> <span class="nx">apply</span><span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">  <span class="kd">function</span> <span class="nx">schedule</span><span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">  <span class="nx">scopeEl</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;change&#39;</span><span class="p">,</span> <span class="nx">schedule</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">  <span class="nx">input</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;input&#39;</span><span class="p">,</span> <span class="nx">schedule</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">  <span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="nx">schedule</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">ui</span><span class="p">,</span> <span class="p">{</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nx">subtree</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>init()</code> 變成 orchestrator：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">init</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">var</span> <span class="nx">shell</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">shell</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nx">waitForElement</span><span class="p">(</span><span class="nx">shell</span><span class="p">,</span> <span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">,</span> <span class="kd">function</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="kd">var</span> <span class="nx">els</span> <span class="o">=</span> <span class="nx">findSearchElements</span><span class="p">(</span><span class="nx">shell</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">syncScopeHeight</span><span class="p">(</span><span class="nx">els</span><span class="p">.</span><span class="nx">scope</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">setupFilterSlotSwap</span><span class="p">(</span><span class="nx">els</span><span class="p">.</span><span class="nx">filter</span><span class="p">,</span> <span class="nx">els</span><span class="p">.</span><span class="nx">drawer</span><span class="p">,</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-filter-slot&#39;</span><span class="p">),</span> <span class="mi">1400</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">reorderFilters</span><span class="p">(</span><span class="nx">els</span><span class="p">.</span><span class="nx">filter</span><span class="p">,</span> <span class="p">[</span><span class="s1">&#39;type&#39;</span><span class="p">,</span> <span class="s1">&#39;tag&#39;</span><span class="p">]);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">setupScopeFilter</span><span class="p">(</span><span class="nx">els</span><span class="p">.</span><span class="nx">scope</span><span class="p">,</span> <span class="nx">els</span><span class="p">.</span><span class="nx">input</span><span class="p">,</span> <span class="nx">els</span><span class="p">.</span><span class="nx">ui</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="nx">init</span><span class="p">();</span></span></span></code></pre></div><p>每個拆出的函式：</p>
<ul>
<li>名字描述做什麼（動詞 + 名詞）</li>
<li>接受需要的元素當參數（不依賴全局）</li>
<li>不知道其他函式的存在（解耦）</li>
</ul>
<hr>
<h2 id="內在屬性比較四種函式拆分粒度">內在屬性比較：四種函式拆分粒度</h2>
<table>
  <thead>
      <tr>
          <th>粒度</th>
          <th>維護成本</th>
          <th>Debug 範圍</th>
          <th>可重用性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一個 mega init function</td>
          <td>高 — 200+ 行交織</td>
          <td>整個函式都要看</td>
          <td>低 — 跟特定 setup 綁</td>
      </tr>
      <tr>
          <td>按行數機械拆（每 30 行一份）</td>
          <td>中 — 切出無意義片段</td>
          <td>中</td>
          <td>低</td>
      </tr>
      <tr>
          <td>按職責拆</td>
          <td>低 — 每函式單一職責</td>
          <td>函式內部、範圍小</td>
          <td>高</td>
      </tr>
      <tr>
          <td>按職責拆 + class 包裝</td>
          <td>低</td>
          <td>範圍小</td>
          <td>最高 — 多實例</td>
      </tr>
  </tbody>
</table>
<p>優先按職責拆 — 函式名表達 intent、debug 範圍小、單獨可測。</p>
<hr>
<h2 id="拆函式的具體技巧">拆函式的具體技巧</h2>
<h3 id="1-函式名是動詞片語">1. 函式名是動詞片語</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">syncScopeHeight</span><span class="p">()</span>           <span class="c1">// 動詞 + 對象
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">setupFilterSlotSwap</span><span class="p">()</span>       <span class="c1">// 動詞 + 對象
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="nx">reorderFilters</span><span class="p">()</span>            <span class="c1">// 動詞 + 對象
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="nx">findSearchElements</span><span class="p">()</span>        <span class="c1">// 動詞 + 對象
</span></span></span></code></pre></div><p>不要：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">filter</span><span class="p">()</span>        <span class="c1">// 動詞模糊（filter 是動詞還是名詞？）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">handle</span><span class="p">()</span>        <span class="c1">// 太抽象
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="nx">init</span><span class="p">()</span>          <span class="c1">// 只有 orchestrator 用、不要散在各處
</span></span></span></code></pre></div><h3 id="2-參數是該函式需要的不傳一個-mega-object">2. 參數是該函式需要的、不傳一個 mega object</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 好 — 函式知道它需要什麼
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">syncScopeHeight</span><span class="p">(</span><span class="nx">scopeEl</span><span class="p">)</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// 較差 — 函式拿到一堆無關的東西、不清楚依賴
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">syncScopeHeight</span><span class="p">(</span><span class="nx">allElements</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="kd">var</span> <span class="nx">scope</span> <span class="o">=</span> <span class="nx">allElements</span><span class="p">.</span><span class="nx">scope</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">...</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>明確參數 = 明確依賴 = 容易測試。</p>
<h3 id="3-副作用集中在一處">3. 副作用集中在一處</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">syncScopeHeight</span><span class="p">(</span><span class="nx">scopeEl</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">function</span> <span class="nx">update</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;--search-scope-h&#39;</span><span class="p">,</span> <span class="nx">scopeEl</span><span class="p">.</span><span class="nx">offsetHeight</span> <span class="o">+</span> <span class="s1">&#39;px&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nx">update</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="k">new</span> <span class="nx">ResizeObserver</span><span class="p">(</span><span class="nx">update</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">scopeEl</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>副作用（DOM 變動、event listener、observer）都在這個函式內。沒散到別處。</p>
<h3 id="4-不依賴外部變數">4. 不依賴外部變數</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 好 — 純函式、依賴只在參數
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">reorderFilters</span><span class="p">(</span><span class="nx">filterPanel</span><span class="p">,</span> <span class="nx">desiredOrder</span><span class="p">)</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// 較差 — 依賴外部全局變數
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="kd">var</span> <span class="nx">desiredOrder</span> <span class="o">=</span> <span class="p">[</span><span class="s1">&#39;type&#39;</span><span class="p">,</span> <span class="s1">&#39;tag&#39;</span><span class="p">];</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="kd">function</span> <span class="nx">reorderFilters</span><span class="p">(</span><span class="nx">filterPanel</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="c1">// 用了 desiredOrder
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="p">}</span></span></span></code></pre></div><p>純函式 = 無隱式依賴 = 重用方便、測試方便。</p>
<hr>
<h2 id="設計取捨大型-init-function-的拆分策略">設計取捨：大型 init function 的拆分策略</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（按職責拆 + 純函式）當預設、其他做法在特定情境合理。</p>
<h3 id="a按職責拆--純函式--init-當-orchestrator這個專案的預設">A：按職責拆 + 純函式 + init 當 orchestrator（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：每職責一個函式（動詞 + 對象命名）、依賴透過參數傳入、init 組合各函式</li>
<li><strong>選 A 的理由</strong>：debug 範圍小（職責 = 函式 = grep 範圍）、單獨可測、可重用</li>
<li><strong>適合</strong>：&gt; 50 行的 init function、預期長期維護</li>
<li><strong>代價</strong>：多寫幾個函式名與簽名、檔案 LOC 略增</li>
</ul>
<h3 id="b按行數機械拆每-30-行一份">B：按行數機械拆（每 30 行一份）</h3>
<ul>
<li><strong>機制</strong>：固定 LOC 拆檔、不考慮職責邊界</li>
<li><strong>跟 A 的取捨</strong>：B 拆完後切片無邏輯意義、A 切片各自完整；B 更亂、debug 反而更難</li>
<li><strong>B 是反模式</strong>：「行數」不是有意義的拆分判準 — 拆完後切片無邏輯意義、debug 反而更難</li>
</ul>
<h3 id="c保持-mega-init-function">C：保持 mega init function</h3>
<ul>
<li><strong>機制</strong>：所有 setup 邏輯塞在一個 init 內</li>
<li><strong>跟 A 的取捨</strong>：C 一個函式看完所有 setup、A 散在多函式；但 C 在 200+ 行時改一處要小心整體</li>
<li><strong>C 才合理的情境</strong>：&lt; 50 行的 init、職責本來就單一</li>
</ul>
<h3 id="d按職責拆--class-包裝多實例">D：按職責拆 + class 包裝多實例</h3>
<ul>
<li><strong>機制</strong>：把 setup 包成 class、<code>new SearchShell(rootEl)</code> 建立實例</li>
<li><strong>跟 A 的取捨</strong>：D 多實例支援更乾淨（每實例自己的 state）、A 用純函式 + 起點當參數也能達成</li>
<li><strong>D 比 A 好的情境</strong>：元件有複雜的內部 state、預期會被多次實例化（library 設計）</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>Refactor 動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一個函式 100+ 行</td>
          <td>列出做的事、按職責拆</td>
      </tr>
      <tr>
          <td>函式名抽象（<code>init</code> / <code>handle</code> / <code>process</code>）</td>
          <td>改名動詞 + 對象、表達 intent</td>
      </tr>
      <tr>
          <td>函式內讀外部全局變數</td>
          <td>把依賴改為參數、純函式化</td>
      </tr>
      <tr>
          <td>Debug 時要 grep 整個函式找哪段邏輯</td>
          <td>拆完後職責 = 函式 = grep 範圍縮小</td>
      </tr>
      <tr>
          <td>同一段邏輯複製到別處</td>
          <td>拆成獨立函式、兩處引用</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：函式是「做一件事」的單位。一個函式越多職責、debug 與重用越難。拆 = 投資、回報是未來的維護成本下降。</p>
]]></content:encoded></item><item><title>Reactive 監聽器的效能 audit：跨 listener 類型盤點觸發頻率</title><link>https://tarrragon.github.io/blog/report/reactive-listener-frequency-management/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/reactive-listener-frequency-management/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>監聽器的「觸發頻率」是效能的第一道防線、跨多種 listener 類型一起盤點。&lt;/strong> 本篇是 audit 視角（「我有效能問題、reactive 監聽器是不是嫌疑」）— 設計新 observer 的細節由 &lt;a href="../mutation-observer-scope/">#29 MutationObserver 範圍與觸發頻率&lt;/a> 處理。Audit 時把所有 reactive 監聽器列一張表、看哪些觸發頻率異常。&lt;/p>
&lt;blockquote>
&lt;p>本篇焦點：&lt;strong>跨 listener 類型的效能盤點&lt;/strong>。&lt;/p>
&lt;ul>
&lt;li>&lt;strong>MutationObserver 的設計細節&lt;/strong>（root / option / debounce / self-mutation）由 &lt;a href="../mutation-observer-scope/">#29&lt;/a> 處理&lt;/li>
&lt;li>&lt;strong>Selector 範圍的設計&lt;/strong>由 &lt;a href="../dom-selector-precision/">#14&lt;/a> 處理&lt;/li>
&lt;li>&lt;strong>Runtime 計算成本&lt;/strong>（regex / textContent / forEach）由 &lt;a href="../runtime-iteration-and-regex-cost/">#34&lt;/a> 處理&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;hr>
&lt;h2 id="為什麼觸發頻率主導效能">為什麼觸發頻率主導效能&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>Reactive 監聽器有三個獨立成本：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &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>看範圍與 option&lt;/td>
 &lt;td>倍數疊加&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Callback 內部運算&lt;/td>
 &lt;td>看實作&lt;/td>
 &lt;td>每次完整跑&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Callback 引發的副作用&lt;/td>
 &lt;td>看 DOM 變動&lt;/td>
 &lt;td>可能反向觸發&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>把單次 callback 從 5ms 優化到 2ms 是 2.5x；把觸發次數從 100 次/秒降到 10 次/秒是 10x。&lt;strong>觸發頻率優化的天花板更高&lt;/strong> — audit 時優先看頻率。&lt;/p>
&lt;h3 id="三類觸發頻率風險速覽">三類觸發頻率風險（速覽）&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>類型&lt;/th>
 &lt;th>表現&lt;/th>
 &lt;th>詳細處理&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>範圍過寬（observer subtree）&lt;/td>
 &lt;td>無關變動也觸發&lt;/td>
 &lt;td>&lt;a href="../mutation-observer-scope/">#29 root 與 option 設計&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Option 全勾&lt;/td>
 &lt;td>多種變動類型同時觸發&lt;/td>
 &lt;td>&lt;a href="../mutation-observer-scope/">#29 三維度收斂&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>自激迴圈&lt;/td>
 &lt;td>callback 自己改 DOM 觸發自己&lt;/td>
 &lt;td>&lt;a href="../mutation-observer-scope/">#29 self-mutation 處理&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>本篇不展開設計細節（避免跟 #29 重複）、只談「audit 時怎麼識別這些 risk」。&lt;/p>
&lt;hr>
&lt;h2 id="跨-observer-類型的盤點">跨 observer 類型的盤點&lt;/h2>
&lt;p>效能 audit 時、列出&lt;strong>所有&lt;/strong> reactive 監聽器、不只 MutationObserver。各類型觸發來源不同、需要分別評估。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>類型&lt;/th>
 &lt;th>觸發來源&lt;/th>
 &lt;th>過頻訊號&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>MutationObserver&lt;/td>
 &lt;td>DOM 變動&lt;/td>
 &lt;td>一次操作觸發 10+ 次&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ResizeObserver&lt;/td>
 &lt;td>元素尺寸變動&lt;/td>
 &lt;td>持續觸發（自激）/ resize 視窗時連發&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>IntersectionObserver&lt;/td>
 &lt;td>可視性變動&lt;/td>
 &lt;td>scroll 時連發&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Event listener (input / scroll / resize)&lt;/td>
 &lt;td>使用者互動&lt;/td>
 &lt;td>高頻事件未 debounce&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>setInterval&lt;/code> / &lt;code>requestAnimationFrame&lt;/code> 迴圈&lt;/td>
 &lt;td>時間&lt;/td>
 &lt;td>持續跑、不只在需要時&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="盤點工具">盤點工具&lt;/h3>
&lt;p>DevTools Performance 面板錄一段使用者操作、看 callback 觸發次數：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 在 callback 內加 console.count
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">new&lt;/span> &lt;span class="nx">MutationObserver&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">mutations&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">console&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;mutation observer fired&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ... 處理
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}).&lt;/span>&lt;span class="nx">observe&lt;/span>&lt;span class="p">(...);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="k">new&lt;/span> &lt;span class="nx">ResizeObserver&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">entries&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">console&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;resize observer fired&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ... 處理
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}).&lt;/span>&lt;span class="nx">observe&lt;/span>&lt;span class="p">(...);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跑一次「使用者打字 + 等結果」的完整操作、看 console 各 listener 觸發幾次。&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>1-3 次&lt;/td>
 &lt;td>正常&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>5-10 次&lt;/td>
 &lt;td>可能過頻、值得查&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>10+ 次&lt;/td>
 &lt;td>範圍 / option 太寬、需要收斂&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>持續觸發（不停）&lt;/td>
 &lt;td>自激迴圈、需要立刻處理&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="resizeobserver-寫變數造成自激">ResizeObserver 寫變數造成自激&lt;/h2>
&lt;p>ResizeObserver 的特殊風險是「寫 CSS 變數可能影響被觀察元素自己的尺寸」 — 這個 case 跟 &lt;a href="../mutation-observer-scope/">#29&lt;/a> 處理的 MutationObserver self-mutation 機制不同、值得獨立展開。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>監聽器的「觸發頻率」是效能的第一道防線、跨多種 listener 類型一起盤點。</strong> 本篇是 audit 視角（「我有效能問題、reactive 監聽器是不是嫌疑」）— 設計新 observer 的細節由 <a href="../mutation-observer-scope/">#29 MutationObserver 範圍與觸發頻率</a> 處理。Audit 時把所有 reactive 監聽器列一張表、看哪些觸發頻率異常。</p>
<blockquote>
<p>本篇焦點：<strong>跨 listener 類型的效能盤點</strong>。</p>
<ul>
<li><strong>MutationObserver 的設計細節</strong>（root / option / debounce / self-mutation）由 <a href="../mutation-observer-scope/">#29</a> 處理</li>
<li><strong>Selector 範圍的設計</strong>由 <a href="../dom-selector-precision/">#14</a> 處理</li>
<li><strong>Runtime 計算成本</strong>（regex / textContent / forEach）由 <a href="../runtime-iteration-and-regex-cost/">#34</a> 處理</li>
</ul></blockquote>
<hr>
<h2 id="為什麼觸發頻率主導效能">為什麼觸發頻率主導效能</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>Reactive 監聽器有三個獨立成本：</p>
<table>
  <thead>
      <tr>
          <th>成本來源</th>
          <th>單次量級</th>
          <th>累積方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>觸發頻率</td>
          <td>看範圍與 option</td>
          <td>倍數疊加</td>
      </tr>
      <tr>
          <td>Callback 內部運算</td>
          <td>看實作</td>
          <td>每次完整跑</td>
      </tr>
      <tr>
          <td>Callback 引發的副作用</td>
          <td>看 DOM 變動</td>
          <td>可能反向觸發</td>
      </tr>
  </tbody>
</table>
<p>把單次 callback 從 5ms 優化到 2ms 是 2.5x；把觸發次數從 100 次/秒降到 10 次/秒是 10x。<strong>觸發頻率優化的天花板更高</strong> — audit 時優先看頻率。</p>
<h3 id="三類觸發頻率風險速覽">三類觸發頻率風險（速覽）</h3>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>表現</th>
          <th>詳細處理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>範圍過寬（observer subtree）</td>
          <td>無關變動也觸發</td>
          <td><a href="../mutation-observer-scope/">#29 root 與 option 設計</a></td>
      </tr>
      <tr>
          <td>Option 全勾</td>
          <td>多種變動類型同時觸發</td>
          <td><a href="../mutation-observer-scope/">#29 三維度收斂</a></td>
      </tr>
      <tr>
          <td>自激迴圈</td>
          <td>callback 自己改 DOM 觸發自己</td>
          <td><a href="../mutation-observer-scope/">#29 self-mutation 處理</a></td>
      </tr>
  </tbody>
</table>
<p>本篇不展開設計細節（避免跟 #29 重複）、只談「audit 時怎麼識別這些 risk」。</p>
<hr>
<h2 id="跨-observer-類型的盤點">跨 observer 類型的盤點</h2>
<p>效能 audit 時、列出<strong>所有</strong> reactive 監聽器、不只 MutationObserver。各類型觸發來源不同、需要分別評估。</p>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>觸發來源</th>
          <th>過頻訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>MutationObserver</td>
          <td>DOM 變動</td>
          <td>一次操作觸發 10+ 次</td>
      </tr>
      <tr>
          <td>ResizeObserver</td>
          <td>元素尺寸變動</td>
          <td>持續觸發（自激）/ resize 視窗時連發</td>
      </tr>
      <tr>
          <td>IntersectionObserver</td>
          <td>可視性變動</td>
          <td>scroll 時連發</td>
      </tr>
      <tr>
          <td>Event listener (input / scroll / resize)</td>
          <td>使用者互動</td>
          <td>高頻事件未 debounce</td>
      </tr>
      <tr>
          <td><code>setInterval</code> / <code>requestAnimationFrame</code> 迴圈</td>
          <td>時間</td>
          <td>持續跑、不只在需要時</td>
      </tr>
  </tbody>
</table>
<h3 id="盤點工具">盤點工具</h3>
<p>DevTools Performance 面板錄一段使用者操作、看 callback 觸發次數：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 在 callback 內加 console.count
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">mutations</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">count</span><span class="p">(</span><span class="s1">&#39;mutation observer fired&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="c1">// ... 處理
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="p">}).</span><span class="nx">observe</span><span class="p">(...);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">new</span> <span class="nx">ResizeObserver</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">entries</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">count</span><span class="p">(</span><span class="s1">&#39;resize observer fired&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="c1">// ... 處理
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="p">}).</span><span class="nx">observe</span><span class="p">(...);</span></span></span></code></pre></div><p>跑一次「使用者打字 + 等結果」的完整操作、看 console 各 listener 觸發幾次。</p>
<table>
  <thead>
      <tr>
          <th>觸發次數</th>
          <th>評估</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1-3 次</td>
          <td>正常</td>
      </tr>
      <tr>
          <td>5-10 次</td>
          <td>可能過頻、值得查</td>
      </tr>
      <tr>
          <td>10+ 次</td>
          <td>範圍 / option 太寬、需要收斂</td>
      </tr>
      <tr>
          <td>持續觸發（不停）</td>
          <td>自激迴圈、需要立刻處理</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="resizeobserver-寫變數造成自激">ResizeObserver 寫變數造成自激</h2>
<p>ResizeObserver 的特殊風險是「寫 CSS 變數可能影響被觀察元素自己的尺寸」 — 這個 case 跟 <a href="../mutation-observer-scope/">#29</a> 處理的 MutationObserver self-mutation 機制不同、值得獨立展開。</p>
<h3 id="機制">機制</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">syncScopeHeight</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s1">&#39;--search-scope-h&#39;</span><span class="p">,</span> <span class="nx">scopeEl</span><span class="p">.</span><span class="nx">offsetHeight</span> <span class="o">+</span> <span class="s1">&#39;px&#39;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">new</span> <span class="nx">ResizeObserver</span><span class="p">(</span><span class="nx">syncScopeHeight</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">scopeEl</span><span class="p">);</span></span></span></code></pre></div><p>如果 <code>--search-scope-h</code> 在 CSS 中被用來計算 <code>scopeEl</code> 自己的 padding / margin / height — 寫入觸發 layout、layout 觸發 resize、resize 觸發 callback、callback 又寫入。</p>
<h3 id="症狀">症狀</h3>
<ul>
<li>CPU 持續被佔</li>
<li>Performance 面板看到 ResizeObserver callback 連發（&gt;60/秒）</li>
<li>元素尺寸持續微調</li>
</ul>
<h3 id="解法">解法</h3>
<p><strong>結構分離</strong>：寫的變數不該影響被觀察元素自己。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">new</span> <span class="nx">ResizeObserver</span><span class="p">(</span><span class="nx">syncScopeHeight</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">scopeEl</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// scopeEl 高度寫到 --search-scope-h
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">// CSS 中 --search-scope-h 用來計算 drawer 的 margin-top
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// drawer 不是 scopeEl、不會反向觸發
</span></span></span></code></pre></div><p>設計時讓「觀察的元素」跟「受變數影響的元素」結構上分離 — 不會循環。</p>
<h3 id="跟-mutationobserver-self-mutation-的差異">跟 MutationObserver self-mutation 的差異</h3>
<table>
  <thead>
      <tr>
          <th>觀察類型</th>
          <th>self-mutation 機制</th>
          <th>處理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>MutationObserver</td>
          <td>callback 改 DOM 結構 / attribute</td>
          <td>disconnect + observe 配對</td>
      </tr>
      <tr>
          <td>ResizeObserver</td>
          <td>callback 改變數 → 反向影響尺寸</td>
          <td>結構分離（觀察 A、影響 B）</td>
      </tr>
      <tr>
          <td>IntersectionObserver</td>
          <td>callback 改可視性 → 反向觸發</td>
          <td>罕見、設計時避免</td>
      </tr>
  </tbody>
</table>
<p>ResizeObserver 沒有 disconnect 配對的等價技巧（disconnect 後再 observe 仍會立即重觸發） — 必須靠結構分離。</p>
<hr>
<h2 id="盤點的標準格式">盤點的標準格式</h2>
<p>每個 reactive 監聽器寫成一段註解、audit 時讀這份「設定卡」即可：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="cm">/**
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="cm"> * 監聽：.pagefind-ui 的子節點變動
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="cm"> * 類型：MutationObserver
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="cm"> * 範圍：subtree（深層也看）
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="cm"> * Option：childList only
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="cm"> * Callback 是否改 DOM：是（toggle class）
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="cm"> * 自激風險：否（class change 不觸發 childList）
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="cm"> * Debounce：80ms
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="cm"> * 預期觸發頻率：使用者打字一次 &lt; 5 次
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="cm"> */</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="nx">schedule</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">ui</span><span class="p">,</span> <span class="p">{</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nx">subtree</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span></span></span></code></pre></div><p>audit 時、看註解就知道：</p>
<ul>
<li>這個 observer 在做什麼</li>
<li>預期觸發頻率多少</li>
<li>實測超過預期 → 範圍太寬或 option 過勾</li>
</ul>
<hr>
<h2 id="設計取捨頻率管理策略選擇">設計取捨：頻率管理策略選擇</h2>
<p>當盤點發現某個 observer 觸發過頻、四種應對：</p>
<h3 id="a縮-observer-範圍--option這個專案的預設">A：縮 observer 範圍 / option（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：subtree → 直接子；移除沒用的 option flag</li>
<li><strong>選 A 的理由</strong>：成本最低、改一行；觸發頻率倍數降低</li>
<li><strong>適合</strong>：絕大多數過頻 case</li>
<li><strong>代價</strong>：需要重新確認哪些變動類型真的需要監聽</li>
<li><strong>詳細</strong>：<a href="../mutation-observer-scope/">#29 三維度收斂</a></li>
</ul>
<h3 id="b加-debounce--throttle">B：加 debounce / throttle</h3>
<ul>
<li><strong>機制</strong>：高頻觸發合併成低頻 apply</li>
<li><strong>跟 A 的取捨</strong>：B 不解問題的根（觸發仍發生）、A 解根；但 B 對「無法縮範圍」的 case（如 input event）必要</li>
<li><strong>B 比 A 好的情境</strong>：使用者輸入事件、scroll 事件 — 本身高頻、無法縮範圍</li>
</ul>
<h3 id="cdisconnect--reconnect-配對">C：Disconnect / reconnect 配對</h3>
<ul>
<li><strong>機制</strong>：callback 改 DOM 前 disconnect、改完 reconnect</li>
<li><strong>跟 A/B 的取捨</strong>：C 處理 self-mutation、A/B 不處理；C 比 A/B 複雜</li>
<li><strong>C 比 A/B 好的情境</strong>：MutationObserver callback 必須改 DOM（沒有結構分離選項）</li>
<li><strong>詳細</strong>：<a href="../mutation-observer-scope/">#29 self-mutation 處理</a></li>
</ul>
<h3 id="dresizeobserver-結構分離">D：ResizeObserver 結構分離</h3>
<ul>
<li><strong>機制</strong>：觀察 A、影響 B（B ≠ A）</li>
<li><strong>跟 C 的取捨</strong>：ResizeObserver 沒 disconnect 等價技巧、必須用 D</li>
<li><strong>D 是 ResizeObserver 自激的唯一解</strong></li>
</ul>
<hr>
<h2 id="不該套用頻率管理的情境">不該套用「頻率管理」的情境</h2>
<p>不是所有 reactive 監聽器都需要管：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼可以放任</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>開發階段、不上 production</td>
          <td>效能不影響真實使用者</td>
      </tr>
      <tr>
          <td>Callback 極輕（單次 &lt; 0.1ms）</td>
          <td>觸發 100 次也才 10ms</td>
      </tr>
      <tr>
          <td>觸發頻率本來就極低（一次 setup 一次 callback）</td>
          <td>沒有頻率問題</td>
      </tr>
  </tbody>
</table>
<p><strong>核心判準</strong>：實測有效能問題嗎？沒有就不必預先優化。Audit 是「找已存在的問題」、不是「預防所有可能」。</p>
<hr>
<h2 id="跟其他原則的關係">跟其他原則的關係</h2>
<table>
  <thead>
      <tr>
          <th>篇</th>
          <th>關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../mutation-observer-scope/">#29 MutationObserver 範圍與觸發頻率</a></td>
          <td>互補 — #29 是設計指引（怎麼寫 observer）、本篇是 audit 視角（怎麼找問題）</td>
      </tr>
      <tr>
          <td><a href="../dom-selector-precision/">#14 Selector 精準度</a></td>
          <td>跟 observer 範圍同源 — selector 起點就是 observer root 的選擇基礎</td>
      </tr>
      <tr>
          <td><a href="../runtime-iteration-and-regex-cost/">#34 Runtime 計算成本</a></td>
          <td>互補 — 本篇看「觸發次數」、#34 看「單次 callback 成本」</td>
      </tr>
      <tr>
          <td><a href="../minimum-necessary-scope-is-sanity-defense/">#43 最小必要範圍</a></td>
          <td>「縮監聽範圍」是「最小必要範圍」原則的應用</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該檢查的位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>使用者操作後瀏覽器卡頓</td>
          <td>該操作觸發了哪些 observer、各自觸發次數</td>
      </tr>
      <tr>
          <td>CPU 持續 100%</td>
          <td>observer 自激迴圈（特別是 ResizeObserver）</td>
      </tr>
      <tr>
          <td><code>setTimeout(0)</code> 也來不及處理</td>
          <td>observer / event 觸發頻率超過 schedule 處理速度</td>
      </tr>
      <tr>
          <td>Callback 內加 console.count 數字爆炸</td>
          <td>observer 範圍過寬 — 收斂方式由 <a href="../mutation-observer-scope/">#29</a> 處理</td>
      </tr>
      <tr>
          <td>ResizeObserver 在某 callback 後持續觸發</td>
          <td>寫的變數反向影響觀察元素 — 結構分離</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：reactive 監聽器的效能 audit = 列所有 listener + 量觸發次數 + 比對預期。發現問題後、設計修正方式由 <a href="../mutation-observer-scope/">#29</a> 等設計指引篇展開 — 本篇只負責「找問題」這一步。</p>
]]></content:encoded></item><item><title>Runtime 計算成本：每筆迭代與正則</title><link>https://tarrragon.github.io/blog/report/runtime-iteration-and-regex-cost/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/runtime-iteration-and-regex-cost/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>每筆迭代的成本 = 單次計算 × 迭代次數。&lt;/strong> 兩個變數都會放大效能問題；單次計算便宜時、迭代次數變多仍可能爆掉 frame budget。盤點時兩維度一起看、不只看單筆。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼迭代次數值得獨立看待">為什麼迭代次數值得獨立看待&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>開發階段測試的資料量通常少（10 筆結果）— 單次迭代 + 10 次 = 不痛。&lt;/p>
&lt;p>上線後資料量放大（200 筆結果）— 同樣的單次計算 × 200 = 痛。&lt;/p>
&lt;p>&lt;strong>單次計算的最佳化收益是固定倍數、迭代次數的成長是線性放大&lt;/strong> — 後者更值得關注。&lt;/p>
&lt;h3 id="三類迭代成本">三類迭代成本&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>類型&lt;/th>
 &lt;th>例&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>對 DOM 集合迭代&lt;/td>
 &lt;td>&lt;code>forEach&lt;/code> over &lt;code>querySelectorAll&lt;/code> 結果&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>對資料陣列迭代&lt;/td>
 &lt;td>&lt;code>map&lt;/code> / &lt;code>filter&lt;/code> over 大量物件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>對 DOM 樹遞迴&lt;/td>
 &lt;td>&lt;code>.contains()&lt;/code> 或 ancestor walk&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每類有不同的優化策略、共通是「先量規模再決定動哪」。&lt;/p>
&lt;hr>
&lt;h2 id="搜尋頁的具體風險點">搜尋頁的具體風險點&lt;/h2>
&lt;h3 id="風險-1scope-filter-對每筆-result-跑-regex">風險 1：scope filter 對每筆 result 跑 regex&lt;/h3>
&lt;p>&lt;strong>位置&lt;/strong>：&lt;code>assets/search.js&lt;/code> 的 &lt;code>apply()&lt;/code>。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">items&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">titleEl&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__result-title&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">excerptEl&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__result-excerpt&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">title&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">titleEl&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="nx">titleEl&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">textContent&lt;/span> &lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">excerpt&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">excerptEl&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="nx">excerptEl&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">textContent&lt;/span> &lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">show&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">scope&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s1">&amp;#39;title&amp;#39;&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="nx">re&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">title&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">:&lt;/span> &lt;span class="nx">re&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">excerpt&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每筆 result 做的事：&lt;/p>
&lt;ol>
&lt;li>兩次 &lt;code>querySelector&lt;/code>（DOM 查詢）&lt;/li>
&lt;li>兩次 &lt;code>textContent&lt;/code> 讀取（DOM 屬性讀取）&lt;/li>
&lt;li>一次 &lt;code>re.test&lt;/code>（正則比對）&lt;/li>
&lt;li>一次 &lt;code>classList.toggle&lt;/code>（class 操作）&lt;/li>
&lt;/ol>
&lt;p>單筆 ~0.1ms 等級、看 DOM 大小。&lt;/p>
&lt;p>&lt;strong>判讀&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>結果 10 筆 → 1ms、無感&lt;/li>
&lt;li>結果 100 筆 → 10ms、接近 frame budget（16.67ms）&lt;/li>
&lt;li>結果 500 筆 → 50ms、明顯卡頓&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>症狀&lt;/strong>：使用者打字時 input lag、scroll jank。&lt;/p>
&lt;p>&lt;strong>第一個該查的&lt;/strong>：DevTools Performance 面板錄一次 apply、看 forEach 那段佔多少。&amp;gt; 5ms 開始考慮優化。&lt;/p>
&lt;h3 id="風險-2textcontent-讀取的隱藏成本">風險 2：textContent 讀取的隱藏成本&lt;/h3>
&lt;p>&lt;strong>位置&lt;/strong>：上述 &lt;code>titleEl.textContent&lt;/code>。&lt;/p>
&lt;p>&lt;strong>判讀&lt;/strong>：&lt;code>textContent&lt;/code> 看似簡單、實際在某些瀏覽器中要 traverse 整個子樹拼字串。對於有 highlight &lt;code>&amp;lt;mark&amp;gt;&lt;/code> 標籤的結果、textContent 要組合多個 text node。&lt;/p>
&lt;p>&lt;strong>症狀&lt;/strong>：textContent 比預期慢、特別在 result 內結構複雜時。&lt;/p>
&lt;p>&lt;strong>第一個該查的&lt;/strong>：用 &lt;code>console.time&lt;/code> 量一次 textContent 讀取、看單次幾 ms。&lt;/p>
&lt;h3 id="風險-3每次-apply-都重新-queryselector">風險 3：每次 apply 都重新 querySelector&lt;/h3>
&lt;p>&lt;strong>位置&lt;/strong>：&lt;code>apply()&lt;/code> 每次跑都 &lt;code>document.querySelectorAll('.pagefind-ui__result')&lt;/code>。&lt;/p>
&lt;p>&lt;strong>判讀&lt;/strong>：querySelector 是 fresh 查詢、不快取。每次 apply 都重新掃 DOM 找到結果集合。&lt;/p>
&lt;p>&lt;strong>症狀&lt;/strong>：apply 觸發頻繁時、querySelector 是固定開銷。&lt;/p>
&lt;p>&lt;strong>第一個該查的&lt;/strong>：把結果集合 cache 一份、observer 觸發時更新 cache、apply 用 cache 不重查 DOM。&lt;/p>
&lt;h3 id="風險-4regex-編譯成本">風險 4：Regex 編譯成本&lt;/h3>
&lt;p>&lt;strong>位置&lt;/strong>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">re&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nb">RegExp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">escapeRegex&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">query&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="s1">&amp;#39;i&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每次 apply 編譯一次 regex。&lt;/p>
&lt;p>&lt;strong>判讀&lt;/strong>：Regex 編譯成本比想像中重 — 對複雜 pattern 可達數 ms。&lt;/p>
&lt;p>&lt;strong>症狀&lt;/strong>：query 字串長、apply 觸發頻繁時、regex 編譯佔 frame budget。&lt;/p>
&lt;p>&lt;strong>第一個該查的&lt;/strong>：把 regex cache 起來、query 變動才重編譯。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>每筆迭代的成本 = 單次計算 × 迭代次數。</strong> 兩個變數都會放大效能問題；單次計算便宜時、迭代次數變多仍可能爆掉 frame budget。盤點時兩維度一起看、不只看單筆。</p>
<hr>
<h2 id="為什麼迭代次數值得獨立看待">為什麼迭代次數值得獨立看待</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>開發階段測試的資料量通常少（10 筆結果）— 單次迭代 + 10 次 = 不痛。</p>
<p>上線後資料量放大（200 筆結果）— 同樣的單次計算 × 200 = 痛。</p>
<p><strong>單次計算的最佳化收益是固定倍數、迭代次數的成長是線性放大</strong> — 後者更值得關注。</p>
<h3 id="三類迭代成本">三類迭代成本</h3>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>對 DOM 集合迭代</td>
          <td><code>forEach</code> over <code>querySelectorAll</code> 結果</td>
      </tr>
      <tr>
          <td>對資料陣列迭代</td>
          <td><code>map</code> / <code>filter</code> over 大量物件</td>
      </tr>
      <tr>
          <td>對 DOM 樹遞迴</td>
          <td><code>.contains()</code> 或 ancestor walk</td>
      </tr>
  </tbody>
</table>
<p>每類有不同的優化策略、共通是「先量規模再決定動哪」。</p>
<hr>
<h2 id="搜尋頁的具體風險點">搜尋頁的具體風險點</h2>
<h3 id="風險-1scope-filter-對每筆-result-跑-regex">風險 1：scope filter 對每筆 result 跑 regex</h3>
<p><strong>位置</strong>：<code>assets/search.js</code> 的 <code>apply()</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">items</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">var</span> <span class="nx">titleEl</span>   <span class="o">=</span> <span class="nx">el</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__result-title&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">var</span> <span class="nx">excerptEl</span> <span class="o">=</span> <span class="nx">el</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__result-excerpt&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="kd">var</span> <span class="nx">title</span>   <span class="o">=</span> <span class="nx">titleEl</span>   <span class="o">?</span> <span class="nx">titleEl</span><span class="p">.</span><span class="nx">textContent</span>   <span class="o">:</span> <span class="s1">&#39;&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="kd">var</span> <span class="nx">excerpt</span> <span class="o">=</span> <span class="nx">excerptEl</span> <span class="o">?</span> <span class="nx">excerptEl</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">:</span> <span class="s1">&#39;&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="kd">var</span> <span class="nx">show</span> <span class="o">=</span> <span class="nx">scope</span> <span class="o">===</span> <span class="s1">&#39;title&#39;</span> <span class="o">?</span> <span class="nx">re</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">title</span><span class="p">)</span> <span class="o">:</span> <span class="nx">re</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">excerpt</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="c1">// ...
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p>每筆 result 做的事：</p>
<ol>
<li>兩次 <code>querySelector</code>（DOM 查詢）</li>
<li>兩次 <code>textContent</code> 讀取（DOM 屬性讀取）</li>
<li>一次 <code>re.test</code>（正則比對）</li>
<li>一次 <code>classList.toggle</code>（class 操作）</li>
</ol>
<p>單筆 ~0.1ms 等級、看 DOM 大小。</p>
<p><strong>判讀</strong>：</p>
<ul>
<li>結果 10 筆 → 1ms、無感</li>
<li>結果 100 筆 → 10ms、接近 frame budget（16.67ms）</li>
<li>結果 500 筆 → 50ms、明顯卡頓</li>
</ul>
<p><strong>症狀</strong>：使用者打字時 input lag、scroll jank。</p>
<p><strong>第一個該查的</strong>：DevTools Performance 面板錄一次 apply、看 forEach 那段佔多少。&gt; 5ms 開始考慮優化。</p>
<h3 id="風險-2textcontent-讀取的隱藏成本">風險 2：textContent 讀取的隱藏成本</h3>
<p><strong>位置</strong>：上述 <code>titleEl.textContent</code>。</p>
<p><strong>判讀</strong>：<code>textContent</code> 看似簡單、實際在某些瀏覽器中要 traverse 整個子樹拼字串。對於有 highlight <code>&lt;mark&gt;</code> 標籤的結果、textContent 要組合多個 text node。</p>
<p><strong>症狀</strong>：textContent 比預期慢、特別在 result 內結構複雜時。</p>
<p><strong>第一個該查的</strong>：用 <code>console.time</code> 量一次 textContent 讀取、看單次幾 ms。</p>
<h3 id="風險-3每次-apply-都重新-queryselector">風險 3：每次 apply 都重新 querySelector</h3>
<p><strong>位置</strong>：<code>apply()</code> 每次跑都 <code>document.querySelectorAll('.pagefind-ui__result')</code>。</p>
<p><strong>判讀</strong>：querySelector 是 fresh 查詢、不快取。每次 apply 都重新掃 DOM 找到結果集合。</p>
<p><strong>症狀</strong>：apply 觸發頻繁時、querySelector 是固定開銷。</p>
<p><strong>第一個該查的</strong>：把結果集合 cache 一份、observer 觸發時更新 cache、apply 用 cache 不重查 DOM。</p>
<h3 id="風險-4regex-編譯成本">風險 4：Regex 編譯成本</h3>
<p><strong>位置</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">re</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">RegExp</span><span class="p">(</span><span class="nx">escapeRegex</span><span class="p">(</span><span class="nx">query</span><span class="p">),</span> <span class="s1">&#39;i&#39;</span><span class="p">);</span></span></span></code></pre></div><p>每次 apply 編譯一次 regex。</p>
<p><strong>判讀</strong>：Regex 編譯成本比想像中重 — 對複雜 pattern 可達數 ms。</p>
<p><strong>症狀</strong>：query 字串長、apply 觸發頻繁時、regex 編譯佔 frame budget。</p>
<p><strong>第一個該查的</strong>：把 regex cache 起來、query 變動才重編譯。</p>
<hr>
<h2 id="內在屬性比較四種優化方向">內在屬性比較：四種優化方向</h2>
<table>
  <thead>
      <tr>
          <th>方向</th>
          <th>縮減幅度</th>
          <th>複雜度</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>縮迭代次數（IntersectionObserver 只處理可視區）</td>
          <td>大</td>
          <td>中</td>
          <td>結果數量大、多數不在可視範圍</td>
      </tr>
      <tr>
          <td>縮單次計算（cache textContent / regex）</td>
          <td>中</td>
          <td>低</td>
          <td>重複計算同樣的東西</td>
      </tr>
      <tr>
          <td>分批處理（requestIdleCallback / chunk）</td>
          <td>大 — 攤開時間</td>
          <td>中</td>
          <td>一次處理量大但可延後</td>
      </tr>
      <tr>
          <td>Web Worker</td>
          <td>最大 — 獨立 thread</td>
          <td>高</td>
          <td>純計算密集、跟 DOM 無關</td>
      </tr>
  </tbody>
</table>
<p>對 scope filter 的場景：<strong>IntersectionObserver 只處理可視區</strong> + <strong>regex cache</strong> 是性價比最高的兩項。</p>
<hr>
<h2 id="規模放大的盤點">規模放大的盤點</h2>
<p>對每個迭代的 callback、預先估算「規模放大時會怎樣」：</p>
<table>
  <thead>
      <tr>
          <th>當前規模</th>
          <th>10x 規模</th>
          <th>100x 規模</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>10 筆 result × 0.1ms = 1ms</td>
          <td>100 筆 = 10ms（接近 16ms 上限）</td>
          <td>1000 筆 = 100ms（明顯卡）</td>
      </tr>
  </tbody>
</table>
<p>10x / 100x 的數字是「未來內容增長 1 個 / 2 個數量級」的預警。當前 fine 但 10x 後不 fine、值得提前考慮優化機制。</p>
<hr>
<h2 id="設計取捨per-item-迭代成本的優化策略">設計取捨：per-item 迭代成本的優化策略</h2>
<p>四種做法、各自機會成本不同。預設先做 A（縮迭代次數）、A 不夠才考慮 B/C/D。</p>
<h3 id="a縮迭代次數intersectionobserver--分頁--過濾這個專案的預設">A：縮迭代次數（IntersectionObserver / 分頁 / 過濾）（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：用 IntersectionObserver 只處理可視區、用過濾條件預先排除大量項目</li>
<li><strong>選 A 的理由</strong>：縮減幅度大（線性放大反向操作）、callback 內部不變</li>
<li><strong>適合</strong>：結果數量大、但實際需要處理的部分少（多數在可視區外）</li>
<li><strong>代價</strong>：增加 observer setup、需要設計「該處理什麼項目」的判斷</li>
</ul>
<h3 id="b縮單次計算cache-textcontent--regex--dom-query">B：縮單次計算（cache textContent / regex / DOM query）</h3>
<ul>
<li><strong>機制</strong>：把重複計算的結果 cache、避免每次重做</li>
<li><strong>跟 A 的取捨</strong>：B 縮減幅度中等（看 cache 命中率）、A 縮減幅度大；兩者解不同問題、可並用</li>
<li><strong>B 比 A 好的情境</strong>：迭代次數無法縮（必須處理所有項目）、但每項計算重複（regex 編譯、textContent 重讀）</li>
</ul>
<h3 id="c分批處理requestidlecallback--chunk">C：分批處理（requestIdleCallback / chunk）</h3>
<ul>
<li><strong>機制</strong>：把一次處理拆成多次、攤開到多個 frame</li>
<li><strong>跟 A/B 的取捨</strong>：C 攤開時間、A/B 縮減總時間；C 在「總時間無法縮、但可以延後」時合理</li>
<li><strong>C 比 A 好的情境</strong>：處理量大但可延後（initial render 時的非關鍵 enhancement）</li>
</ul>
<h3 id="dweb-worker">D：Web Worker</h3>
<ul>
<li><strong>機制</strong>：把計算搬到獨立 thread</li>
<li><strong>跟 A/B/C 的取捨</strong>：D 完全不阻 main thread、但 setup 成本高（postMessage 序列化）</li>
<li><strong>D 才合理的情境</strong>：純計算密集、跟 DOM 無關（搜尋 indexing、複雜資料處理）— 對 DOM 操作沒意義（Web Worker 不能直接動 DOM）</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該檢查的位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>forEach over 大集合佔用 frame budget</td>
          <td>用 IntersectionObserver 只處理可視區</td>
      </tr>
      <tr>
          <td>每次 apply 重做相同的查詢 / 編譯</td>
          <td>Cache 結果、變動觸發時更新 cache</td>
      </tr>
      <tr>
          <td>Async 處理可接受時還在同步跑</td>
          <td>改 requestIdleCallback / 分批 setTimeout</td>
      </tr>
      <tr>
          <td>資料量比測試時大 N 倍後才發現問題</td>
          <td>開發時做規模 10x / 100x 預估</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：「每筆都做」的計算成本 = 每筆 × 筆數。優化時兩維度都看、不要只盯單次。</p>
]]></content:encoded></item><item><title>動態 DOM 移動時的 focus 管理</title><link>https://tarrragon.github.io/blog/report/focus-management-on-dom-move/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/focus-management-on-dom-move/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>JS 移動或隱藏 DOM 元素時、鍵盤 focus 的命運要主動處理 — 不處理會跑掉或停在不可見元素上、鍵盤使用者瞬間迷失方向。&lt;/strong> 多數動態 UI 的 focus 問題不是「某個元素該 focusable」、是「某個變動沒考慮 focus 該去哪」。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼-focus-管理需要主動處理">為什麼 focus 管理需要主動處理&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>鍵盤使用者依 focus 知道「現在在哪」。focus 變動有三種來源：&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>使用者主動（Tab、Enter、方向鍵）&lt;/td>
 &lt;td>預期、無需處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Focus 元素被移除&lt;/td>
 &lt;td>focus 跳到 body — 使用者迷失&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Focus 元素被 reparent&lt;/td>
 &lt;td>看瀏覽器、可能 focus 仍在元素上、可能掉失&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第二、三類是 JS 變動 DOM 引起的副作用、開發者要主動處理。&lt;/p>
&lt;h3 id="三類-dom-變動對-focus-的影響">三類 DOM 變動對 focus 的影響&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>變動類型&lt;/th>
 &lt;th>Focus 行為&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>整節點 reparent（appendChild）&lt;/td>
 &lt;td>視瀏覽器、Chrome 多半保留 focus、Safari 可能掉&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>節點 remove&lt;/td>
 &lt;td>focus 跳到 body&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>節點 display: none&lt;/td>
 &lt;td>focus 跳到 body&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>節點 visibility: hidden&lt;/td>
 &lt;td>focus 仍在但元素不可見、使用者迷失&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每類有對應的處理 — 主要是「事前 save、事後 restore」。&lt;/p>
&lt;hr>
&lt;h2 id="搜尋頁的具體風險點">搜尋頁的具體風險點&lt;/h2>
&lt;h3 id="風險-1filter-slot-跨-viewport-切換">風險 1：Filter slot 跨 viewport 切換&lt;/h3>
&lt;p>&lt;strong>位置&lt;/strong>：matchMedia callback 的 &lt;code>place()&lt;/code> 函式。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">place&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">mql&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">matches&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">slot&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">appendChild&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">else&lt;/span> &lt;span class="nx">drawer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">insertBefore&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">drawer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">firstChild&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>判讀&lt;/strong>：使用者鍵盤 focus 在 filter 內某個 checkbox、視窗 resize 跨過 1400px、&lt;code>appendChild&lt;/code> 把 filter 整個搬到別處。理論上 focus 跟著節點走、實際視瀏覽器。&lt;/p>
&lt;p>&lt;strong>症狀&lt;/strong>：使用者按 tab 進到 filter checkbox、調視窗寬度跨 breakpoint、focus 突然在 body 或其他位置。&lt;/p>
&lt;p>&lt;strong>第一個該查的&lt;/strong>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">place&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">activeBefore&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">activeElement&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">mql&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">matches&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">slot&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">appendChild&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">else&lt;/span> &lt;span class="nx">drawer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">insertBefore&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">drawer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">firstChild&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 嘗試還原 focus
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">activeBefore&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">filter&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">contains&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">activeBefore&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="nx">activeBefore&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">focus&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>activeElement&lt;/code> 在 reparent 前後仍指向同一個 DOM 節點（如果 focus 在 filter 內）。明確 &lt;code>.focus()&lt;/code> 確保視覺一致。&lt;/p>
&lt;h3 id="風險-2scope-filter-隱藏當前-focus-元素">風險 2：Scope filter 隱藏當前 focus 元素&lt;/h3>
&lt;p>&lt;strong>位置&lt;/strong>：scope filter 的 &lt;code>apply()&lt;/code>。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">items&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">classList&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">toggle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;is-scope-filtered&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">!&lt;/span>&lt;span class="nx">show&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>判讀&lt;/strong>：若使用者 focus 在某個 result（例如標題連結）、切換 scope 後該 result 被隱藏（display: none）— focus 跳到 body。&lt;/p>
&lt;p>&lt;strong>症狀&lt;/strong>：使用者 tab 到 result、切 scope、focus 不見了。&lt;/p>
&lt;p>&lt;strong>第一個該查的&lt;/strong>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">apply&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">activeBefore&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">activeElement&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ... 套用 scope filter
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">activeBefore&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">getComputedStyle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">activeBefore&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">display&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s1">&amp;#39;none&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 該元素被隱藏、focus 移到下一個可見的同類元素
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">var&lt;/span> &lt;span class="nx">nextResult&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">findNextVisibleResult&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">activeBefore&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">nextResult&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">nextResult&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">focus&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">else&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">focus&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// 沒有下一個就回到 search input
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>明確處理「focus 元素被隱藏時去哪」、不留給瀏覽器預設行為。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>JS 移動或隱藏 DOM 元素時、鍵盤 focus 的命運要主動處理 — 不處理會跑掉或停在不可見元素上、鍵盤使用者瞬間迷失方向。</strong> 多數動態 UI 的 focus 問題不是「某個元素該 focusable」、是「某個變動沒考慮 focus 該去哪」。</p>
<hr>
<h2 id="為什麼-focus-管理需要主動處理">為什麼 focus 管理需要主動處理</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>鍵盤使用者依 focus 知道「現在在哪」。focus 變動有三種來源：</p>
<table>
  <thead>
      <tr>
          <th>來源</th>
          <th>含義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>使用者主動（Tab、Enter、方向鍵）</td>
          <td>預期、無需處理</td>
      </tr>
      <tr>
          <td>Focus 元素被移除</td>
          <td>focus 跳到 body — 使用者迷失</td>
      </tr>
      <tr>
          <td>Focus 元素被 reparent</td>
          <td>看瀏覽器、可能 focus 仍在元素上、可能掉失</td>
      </tr>
  </tbody>
</table>
<p>第二、三類是 JS 變動 DOM 引起的副作用、開發者要主動處理。</p>
<h3 id="三類-dom-變動對-focus-的影響">三類 DOM 變動對 focus 的影響</h3>
<table>
  <thead>
      <tr>
          <th>變動類型</th>
          <th>Focus 行為</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>整節點 reparent（appendChild）</td>
          <td>視瀏覽器、Chrome 多半保留 focus、Safari 可能掉</td>
      </tr>
      <tr>
          <td>節點 remove</td>
          <td>focus 跳到 body</td>
      </tr>
      <tr>
          <td>節點 display: none</td>
          <td>focus 跳到 body</td>
      </tr>
      <tr>
          <td>節點 visibility: hidden</td>
          <td>focus 仍在但元素不可見、使用者迷失</td>
      </tr>
  </tbody>
</table>
<p>每類有對應的處理 — 主要是「事前 save、事後 restore」。</p>
<hr>
<h2 id="搜尋頁的具體風險點">搜尋頁的具體風險點</h2>
<h3 id="風險-1filter-slot-跨-viewport-切換">風險 1：Filter slot 跨 viewport 切換</h3>
<p><strong>位置</strong>：matchMedia callback 的 <code>place()</code> 函式。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">place</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">mql</span><span class="p">.</span><span class="nx">matches</span><span class="p">)</span> <span class="nx">slot</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">filter</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">else</span> <span class="nx">drawer</span><span class="p">.</span><span class="nx">insertBefore</span><span class="p">(</span><span class="nx">filter</span><span class="p">,</span> <span class="nx">drawer</span><span class="p">.</span><span class="nx">firstChild</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>判讀</strong>：使用者鍵盤 focus 在 filter 內某個 checkbox、視窗 resize 跨過 1400px、<code>appendChild</code> 把 filter 整個搬到別處。理論上 focus 跟著節點走、實際視瀏覽器。</p>
<p><strong>症狀</strong>：使用者按 tab 進到 filter checkbox、調視窗寬度跨 breakpoint、focus 突然在 body 或其他位置。</p>
<p><strong>第一個該查的</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">place</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">var</span> <span class="nx">activeBefore</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">activeElement</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">mql</span><span class="p">.</span><span class="nx">matches</span><span class="p">)</span> <span class="nx">slot</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">filter</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">else</span> <span class="nx">drawer</span><span class="p">.</span><span class="nx">insertBefore</span><span class="p">(</span><span class="nx">filter</span><span class="p">,</span> <span class="nx">drawer</span><span class="p">.</span><span class="nx">firstChild</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="c1">// 嘗試還原 focus
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>  <span class="k">if</span> <span class="p">(</span><span class="nx">activeBefore</span> <span class="o">&amp;&amp;</span> <span class="nx">filter</span><span class="p">.</span><span class="nx">contains</span><span class="p">(</span><span class="nx">activeBefore</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">activeBefore</span><span class="p">.</span><span class="nx">focus</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>activeElement</code> 在 reparent 前後仍指向同一個 DOM 節點（如果 focus 在 filter 內）。明確 <code>.focus()</code> 確保視覺一致。</p>
<h3 id="風險-2scope-filter-隱藏當前-focus-元素">風險 2：Scope filter 隱藏當前 focus 元素</h3>
<p><strong>位置</strong>：scope filter 的 <code>apply()</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">items</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">el</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nx">toggle</span><span class="p">(</span><span class="s1">&#39;is-scope-filtered&#39;</span><span class="p">,</span> <span class="o">!</span><span class="nx">show</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p><strong>判讀</strong>：若使用者 focus 在某個 result（例如標題連結）、切換 scope 後該 result 被隱藏（display: none）— focus 跳到 body。</p>
<p><strong>症狀</strong>：使用者 tab 到 result、切 scope、focus 不見了。</p>
<p><strong>第一個該查的</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">apply</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">var</span> <span class="nx">activeBefore</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">activeElement</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="c1">// ... 套用 scope filter
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>  <span class="k">if</span> <span class="p">(</span><span class="nx">activeBefore</span> <span class="o">&amp;&amp;</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nx">activeBefore</span><span class="p">).</span><span class="nx">display</span> <span class="o">===</span> <span class="s1">&#39;none&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="c1">// 該元素被隱藏、focus 移到下一個可見的同類元素
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>    <span class="kd">var</span> <span class="nx">nextResult</span> <span class="o">=</span> <span class="nx">findNextVisibleResult</span><span class="p">(</span><span class="nx">activeBefore</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">nextResult</span><span class="p">)</span> <span class="nx">nextResult</span><span class="p">.</span><span class="nx">focus</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">else</span> <span class="nx">input</span><span class="p">.</span><span class="nx">focus</span><span class="p">();</span>   <span class="c1">// 沒有下一個就回到 search input
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span>  <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>明確處理「focus 元素被隱藏時去哪」、不留給瀏覽器預設行為。</p>
<h3 id="風險-3pagefind-重繪結果時-focus-流失">風險 3：Pagefind 重繪結果時 focus 流失</h3>
<p><strong>位置</strong>：使用者改 query 時、pagefind 重新渲染結果列表。</p>
<p><strong>判讀</strong>：若使用者 tab 到第 1 個結果、修改 query、pagefind 替換整個結果列表 — 第 1 個結果被 remove、focus 跳到 body。</p>
<p><strong>症狀</strong>：使用者打字過程中、tab 順序時不時被打回起點。</p>
<p><strong>第一個該查的</strong>：這個情境較難解 — 框架管的 DOM 我們不能干預。可行的做法：</p>
<ul>
<li>使用者打字時通常在 input 上、focus 不在結果列表 — 影響面小</li>
<li>若真有需要、用 tabindex / aria-activedescendant 模擬 focus 但不實際 focus DOM</li>
</ul>
<h3 id="風險-4載入-pagefind-ui-時-focus-行為">風險 4：載入 pagefind UI 時 focus 行為</h3>
<p><strong>位置</strong>：頁面載入後 PagefindUI mount 約 200-500ms。</p>
<p><strong>判讀</strong>：使用者開啟搜尋頁、瀏覽器把 focus 放 body、使用者按 tab — 應該到搜尋輸入框。</p>
<p><strong>症狀</strong>：使用者開頁面立刻按 tab、focus 跳到網站其他部分（nav、其他 link）、不是搜尋框。</p>
<p><strong>第一個該查的</strong>：考慮頁面載入後自動 focus 搜尋輸入框（auto-focus）— 對搜尋頁是合理 UX、不是干擾。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">waitForElement</span><span class="p">(</span><span class="nx">searchRoot</span><span class="p">,</span> <span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">,</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">input</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">input</span><span class="p">.</span><span class="nx">focus</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><hr>
<h2 id="內在屬性比較四種-focus-處理策略">內在屬性比較：四種 focus 處理策略</h2>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>維護成本</th>
          <th>涵蓋情境</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>不處理（瀏覽器預設）</td>
          <td>低</td>
          <td>簡單情境</td>
          <td>focus 掉失常見</td>
      </tr>
      <tr>
          <td>Save / restore activeElement</td>
          <td>中</td>
          <td>DOM 移動、隱藏</td>
          <td>大多有效</td>
      </tr>
      <tr>
          <td>用 tabindex / aria-activedescendant 模擬 focus</td>
          <td>高</td>
          <td>框架管的 DOM</td>
          <td>較複雜、視框架行為</td>
      </tr>
      <tr>
          <td>Auto-focus 關鍵元素</td>
          <td>低</td>
          <td>頁面載入、modal 開啟</td>
          <td>使用者預期才適用</td>
      </tr>
  </tbody>
</table>
<p>選擇順序：<strong>簡單變動用 save / restore；framework 管的 DOM 用模擬 focus；關鍵元素用 auto-focus</strong>。</p>
<hr>
<h2 id="盤點-focus-影響的具體步驟">盤點 focus 影響的具體步驟</h2>
<p>對每個 JS 變動 DOM 的位置、列三個問題：</p>
<ol>
<li><strong>這個變動會 reparent / remove / hide 哪些元素？</strong></li>
<li><strong>這些元素有可能是當前 focus 嗎？</strong> （form input、checkbox、link 都是常見 focusable）</li>
<li><strong>若是、focus 該去哪？</strong> （restore / next sibling / 預設位置）</li>
</ol>
<p>回答完三題、變動前後加 save / restore 邏輯。</p>
<hr>
<h2 id="設計取捨dom-變動時的-focus-處理策略">設計取捨：DOM 變動時的 focus 處理策略</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（save / restore activeElement）當預設、其他做法在特定情境合理。</p>
<h3 id="asave--restore-activeelement這個專案的預設">A：Save / restore activeElement（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：JS 變動 DOM 前 <code>var activeBefore = document.activeElement</code>、變動後 <code>activeBefore.focus()</code></li>
<li><strong>選 A 的理由</strong>：跨瀏覽器一致、簡單元件移動 / 顯隱都涵蓋</li>
<li><strong>適合</strong>：自家管的 DOM 變動（reparent、display: none、remove）</li>
<li><strong>代價</strong>：每個變動位置要顯式加 save / restore 邏輯（用 helper 包裝可一行）</li>
</ul>
<h3 id="b不處理依瀏覽器預設">B：不處理（依瀏覽器預設）</h3>
<ul>
<li><strong>機制</strong>：JS 變動 DOM、不主動處理 focus</li>
<li><strong>跟 A 的取捨</strong>：B 簡單、A 有額外邏輯；但 B 結果不一致（Chrome / Safari 不同）、多數預設是「focus 跳 body」、使用者迷失</li>
<li><strong>B 才合理的情境</strong>：純展示元素變動（沒有 focusable 子元素）— 不會發生 focus 掉失</li>
</ul>
<h3 id="c用-tabindex--aria-activedescendant-模擬-focus">C：用 tabindex / aria-activedescendant 模擬 focus</h3>
<ul>
<li><strong>機制</strong>：focus 物理上不動、用 attribute 標記「邏輯 focus」</li>
<li><strong>跟 A 的取捨</strong>：C 比 A 複雜、但能處理 framework 管的 DOM（無法 save / restore）</li>
<li><strong>C 比 A 好的情境</strong>：framework 持續重繪元素 identity、save / restore 失敗 — 用 attribute 表達 focus</li>
</ul>
<h3 id="dauto-focus-關鍵元素">D：Auto-focus 關鍵元素</h3>
<ul>
<li><strong>機制</strong>：頁面載入後 / modal 開啟後自動 focus 預期的第一個元素</li>
<li><strong>跟 A 的取捨</strong>：D 不依變動觸發、A 對應變動處理；D 適合「使用者預期」的初始 focus</li>
<li><strong>D 比 A 好的情境</strong>：搜尋頁載入 → focus search input、modal 開啟 → focus 第一個 input — 使用者預期的場景才用</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該檢查的位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>鍵盤使用者 tab 中途 focus 突然跳走</td>
          <td>該時點是否有 JS 變動 DOM</td>
      </tr>
      <tr>
          <td>Resize 視窗後 focus 不見</td>
          <td>matchMedia callback 內加 save / restore</td>
      </tr>
      <tr>
          <td>切 filter / mode 後 focus 在 body</td>
          <td>apply 函式內處理被隱藏元素的 focus</td>
      </tr>
      <tr>
          <td>開頁面立刻按 tab 跳到不對位置</td>
          <td>評估是否該 auto-focus 主要互動元素</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：JS 變動 DOM = focus 副作用。每個變動位置都該回答「focus 該去哪」、不留給瀏覽器預設。</p>
<p>跟 <a href="../loading-empty-end-state-distinction/">#57 Loading / Empty / End 三狀態</a> 共骨：兩者都是「狀態變動需要回答對應的 UX 問題」 — #57 講「使用者看到的訊號」、本卡講「鍵盤使用者的 focus 位置」。動態 UI 設計 = 狀態變動 + 狀態變動的 UX + 狀態變動的 a11y 三個維度同時設計。</p>
]]></content:encoded></item><item><title>Pattern：Document 全文件 query</title><link>https://tarrragon.github.io/blog/report/pattern-document-query/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/pattern-document-query/</guid><description>&lt;h2 id="核心做法">核心做法&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.target&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelectorAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.target&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>從整個頁面找元素、不指定 ancestor scope。&lt;/p>
&lt;hr>
&lt;h2 id="這個做法存在的價值">這個做法存在的價值&lt;/h2>
&lt;p>簡潔。一行就能取到目標、不需要先建立元件根變數。在「我只想快速確認某個元素在不在 / 取它的某個屬性」這類情境下、寫一個 import shell 變數 + null check 是過度工程。&lt;/p>
&lt;hr>
&lt;h2 id="適合的情境">適合的情境&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>為什麼合理&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Devtools console 一行查詢&lt;/td>
 &lt;td>沒有「未來會壞」的問題、用完就丟&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>原型 / spike 階段程式碼&lt;/td>
 &lt;td>預期會被丟棄重寫、不需要長期維護考量&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>確定全頁唯一的單例（&lt;code>document.body&lt;/code>、&lt;code>&amp;lt;html&amp;gt;&lt;/code>）&lt;/td>
 &lt;td>從定義上不會多個、也不會被誤命中&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Build-time script、不會在 runtime 跑&lt;/td>
 &lt;td>沒有「同頁多元件」的可能性&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>核心特徵：&lt;strong>這段程式不會在多元件 / 動態 DOM 環境長期存活&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="不適合的情境">不適合的情境&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>失敗模式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Production 客製、預期長期存活&lt;/td>
 &lt;td>未來頁面結構變動、誤命中或漏命中&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同頁可能有多個同類元件&lt;/td>
 &lt;td>只取第一個、其他被忽略且不報錯&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元件可能在 SPA 路由中動態增減&lt;/td>
 &lt;td>query 時機跟元件 mount 時機不對齊&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>寫入第三方函式庫&lt;/td>
 &lt;td>使用者頁面的其他 class 可能跟你的 selector 撞&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>安靜失敗是最危險的特徵&lt;/strong> — 不報錯、操作了錯元素、bug 表現遠離 root cause。&lt;/p>
&lt;hr>
&lt;h2 id="跟其他起點做法的關係">跟其他起點做法的關係&lt;/h2>
&lt;p>&lt;a href="../dom-selector-precision/">#14 Selector 精準度&lt;/a> 的「起點」維度有四種做法、document query 是其中之一：&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>本卡片：document query&lt;/td>
 &lt;td>簡潔但不防護未來變動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="../pattern-component-root/">元件根變數&lt;/a>&lt;/td>
 &lt;td>多一行 setup、換到「shell 內隔離」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="../pattern-root-as-parameter/">起點當參數&lt;/a>&lt;/td>
 &lt;td>多實例支援、適合可能擴展的客製&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="../pattern-closest-lookup/">closest 反向找根&lt;/a>&lt;/td>
 &lt;td>事件委派情境、動態元件&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>選擇順序：production 客製預設用「元件根變數」、原型 / 探索 / 一次性才用 document query。&lt;/p>
&lt;hr>
&lt;h2 id="邊界什麼時候-document-query-在-production-也合理">邊界：什麼時候 document query 在 production 也合理&lt;/h2>
&lt;p>幾個常見的 production 例外：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 例外 1：操作的目標就是「全頁面唯一單例」
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">classList&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;page-search&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">documentElement&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setAttribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;data-theme&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;dark&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">// 例外 2：跨元件邊界的元素（不在任何元件內）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">var&lt;/span> &lt;span class="nx">slot&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-filter-slot&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">// (slot 是 main 的子節點、不在 search-shell 內、不能從 shell 找)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">// 例外 3：頁面層級的 meta 元素
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;meta[name=&amp;#34;description&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>例外都共享一個特徵：&lt;strong>目標元素本質上就在「頁面層級」、不是任何元件的內部&lt;/strong>。&lt;/p>
&lt;p>不是例外的場景、即使「當前頁面只有一個」、也用元件根變數 — 預防未來擴展。&lt;/p>
&lt;hr>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&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;code>document.querySelector('.x')&lt;/code>&lt;/td>
 &lt;td>是 — 至少改成存變數重用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>寫第三方 library 用 document query&lt;/td>
 &lt;td>是 — 改用根參數 pattern&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>操作 &lt;code>document.body&lt;/code> / &lt;code>&amp;lt;html&amp;gt;&lt;/code>&lt;/td>
 &lt;td>否 — 這就是合理場景&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>程式跑一次後丟棄（migration script）&lt;/td>
 &lt;td>否 — 簡潔優先&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>核心原則&lt;/strong>：document query 不是反模式、是有適用範圍的工具。判斷「這段程式預期活多久」 — 短命用 document、長命用元件根。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心做法">核心做法</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">);</span></span></span></code></pre></div><p>從整個頁面找元素、不指定 ancestor scope。</p>
<hr>
<h2 id="這個做法存在的價值">這個做法存在的價值</h2>
<p>簡潔。一行就能取到目標、不需要先建立元件根變數。在「我只想快速確認某個元素在不在 / 取它的某個屬性」這類情境下、寫一個 import shell 變數 + null check 是過度工程。</p>
<hr>
<h2 id="適合的情境">適合的情境</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼合理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Devtools console 一行查詢</td>
          <td>沒有「未來會壞」的問題、用完就丟</td>
      </tr>
      <tr>
          <td>原型 / spike 階段程式碼</td>
          <td>預期會被丟棄重寫、不需要長期維護考量</td>
      </tr>
      <tr>
          <td>確定全頁唯一的單例（<code>document.body</code>、<code>&lt;html&gt;</code>）</td>
          <td>從定義上不會多個、也不會被誤命中</td>
      </tr>
      <tr>
          <td>Build-time script、不會在 runtime 跑</td>
          <td>沒有「同頁多元件」的可能性</td>
      </tr>
  </tbody>
</table>
<p>核心特徵：<strong>這段程式不會在多元件 / 動態 DOM 環境長期存活</strong>。</p>
<hr>
<h2 id="不適合的情境">不適合的情境</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>失敗模式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Production 客製、預期長期存活</td>
          <td>未來頁面結構變動、誤命中或漏命中</td>
      </tr>
      <tr>
          <td>同頁可能有多個同類元件</td>
          <td>只取第一個、其他被忽略且不報錯</td>
      </tr>
      <tr>
          <td>元件可能在 SPA 路由中動態增減</td>
          <td>query 時機跟元件 mount 時機不對齊</td>
      </tr>
      <tr>
          <td>寫入第三方函式庫</td>
          <td>使用者頁面的其他 class 可能跟你的 selector 撞</td>
      </tr>
  </tbody>
</table>
<p><strong>安靜失敗是最危險的特徵</strong> — 不報錯、操作了錯元素、bug 表現遠離 root cause。</p>
<hr>
<h2 id="跟其他起點做法的關係">跟其他起點做法的關係</h2>
<p><a href="../dom-selector-precision/">#14 Selector 精準度</a> 的「起點」維度有四種做法、document query 是其中之一：</p>
<table>
  <thead>
      <tr>
          <th>做法</th>
          <th>比較</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>本卡片：document query</td>
          <td>簡潔但不防護未來變動</td>
      </tr>
      <tr>
          <td><a href="../pattern-component-root/">元件根變數</a></td>
          <td>多一行 setup、換到「shell 內隔離」</td>
      </tr>
      <tr>
          <td><a href="../pattern-root-as-parameter/">起點當參數</a></td>
          <td>多實例支援、適合可能擴展的客製</td>
      </tr>
      <tr>
          <td><a href="../pattern-closest-lookup/">closest 反向找根</a></td>
          <td>事件委派情境、動態元件</td>
      </tr>
  </tbody>
</table>
<p>選擇順序：production 客製預設用「元件根變數」、原型 / 探索 / 一次性才用 document query。</p>
<hr>
<h2 id="邊界什麼時候-document-query-在-production-也合理">邊界：什麼時候 document query 在 production 也合理</h2>
<p>幾個常見的 production 例外：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 例外 1：操作的目標就是「全頁面唯一單例」
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nx">add</span><span class="p">(</span><span class="s1">&#39;page-search&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-theme&#39;</span><span class="p">,</span> <span class="s1">&#39;dark&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1">// 例外 2：跨元件邊界的元素（不在任何元件內）
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="kd">var</span> <span class="nx">slot</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-filter-slot&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">// (slot 是 main 的子節點、不在 search-shell 內、不能從 shell 找)
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1">// 例外 3：頁面層級的 meta 元素
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;meta[name=&#34;description&#34;]&#39;</span><span class="p">);</span></span></span></code></pre></div><p>例外都共享一個特徵：<strong>目標元素本質上就在「頁面層級」、不是任何元件的內部</strong>。</p>
<p>不是例外的場景、即使「當前頁面只有一個」、也用元件根變數 — 預防未來擴展。</p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該換做法嗎？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>「現在只有一個、之後再想」</td>
          <td>是 — 換元件根變數</td>
      </tr>
      <tr>
          <td>同檔案多處 <code>document.querySelector('.x')</code></td>
          <td>是 — 至少改成存變數重用</td>
      </tr>
      <tr>
          <td>寫第三方 library 用 document query</td>
          <td>是 — 改用根參數 pattern</td>
      </tr>
      <tr>
          <td>操作 <code>document.body</code> / <code>&lt;html&gt;</code></td>
          <td>否 — 這就是合理場景</td>
      </tr>
      <tr>
          <td>程式跑一次後丟棄（migration script）</td>
          <td>否 — 簡潔優先</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：document query 不是反模式、是有適用範圍的工具。判斷「這段程式預期活多久」 — 短命用 document、長命用元件根。</p>
]]></content:encoded></item><item><title>Pattern：元件根變數 query</title><link>https://tarrragon.github.io/blog/report/pattern-component-root/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/pattern-component-root/</guid><description>&lt;h2 id="核心做法">核心做法&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">shell&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-shell&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">input&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">drawer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__drawer&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">// ... 之後所有 query 都從 shell 開始
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>把元件根 query 一次存變數、所有後續 query 都從這個變數開始。&lt;/p>
&lt;hr>
&lt;h2 id="這個做法存在的價值">這個做法存在的價值&lt;/h2>
&lt;p>把 selector 的作用範圍從「全頁面」收斂到「元件內部」。即使未來頁面其他地方出現同名元素、跟我無關。成本只多一行 query + 一個 null check、防護收益大。&lt;/p>
&lt;hr>
&lt;h2 id="適合的情境">適合的情境&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>為什麼合理&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Production 客製、預期長期存活&lt;/td>
 &lt;td>未來頁面結構可能變動、需要隔離&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>當前只有一個元件實例、未來可能加&lt;/td>
 &lt;td>提早預防、改造成本最低&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元件根 mount 後不會被移除&lt;/td>
 &lt;td>變數生命週期跟元件一致&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>程式跑在頁面 mount 後（DOMContentLoaded 後）&lt;/td>
 &lt;td>shell 可被找到&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>核心特徵&lt;/strong>：寫的時候只有一個元件、但希望程式碼能容忍未來頁面結構變動。&lt;/p>
&lt;hr>
&lt;h2 id="不適合的情境">不適合的情境&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &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>變數只存第一個 shell、其他被忽略&lt;/td>
 &lt;td>&lt;a href="../pattern-root-as-parameter/">起點當參數&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元件動態增減（SPA 路由切換）&lt;/td>
 &lt;td>變數指向 stale DOM&lt;/td>
 &lt;td>&lt;a href="../pattern-closest-lookup/">closest 反向找根&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>一次性 / 探索期程式&lt;/td>
 &lt;td>過度工程&lt;/td>
 &lt;td>&lt;a href="../pattern-document-query/">document query&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="設計細節">設計細節&lt;/h2>
&lt;h3 id="null-check-的時機">Null check 的時機&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">shell&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-shell&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>頁面可能沒有 shell（不是搜尋頁），所有後續 query 都會 null pointer。提早 return 比後續一連串 &lt;code>if (drawer)&lt;/code> 乾淨。&lt;/p>
&lt;p>&lt;strong>等同於宣告&lt;/strong>：「這段程式只在有 shell 的頁面執行」。&lt;/p>
&lt;h3 id="變數的宣告位置">變數的宣告位置&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>位置&lt;/th>
 &lt;th>適合&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>函式內 local 變數&lt;/td>
 &lt;td>預設、scope 最小&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Module scope（IIFE 內）&lt;/td>
 &lt;td>多函式共用同一 shell&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Class instance property&lt;/td>
 &lt;td>元件本身用 class 包裝時&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>避免全域變數 — &lt;code>window.shell&lt;/code> 容易跟其他 script 撞。&lt;/p>
&lt;h3 id="等待-shell-mount-的處理">等待 shell mount 的處理&lt;/h3>
&lt;p>如果 script 跑得太早（shell 還沒 mount），shell 會是 null：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 解法 1：等 DOMContentLoaded
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;DOMContentLoaded&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">shell&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-shell&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">// 解法 2：MutationObserver 等 mount
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">var&lt;/span> &lt;span class="nx">bootstrap&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">MutationObserver&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">shell&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-shell&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">bootstrap&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">disconnect&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="nx">bootstrap&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">observe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">body&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">childList&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">subtree&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span> &lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>選擇取決於 shell 是 server-render 還是 client-render&lt;/strong>：server-render 用 DOMContentLoaded、client-render 用 observer。&lt;/p>
&lt;hr>
&lt;h2 id="跟其他起點做法的關係">跟其他起點做法的關係&lt;/h2>
&lt;p>&lt;a href="../dom-selector-precision/">#14 Selector 精準度&lt;/a> 的「起點」維度有四種做法：&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;a href="../pattern-document-query/">document query&lt;/a>&lt;/td>
 &lt;td>比本卡片簡潔、不防護未來變動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>本卡片：元件根變數&lt;/td>
 &lt;td>多一行設定、隔離未來頁面變動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="../pattern-root-as-parameter/">起點當參數&lt;/a>&lt;/td>
 &lt;td>比本卡片多支援多實例、設計成本前移&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="../pattern-closest-lookup/">closest 反向找根&lt;/a>&lt;/td>
 &lt;td>適合動態元件、不依賴變數綁定的時間&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>預設用本卡片、需要多實例升級到「起點當參數」、需要動態升級到「closest」。&lt;/p>
&lt;hr>
&lt;h2 id="應用範例完整-setup">應用範例：完整 setup&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">init&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">shell&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-shell&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">ui&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">input&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">drawer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__drawer&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nx">input&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="o">!&lt;/span>&lt;span class="nx">drawer&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 元件未完整 mount
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">syncScopeHeight&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-scope&amp;#39;&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">setupFilterSlotSwap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">drawer&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">setupScopeFilter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;DOMContentLoaded&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">init&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>shell 取一次、各 setup 函式從 shell 派生需要的子節點。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心做法">核心做法</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">shell</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">shell</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="kd">var</span> <span class="nx">input</span>  <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kd">var</span> <span class="nx">drawer</span> <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">// ... 之後所有 query 都從 shell 開始
</span></span></span></code></pre></div><p>把元件根 query 一次存變數、所有後續 query 都從這個變數開始。</p>
<hr>
<h2 id="這個做法存在的價值">這個做法存在的價值</h2>
<p>把 selector 的作用範圍從「全頁面」收斂到「元件內部」。即使未來頁面其他地方出現同名元素、跟我無關。成本只多一行 query + 一個 null check、防護收益大。</p>
<hr>
<h2 id="適合的情境">適合的情境</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼合理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Production 客製、預期長期存活</td>
          <td>未來頁面結構可能變動、需要隔離</td>
      </tr>
      <tr>
          <td>當前只有一個元件實例、未來可能加</td>
          <td>提早預防、改造成本最低</td>
      </tr>
      <tr>
          <td>元件根 mount 後不會被移除</td>
          <td>變數生命週期跟元件一致</td>
      </tr>
      <tr>
          <td>程式跑在頁面 mount 後（DOMContentLoaded 後）</td>
          <td>shell 可被找到</td>
      </tr>
  </tbody>
</table>
<p><strong>核心特徵</strong>：寫的時候只有一個元件、但希望程式碼能容忍未來頁面結構變動。</p>
<hr>
<h2 id="不適合的情境">不適合的情境</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼不夠</th>
          <th>改用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同頁同時有多個元件實例</td>
          <td>變數只存第一個 shell、其他被忽略</td>
          <td><a href="../pattern-root-as-parameter/">起點當參數</a></td>
      </tr>
      <tr>
          <td>元件動態增減（SPA 路由切換）</td>
          <td>變數指向 stale DOM</td>
          <td><a href="../pattern-closest-lookup/">closest 反向找根</a></td>
      </tr>
      <tr>
          <td>一次性 / 探索期程式</td>
          <td>過度工程</td>
          <td><a href="../pattern-document-query/">document query</a></td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="設計細節">設計細節</h2>
<h3 id="null-check-的時機">Null check 的時機</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">shell</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">shell</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span></span></span></code></pre></div><p>頁面可能沒有 shell（不是搜尋頁），所有後續 query 都會 null pointer。提早 return 比後續一連串 <code>if (drawer)</code> 乾淨。</p>
<p><strong>等同於宣告</strong>：「這段程式只在有 shell 的頁面執行」。</p>
<h3 id="變數的宣告位置">變數的宣告位置</h3>
<table>
  <thead>
      <tr>
          <th>位置</th>
          <th>適合</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>函式內 local 變數</td>
          <td>預設、scope 最小</td>
      </tr>
      <tr>
          <td>Module scope（IIFE 內）</td>
          <td>多函式共用同一 shell</td>
      </tr>
      <tr>
          <td>Class instance property</td>
          <td>元件本身用 class 包裝時</td>
      </tr>
  </tbody>
</table>
<p>避免全域變數 — <code>window.shell</code> 容易跟其他 script 撞。</p>
<h3 id="等待-shell-mount-的處理">等待 shell mount 的處理</h3>
<p>如果 script 跑得太早（shell 還沒 mount），shell 會是 null：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 解法 1：等 DOMContentLoaded
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="nb">document</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;DOMContentLoaded&#39;</span><span class="p">,</span> <span class="kd">function</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kd">var</span> <span class="nx">shell</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">shell</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="c1">// ...
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="p">});</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1">// 解法 2：MutationObserver 等 mount
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="kd">var</span> <span class="nx">bootstrap</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="kd">function</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="kd">var</span> <span class="nx">shell</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">shell</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="nx">bootstrap</span><span class="p">.</span><span class="nx">disconnect</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="c1">// ...
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="p">});</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="nx">bootstrap</span><span class="p">.</span><span class="nx">observe</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">,</span> <span class="p">{</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nx">subtree</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span></span></span></code></pre></div><p><strong>選擇取決於 shell 是 server-render 還是 client-render</strong>：server-render 用 DOMContentLoaded、client-render 用 observer。</p>
<hr>
<h2 id="跟其他起點做法的關係">跟其他起點做法的關係</h2>
<p><a href="../dom-selector-precision/">#14 Selector 精準度</a> 的「起點」維度有四種做法：</p>
<table>
  <thead>
      <tr>
          <th>做法</th>
          <th>比較</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../pattern-document-query/">document query</a></td>
          <td>比本卡片簡潔、不防護未來變動</td>
      </tr>
      <tr>
          <td>本卡片：元件根變數</td>
          <td>多一行設定、隔離未來頁面變動</td>
      </tr>
      <tr>
          <td><a href="../pattern-root-as-parameter/">起點當參數</a></td>
          <td>比本卡片多支援多實例、設計成本前移</td>
      </tr>
      <tr>
          <td><a href="../pattern-closest-lookup/">closest 反向找根</a></td>
          <td>適合動態元件、不依賴變數綁定的時間</td>
      </tr>
  </tbody>
</table>
<p>預設用本卡片、需要多實例升級到「起點當參數」、需要動態升級到「closest」。</p>
<hr>
<h2 id="應用範例完整-setup">應用範例：完整 setup</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">init</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">var</span> <span class="nx">shell</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">shell</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="kd">var</span> <span class="nx">ui</span>     <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="kd">var</span> <span class="nx">input</span>  <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="kd">var</span> <span class="nx">drawer</span> <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">input</span> <span class="o">||</span> <span class="o">!</span><span class="nx">drawer</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>  <span class="c1">// 元件未完整 mount
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="nx">syncScopeHeight</span><span class="p">(</span><span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-scope&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="nx">setupFilterSlotSwap</span><span class="p">(</span><span class="nx">shell</span><span class="p">,</span> <span class="nx">drawer</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="nx">setupScopeFilter</span><span class="p">(</span><span class="nx">shell</span><span class="p">,</span> <span class="nx">input</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;DOMContentLoaded&#39;</span><span class="p">,</span> <span class="nx">init</span><span class="p">);</span></span></span></code></pre></div><p>shell 取一次、各 setup 函式從 shell 派生需要的子節點。</p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該換做法嗎？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>多函式共用同一 shell、各自重 query</td>
          <td>否 — 把 shell 提到 module scope 共用</td>
      </tr>
      <tr>
          <td>同頁面要支援多個 shell 實例</td>
          <td>是 — 升級到「起點當參數」</td>
      </tr>
      <tr>
          <td>元件可能在 runtime 動態出現 / 消失</td>
          <td>是 — 升級到「closest 反向」</td>
      </tr>
      <tr>
          <td>Shell 偶爾找不到（時序問題）</td>
          <td>否 — 加 MutationObserver bootstrap、做法不變</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：本 pattern 是 production 客製的預設、不是極致最佳化。當當前情境不複雜（一個元件、靜態 mount）、用本 pattern 即可；情境變複雜時再升級到對應做法。</p>
]]></content:encoded></item><item><title>Pattern：起點當函式參數</title><link>https://tarrragon.github.io/blog/report/pattern-root-as-parameter/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/pattern-root-as-parameter/</guid><description>&lt;h2 id="核心做法">核心做法&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">setupSearchShell&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">ui&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">input&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">drawer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__drawer&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ... 所有 query 從參數 shell 開始
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelectorAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-shell&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">setupSearchShell&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>元件根不在函式內 query、由呼叫者傳入。函式支援任意數量的元件實例。&lt;/p>
&lt;hr>
&lt;h2 id="這個做法存在的價值">這個做法存在的價值&lt;/h2>
&lt;p>兩件事：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>多實例支援免費&lt;/strong>：&lt;code>forEach(setup)&lt;/code> 自動處理多個 shell&lt;/li>
&lt;li>&lt;strong>純函式特性&lt;/strong>：函式行為只依賴參數、不依賴外部狀態 — 可單獨測試、可重用、副作用集中&lt;/li>
&lt;/ol>
&lt;p>跟&lt;a href="../pattern-component-root/">元件根變數&lt;/a>的關鍵差異：那個 pattern 假設「shell 唯一」、本 pattern 把這個假設外移到呼叫端、函式本身不假設。&lt;/p>
&lt;hr>
&lt;h2 id="適合的情境">適合的情境&lt;/h2>
&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;code>forEach&lt;/code> 自動覆蓋全部&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元件設計成可被重用到其他頁面&lt;/td>
 &lt;td>沒有 hardcoded 依賴、容易移植&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>寫成函式庫 / 第三方 component&lt;/td>
 &lt;td>使用者可以對任意根節點呼叫&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>想單元測試函式行為&lt;/td>
 &lt;td>傳入 mock root 即可測試&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>核心特徵&lt;/strong>：把「shell 從哪來」這個責任明確交給呼叫端、函式自己不關心。&lt;/p>
&lt;hr>
&lt;h2 id="不適合的情境">不適合的情境&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &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;a href="../pattern-component-root/">元件根變數&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元件動態增減、生命週期不可預測&lt;/td>
 &lt;td>forEach 只跑一次、無法捕捉後加的元件&lt;/td>
 &lt;td>&lt;a href="../pattern-closest-lookup/">closest 反向找根&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>一次性探索程式碼&lt;/td>
 &lt;td>純函式設計成本不值得&lt;/td>
 &lt;td>&lt;a href="../pattern-document-query/">document query&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="設計細節">設計細節&lt;/h2>
&lt;h3 id="函式簽名的設計">函式簽名的設計&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 好：shell 是必填參數
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="nx">setupSearchShell&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1">// 較差：依賴外部變數
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">var&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// module scope
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="nx">setupSearchShell&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 用了外部 shell
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">// 更差：mega object
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="nx">setupSearchShell&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">allElements&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">shell&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">allElements&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 不知道實際依賴什麼
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// ...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>明確參數 = 明確依賴 = 容易測試、容易讀。&lt;/p>
&lt;h3 id="內部子函式也接受-shell">內部子函式也接受 shell&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">setupSearchShell&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">syncScopeHeight&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">setupFilterSlot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">setupScopeFilter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">syncScopeHeight&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">scope&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-scope&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每層都明確接受 shell — 不依賴外層 closure。整套函式族都是純函式。&lt;/p>
&lt;h3 id="預先抓子節點-vs-每次重-query">預先抓子節點 vs 每次重 query&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 方式 A：函式入口抓所有子節點
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="nx">setupSearchShell&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">els&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">ui&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">input&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">drawer&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__drawer&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="p">};&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 後續用 els.ui / els.input / els.drawer
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">// 方式 B：各子函式自己 query
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="nx">setupSearchShell&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">syncScopeHeight&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c1">// 內部自己 querySelector
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">setupFilterSlot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A 比較有效率（只 query 一次）、B 比較解耦（子函式自包含）。&lt;strong>選 B 為預設、效能瓶頸時才考慮 A&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="跟其他起點做法的關係">跟其他起點做法的關係&lt;/h2>
&lt;p>&lt;a href="../dom-selector-precision/">#14 Selector 精準度&lt;/a> 的「起點」維度有四種做法：&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;a href="../pattern-document-query/">document query&lt;/a>&lt;/td>
 &lt;td>比本卡片簡潔、無多實例支援&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="../pattern-component-root/">元件根變數&lt;/a>&lt;/td>
 &lt;td>比本卡片少一個參數、無多實例支援&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>本卡片：起點當參數&lt;/td>
 &lt;td>多實例支援、純函式、設計成本前移&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="../pattern-closest-lookup/">closest 反向找根&lt;/a>&lt;/td>
 &lt;td>比本卡片更動態、不依賴 forEach 時機&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>升級階梯：document → 元件根變數 → 起點當參數 → closest。複雜度遞增、能處理的情境也遞增。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心做法">核心做法</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">setupSearchShell</span><span class="p">(</span><span class="nx">shell</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">var</span> <span class="nx">ui</span>     <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">var</span> <span class="nx">input</span>  <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="kd">var</span> <span class="nx">drawer</span> <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="c1">// ... 所有 query 從參數 shell 開始
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">setupSearchShell</span><span class="p">);</span></span></span></code></pre></div><p>元件根不在函式內 query、由呼叫者傳入。函式支援任意數量的元件實例。</p>
<hr>
<h2 id="這個做法存在的價值">這個做法存在的價值</h2>
<p>兩件事：</p>
<ol>
<li><strong>多實例支援免費</strong>：<code>forEach(setup)</code> 自動處理多個 shell</li>
<li><strong>純函式特性</strong>：函式行為只依賴參數、不依賴外部狀態 — 可單獨測試、可重用、副作用集中</li>
</ol>
<p>跟<a href="../pattern-component-root/">元件根變數</a>的關鍵差異：那個 pattern 假設「shell 唯一」、本 pattern 把這個假設外移到呼叫端、函式本身不假設。</p>
<hr>
<h2 id="適合的情境">適合的情境</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼合理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同頁面有多個元件實例（多語切換、相關搜尋）</td>
          <td><code>forEach</code> 自動覆蓋全部</td>
      </tr>
      <tr>
          <td>元件設計成可被重用到其他頁面</td>
          <td>沒有 hardcoded 依賴、容易移植</td>
      </tr>
      <tr>
          <td>寫成函式庫 / 第三方 component</td>
          <td>使用者可以對任意根節點呼叫</td>
      </tr>
      <tr>
          <td>想單元測試函式行為</td>
          <td>傳入 mock root 即可測試</td>
      </tr>
  </tbody>
</table>
<p><strong>核心特徵</strong>：把「shell 從哪來」這個責任明確交給呼叫端、函式自己不關心。</p>
<hr>
<h2 id="不適合的情境">不適合的情境</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼過度工程</th>
          <th>改用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>確定全站只有一個元件實例</td>
          <td>每函式多一個參數、收益不明顯</td>
          <td><a href="../pattern-component-root/">元件根變數</a></td>
      </tr>
      <tr>
          <td>元件動態增減、生命週期不可預測</td>
          <td>forEach 只跑一次、無法捕捉後加的元件</td>
          <td><a href="../pattern-closest-lookup/">closest 反向找根</a></td>
      </tr>
      <tr>
          <td>一次性探索程式碼</td>
          <td>純函式設計成本不值得</td>
          <td><a href="../pattern-document-query/">document query</a></td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="設計細節">設計細節</h2>
<h3 id="函式簽名的設計">函式簽名的設計</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 好：shell 是必填參數
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">setupSearchShell</span><span class="p">(</span><span class="nx">shell</span><span class="p">)</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">// 較差：依賴外部變數
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="kd">var</span> <span class="nx">shell</span><span class="p">;</span>  <span class="c1">// module scope
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">setupSearchShell</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="c1">// 用了外部 shell
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">// 更差：mega object
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">setupSearchShell</span><span class="p">(</span><span class="nx">allElements</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="kd">var</span> <span class="nx">shell</span> <span class="o">=</span> <span class="nx">allElements</span><span class="p">.</span><span class="nx">shell</span><span class="p">;</span>  <span class="c1">// 不知道實際依賴什麼
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span>  <span class="c1">// ...
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="p">}</span></span></span></code></pre></div><p>明確參數 = 明確依賴 = 容易測試、容易讀。</p>
<h3 id="內部子函式也接受-shell">內部子函式也接受 shell</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">setupSearchShell</span><span class="p">(</span><span class="nx">shell</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nx">syncScopeHeight</span><span class="p">(</span><span class="nx">shell</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">setupFilterSlot</span><span class="p">(</span><span class="nx">shell</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nx">setupScopeFilter</span><span class="p">(</span><span class="nx">shell</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kd">function</span> <span class="nx">syncScopeHeight</span><span class="p">(</span><span class="nx">shell</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="kd">var</span> <span class="nx">scope</span> <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-scope&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="c1">// ...
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="p">}</span></span></span></code></pre></div><p>每層都明確接受 shell — 不依賴外層 closure。整套函式族都是純函式。</p>
<h3 id="預先抓子節點-vs-每次重-query">預先抓子節點 vs 每次重 query</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 方式 A：函式入口抓所有子節點
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">setupSearchShell</span><span class="p">(</span><span class="nx">shell</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kd">var</span> <span class="nx">els</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">ui</span><span class="o">:</span>     <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">input</span><span class="o">:</span>  <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">drawer</span><span class="o">:</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="c1">// 後續用 els.ui / els.input / els.drawer
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">// 方式 B：各子函式自己 query
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">setupSearchShell</span><span class="p">(</span><span class="nx">shell</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="nx">syncScopeHeight</span><span class="p">(</span><span class="nx">shell</span><span class="p">);</span>  <span class="c1">// 內部自己 querySelector
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span>  <span class="nx">setupFilterSlot</span><span class="p">(</span><span class="nx">shell</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>A 比較有效率（只 query 一次）、B 比較解耦（子函式自包含）。<strong>選 B 為預設、效能瓶頸時才考慮 A</strong>。</p>
<hr>
<h2 id="跟其他起點做法的關係">跟其他起點做法的關係</h2>
<p><a href="../dom-selector-precision/">#14 Selector 精準度</a> 的「起點」維度有四種做法：</p>
<table>
  <thead>
      <tr>
          <th>做法</th>
          <th>比較</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../pattern-document-query/">document query</a></td>
          <td>比本卡片簡潔、無多實例支援</td>
      </tr>
      <tr>
          <td><a href="../pattern-component-root/">元件根變數</a></td>
          <td>比本卡片少一個參數、無多實例支援</td>
      </tr>
      <tr>
          <td>本卡片：起點當參數</td>
          <td>多實例支援、純函式、設計成本前移</td>
      </tr>
      <tr>
          <td><a href="../pattern-closest-lookup/">closest 反向找根</a></td>
          <td>比本卡片更動態、不依賴 forEach 時機</td>
      </tr>
  </tbody>
</table>
<p>升級階梯：document → 元件根變數 → 起點當參數 → closest。複雜度遞增、能處理的情境也遞增。</p>
<hr>
<h2 id="應用範例多實例-setup">應用範例：多實例 setup</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 頁面有 N 個 search-shell（例如多語版面切換）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">setupSearchShell</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// 跑完之後：每個 shell 各自獨立 setup、互不干擾
</span></span></span></code></pre></div><p>當前頁只一個 shell、上面這行也適用 —<code>forEach</code> 對 1 個元素跑一次、跟 hardcode 單例沒差。<strong>做了多實例設計、未來不需要重寫</strong>。</p>
<hr>
<h2 id="應用範例單元測試">應用範例：單元測試</h2>
<p>純函式可以對 mock DOM 測試：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;setupSearchShell 把 filter 移到 sidebar&#39;</span><span class="p">,</span> <span class="kd">function</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">var</span> <span class="nx">shell</span> <span class="o">=</span> <span class="nx">createMockShell</span><span class="p">();</span>  <span class="c1">// 建立測試用 DOM
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="nx">setupSearchShell</span><span class="p">(</span><span class="nx">shell</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nx">expect</span><span class="p">(</span><span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-filter-slot&#39;</span><span class="p">).</span><span class="nx">children</span><span class="p">.</span><span class="nx">length</span><span class="p">).</span><span class="nx">toBe</span><span class="p">(</span><span class="mi">1</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>不需要全頁面 mount、只需要 mock 一個 shell — 測試成本低。</p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該套用本 pattern 嗎？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同頁要支援多個元件實例</td>
          <td>是 — 直接的好處</td>
      </tr>
      <tr>
          <td>想對函式寫單元測試</td>
          <td>是 — 純函式才好測</td>
      </tr>
      <tr>
          <td>函式內讀 module scope 變數</td>
          <td>是 — 改成參數讓依賴顯式</td>
      </tr>
      <tr>
          <td>確定永遠只一個實例、且不寫測試</td>
          <td>否 — <a href="../pattern-component-root/">元件根變數</a> 已夠</td>
      </tr>
      <tr>
          <td>元件實例 runtime 動態增減</td>
          <td>否 — 升級到 <a href="../pattern-closest-lookup/">closest</a></td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：本 pattern 把「我從哪取得 shell」的答案從函式內搬到呼叫端 — 換到「函式可重用」+「測試容易」+「多實例免費」三個收益、代價是函式簽名多一個參數。當前情境只一個實例也適用、未來擴展不需重寫。</p>
]]></content:encoded></item><item><title>Pattern：closest 反向找根</title><link>https://tarrragon.github.io/blog/report/pattern-closest-lookup/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/pattern-closest-lookup/</guid><description>&lt;h2 id="核心做法">核心做法&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;click&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">e&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">shell&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">e&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">target&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">closest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-shell&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 在這個 shell 內處理
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">handleSearchClick&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">e&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>不在初始化時綁定 listener、而是頁面層級委派事件、事件處理時從 &lt;code>e.target&lt;/code> 反向找元件根。&lt;/p>
&lt;hr>
&lt;h2 id="這個做法存在的價值">這個做法存在的價值&lt;/h2>
&lt;p>把「找元件根」從「初始化時綁定」延後到「事件發生時動態判斷」 — 換到三個能力：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>元件動態增減免處理&lt;/strong>：新加的元件不需要重新綁 listener&lt;/li>
&lt;li>&lt;strong>多實例不需要 forEach setup&lt;/strong>：所有實例共用一個 listener&lt;/li>
&lt;li>&lt;strong>記憶體效率&lt;/strong>：N 個元件只綁 1 個 listener、不是 N 個&lt;/li>
&lt;/ol>
&lt;p>代價是事件處理邏輯多一層（每次都要 closest 反向找）。&lt;/p>
&lt;hr>
&lt;h2 id="適合的情境">適合的情境&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>為什麼合理&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>SPA 路由切換、元件動態 mount/unmount&lt;/td>
 &lt;td>不需要在 mount 時重綁 listener&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元件數量大（&amp;gt;10 個實例）&lt;/td>
 &lt;td>事件委派比每實例綁 listener 省記憶體&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元件透過 AJAX 動態注入&lt;/td>
 &lt;td>注入後不需要任何 setup 動作&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>第三方 widget、不能控制元件生命週期&lt;/td>
 &lt;td>listener 綁在 document、跟 widget 解耦&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>核心特徵&lt;/strong>：元件的 mount 時機 / 數量 runtime 才知道、不是初始化時固定。&lt;/p>
&lt;hr>
&lt;h2 id="不適合的情境">不適合的情境&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>為什麼過度工程&lt;/th>
 &lt;th>改用&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>元件靜態 mount、生命週期跟頁面一樣&lt;/td>
 &lt;td>委派多一層、收益不明顯&lt;/td>
 &lt;td>&lt;a href="../pattern-root-as-parameter/">起點當參數&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>一個元件實例、永不變動&lt;/td>
 &lt;td>完全沒必要&lt;/td>
 &lt;td>&lt;a href="../pattern-component-root/">元件根變數&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>需要在元件 mount 時就跑邏輯（不只回應事件）&lt;/td>
 &lt;td>closest 只在事件發生時跑、無法當 init hook&lt;/td>
 &lt;td>&lt;a href="../pattern-root-as-parameter/">起點當參數&lt;/a> + MutationObserver&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="設計細節">設計細節&lt;/h2>
&lt;h3 id="closest-失敗的處理">Closest 失敗的處理&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;click&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">e&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">shell&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">e&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">target&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">closest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-shell&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 點擊不在任何 shell 內
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// ...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>closest&lt;/code> 找不到時回 &lt;code>null&lt;/code>、提早 return 是必要防護。&lt;strong>沒這個 check 會在頁面其他地方點擊時報錯&lt;/strong>。&lt;/p>
&lt;h3 id="從-closest-結果再往下-query">從 closest 結果再往下 query&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">shell&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">e&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">target&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">closest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-shell&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">input&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>closest&lt;/code> 找到 shell 後、可以從 shell 往下 query 同元件內的其他元素 — 這是「事件 + closest + 局部 query」的組合。&lt;/p>
&lt;h3 id="事件類型的選擇">事件類型的選擇&lt;/h3>
&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;code>click&lt;/code>&lt;/td>
 &lt;td>點擊互動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>input&lt;/code>&lt;/td>
 &lt;td>輸入框文字變動（需要 bubble）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>change&lt;/code>&lt;/td>
 &lt;td>選項變動（select / radio / checkbox）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>keydown&lt;/code>&lt;/td>
 &lt;td>鍵盤快捷鍵&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>focus&lt;/code> / &lt;code>blur&lt;/code>&lt;/td>
 &lt;td>焦點移動（不 bubble、要用 &lt;code>focusin&lt;/code> / &lt;code>focusout&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>注意 &lt;code>focus&lt;/code> / &lt;code>blur&lt;/code> 不會 bubble — 事件委派要用 &lt;code>focusin&lt;/code> / &lt;code>focusout&lt;/code>。&lt;/p>
&lt;h3 id="委派的根節點選擇">委派的根節點選擇&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 選項 1：document（最寬）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;click&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">handler&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">// 選項 2：特定容器（縮範圍）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">var&lt;/span> &lt;span class="nx">pageContainer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;main&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="nx">pageContainer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;click&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">handler&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>縮範圍的好處是「跟其他頁面區域的 listener 不互相干擾」。預設用 document、有干擾風險才縮。&lt;/p>
&lt;hr>
&lt;h2 id="跟其他起點做法的關係">跟其他起點做法的關係&lt;/h2>
&lt;p>&lt;a href="../dom-selector-precision/">#14 Selector 精準度&lt;/a> 的「起點」維度有四種做法：&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;a href="../pattern-document-query/">document query&lt;/a>&lt;/td>
 &lt;td>靜態、簡潔、無多實例支援&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="../pattern-component-root/">元件根變數&lt;/a>&lt;/td>
 &lt;td>靜態、shell 唯一假設&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="../pattern-root-as-parameter/">起點當參數&lt;/a>&lt;/td>
 &lt;td>靜態多實例、forEach 一次設定&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>本卡片：closest 反向找根&lt;/td>
 &lt;td>動態、事件驅動、無 init 時機綁定&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>複雜度遞增、能處理的動態程度也遞增。最動態的場景才用本 pattern。&lt;/p>
&lt;hr>
&lt;h2 id="應用範例跨多-shell-的-scope-filter">應用範例：跨多 shell 的 scope filter&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">setupGlobalScopeFilter&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;change&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">e&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">shell&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">e&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">target&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">closest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-shell&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">scope&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">e&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">target&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">closest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-scope&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nx">scope&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 不是 scope 控制的 change
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">applyScope&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shell&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">scope&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="nx">setupGlobalScopeFilter&lt;/span>&lt;span class="p">();&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>一個 listener 處理所有 shell 的 scope 變動 — 不論 shell 是初始 mount 的、還是 runtime 注入的。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心做法">核心做法</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;click&#39;</span><span class="p">,</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">var</span> <span class="nx">shell</span> <span class="o">=</span> <span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">closest</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">shell</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="c1">// 在這個 shell 內處理
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>  <span class="nx">handleSearchClick</span><span class="p">(</span><span class="nx">shell</span><span class="p">,</span> <span class="nx">e</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>不在初始化時綁定 listener、而是頁面層級委派事件、事件處理時從 <code>e.target</code> 反向找元件根。</p>
<hr>
<h2 id="這個做法存在的價值">這個做法存在的價值</h2>
<p>把「找元件根」從「初始化時綁定」延後到「事件發生時動態判斷」 — 換到三個能力：</p>
<ol>
<li><strong>元件動態增減免處理</strong>：新加的元件不需要重新綁 listener</li>
<li><strong>多實例不需要 forEach setup</strong>：所有實例共用一個 listener</li>
<li><strong>記憶體效率</strong>：N 個元件只綁 1 個 listener、不是 N 個</li>
</ol>
<p>代價是事件處理邏輯多一層（每次都要 closest 反向找）。</p>
<hr>
<h2 id="適合的情境">適合的情境</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼合理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SPA 路由切換、元件動態 mount/unmount</td>
          <td>不需要在 mount 時重綁 listener</td>
      </tr>
      <tr>
          <td>元件數量大（&gt;10 個實例）</td>
          <td>事件委派比每實例綁 listener 省記憶體</td>
      </tr>
      <tr>
          <td>元件透過 AJAX 動態注入</td>
          <td>注入後不需要任何 setup 動作</td>
      </tr>
      <tr>
          <td>第三方 widget、不能控制元件生命週期</td>
          <td>listener 綁在 document、跟 widget 解耦</td>
      </tr>
  </tbody>
</table>
<p><strong>核心特徵</strong>：元件的 mount 時機 / 數量 runtime 才知道、不是初始化時固定。</p>
<hr>
<h2 id="不適合的情境">不適合的情境</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼過度工程</th>
          <th>改用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>元件靜態 mount、生命週期跟頁面一樣</td>
          <td>委派多一層、收益不明顯</td>
          <td><a href="../pattern-root-as-parameter/">起點當參數</a></td>
      </tr>
      <tr>
          <td>一個元件實例、永不變動</td>
          <td>完全沒必要</td>
          <td><a href="../pattern-component-root/">元件根變數</a></td>
      </tr>
      <tr>
          <td>需要在元件 mount 時就跑邏輯（不只回應事件）</td>
          <td>closest 只在事件發生時跑、無法當 init hook</td>
          <td><a href="../pattern-root-as-parameter/">起點當參數</a> + MutationObserver</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="設計細節">設計細節</h2>
<h3 id="closest-失敗的處理">Closest 失敗的處理</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;click&#39;</span><span class="p">,</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">var</span> <span class="nx">shell</span> <span class="o">=</span> <span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">closest</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">shell</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>  <span class="c1">// 點擊不在任何 shell 內
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="c1">// ...
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p><code>closest</code> 找不到時回 <code>null</code>、提早 return 是必要防護。<strong>沒這個 check 會在頁面其他地方點擊時報錯</strong>。</p>
<h3 id="從-closest-結果再往下-query">從 closest 結果再往下 query</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">shell</span> <span class="o">=</span> <span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">closest</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">var</span> <span class="nx">input</span> <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">);</span></span></span></code></pre></div><p><code>closest</code> 找到 shell 後、可以從 shell 往下 query 同元件內的其他元素 — 這是「事件 + closest + 局部 query」的組合。</p>
<h3 id="事件類型的選擇">事件類型的選擇</h3>
<table>
  <thead>
      <tr>
          <th>事件</th>
          <th>適合</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>click</code></td>
          <td>點擊互動</td>
      </tr>
      <tr>
          <td><code>input</code></td>
          <td>輸入框文字變動（需要 bubble）</td>
      </tr>
      <tr>
          <td><code>change</code></td>
          <td>選項變動（select / radio / checkbox）</td>
      </tr>
      <tr>
          <td><code>keydown</code></td>
          <td>鍵盤快捷鍵</td>
      </tr>
      <tr>
          <td><code>focus</code> / <code>blur</code></td>
          <td>焦點移動（不 bubble、要用 <code>focusin</code> / <code>focusout</code>）</td>
      </tr>
  </tbody>
</table>
<p>注意 <code>focus</code> / <code>blur</code> 不會 bubble — 事件委派要用 <code>focusin</code> / <code>focusout</code>。</p>
<h3 id="委派的根節點選擇">委派的根節點選擇</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 選項 1：document（最寬）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nb">document</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;click&#39;</span><span class="p">,</span> <span class="nx">handler</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// 選項 2：特定容器（縮範圍）
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="kd">var</span> <span class="nx">pageContainer</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;main&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">pageContainer</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;click&#39;</span><span class="p">,</span> <span class="nx">handler</span><span class="p">);</span></span></span></code></pre></div><p>縮範圍的好處是「跟其他頁面區域的 listener 不互相干擾」。預設用 document、有干擾風險才縮。</p>
<hr>
<h2 id="跟其他起點做法的關係">跟其他起點做法的關係</h2>
<p><a href="../dom-selector-precision/">#14 Selector 精準度</a> 的「起點」維度有四種做法：</p>
<table>
  <thead>
      <tr>
          <th>做法</th>
          <th>比較</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../pattern-document-query/">document query</a></td>
          <td>靜態、簡潔、無多實例支援</td>
      </tr>
      <tr>
          <td><a href="../pattern-component-root/">元件根變數</a></td>
          <td>靜態、shell 唯一假設</td>
      </tr>
      <tr>
          <td><a href="../pattern-root-as-parameter/">起點當參數</a></td>
          <td>靜態多實例、forEach 一次設定</td>
      </tr>
      <tr>
          <td>本卡片：closest 反向找根</td>
          <td>動態、事件驅動、無 init 時機綁定</td>
      </tr>
  </tbody>
</table>
<p>複雜度遞增、能處理的動態程度也遞增。最動態的場景才用本 pattern。</p>
<hr>
<h2 id="應用範例跨多-shell-的-scope-filter">應用範例：跨多 shell 的 scope filter</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">setupGlobalScopeFilter</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;change&#39;</span><span class="p">,</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="kd">var</span> <span class="nx">shell</span> <span class="o">=</span> <span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">closest</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">shell</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="kd">var</span> <span class="nx">scope</span> <span class="o">=</span> <span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">closest</span><span class="p">(</span><span class="s1">&#39;.search-scope&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">scope</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>  <span class="c1">// 不是 scope 控制的 change
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">applyScope</span><span class="p">(</span><span class="nx">shell</span><span class="p">,</span> <span class="nx">scope</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="nx">setupGlobalScopeFilter</span><span class="p">();</span></span></span></code></pre></div><p>一個 listener 處理所有 shell 的 scope 變動 — 不論 shell 是初始 mount 的、還是 runtime 注入的。</p>
<hr>
<h2 id="應用範例與-起點當參數-組合">應用範例：與 <a href="../pattern-root-as-parameter/">起點當參數</a> 組合</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 初始化階段：對已存在的 shell 做 setup
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">setupSearchShell</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">// 事件階段：用 closest 處理可能新加的 shell
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="nb">document</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;click&#39;</span><span class="p">,</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="kd">var</span> <span class="nx">shell</span> <span class="o">=</span> <span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">closest</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">shell</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="c1">// 處理事件、不論 shell 是初始的還是後加的
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="p">});</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">// MutationObserver：捕捉新加的 shell 做 setup
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">mutations</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="nx">mutations</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">m</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">m</span><span class="p">.</span><span class="nx">addedNodes</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">node</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="nx">node</span><span class="p">.</span><span class="nx">matches</span> <span class="o">&amp;&amp;</span> <span class="nx">node</span><span class="p">.</span><span class="nx">matches</span><span class="p">(</span><span class="s1">&#39;.search-shell&#39;</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">setupSearchShell</span><span class="p">(</span><span class="nx">node</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}).</span><span class="nx">observe</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">,</span> <span class="p">{</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nx">subtree</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span></span></span></code></pre></div><p>三個 pattern 組合：「靜態 setup」+「事件動態」+「mount 時 setup」 — 各 pattern 補不同時間點的需求。</p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該套用本 pattern 嗎？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>元件 SPA 路由動態切換</td>
          <td>是 — 直接對應使用情境</td>
      </tr>
      <tr>
          <td>元件數量大、每實例都要綁 listener</td>
          <td>是 — 委派省記憶體</td>
      </tr>
      <tr>
          <td>AJAX / Web Component runtime 注入</td>
          <td>是 — 不需要重綁</td>
      </tr>
      <tr>
          <td>確定元件靜態、生命週期固定</td>
          <td>否 — <a href="../pattern-root-as-parameter/">起點當參數</a> 已夠</td>
      </tr>
      <tr>
          <td>邏輯不是事件驅動（init 時就要跑）</td>
          <td>否 — closest 只在事件發生時跑</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：closest 反向找根把「定位元件」從綁定時延後到事件發生時 — 換到動態能力、付出的是事件處理多一層判斷。靜態場景用更簡單的做法、動態場景才升級到本 pattern。</p>
]]></content:encoded></item><item><title>Pattern：DOM attribute idempotency 標記</title><link>https://tarrragon.github.io/blog/report/pattern-attribute-idempotency-marker/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/pattern-attribute-idempotency-marker/</guid><description>&lt;h2 id="核心做法">核心做法&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelectorAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__result:not([data-scoped])&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ... 處理
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setAttribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;data-scoped&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;true&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>apply 函式入口用 &lt;code>:not([data-x])&lt;/code> 過濾掉已處理元素、處理完後設 attribute 標記。下次 apply 被觸發時、已處理的元素不會被命中。&lt;/p>
&lt;hr>
&lt;h2 id="這個做法存在的價值">這個做法存在的價值&lt;/h2>
&lt;p>把「保證只處理一次」的責任從&lt;strong>呼叫端&lt;/strong>（要記得只呼叫一次）轉到&lt;strong>元素本身&lt;/strong>（看自己有沒有被處理過）。&lt;/p>
&lt;p>apply 函式可能被多個源觸發：&lt;/p>
&lt;ul>
&lt;li>初始化時呼叫&lt;/li>
&lt;li>MutationObserver 偵測到變動觸發&lt;/li>
&lt;li>使用者事件觸發&lt;/li>
&lt;li>Framework 重繪後重新呼叫&lt;/li>
&lt;/ul>
&lt;p>任一個源多呼叫就重複處理 — 無法靠呼叫端紀律避免。Idempotency 標記讓 apply 自己防護。&lt;/p>
&lt;hr>
&lt;h2 id="適合的情境">適合的情境&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>為什麼合理&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Production apply 函式、可能被多源觸發&lt;/td>
 &lt;td>標記在元素上、不依賴呼叫紀律&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>處理動作有副作用（綁 listener、改 class）&lt;/td>
 &lt;td>重複觸發會疊加副作用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元素生命週期跟 attribute 同步（不會被 reset）&lt;/td>
 &lt;td>標記跟著元素走、自然清理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Devtools debug 友善&lt;/td>
 &lt;td>attribute 在 inspector 可見&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>核心特徵&lt;/strong>：元素的 attribute 跟著元素 DOM 生命週期、元素移除時標記自動消失。&lt;/p>
&lt;hr>
&lt;h2 id="不適合的情境">不適合的情境&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>為什麼不夠&lt;/th>
 &lt;th>改用&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>寫第三方 library&lt;/td>
 &lt;td>在使用者 DOM 加自家 attribute、有命名衝突風險&lt;/td>
 &lt;td>&lt;a href="../pattern-weakmap-idempotency-record/">WeakMap 紀錄&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Framework 重繪會清掉 attribute&lt;/td>
 &lt;td>標記消失、防護失效&lt;/td>
 &lt;td>配合 disconnect/observe 或改 WeakMap&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>需要週期性 reset 標記&lt;/td>
 &lt;td>attribute 改回需要遍歷所有元素&lt;/td>
 &lt;td>WeakMap 可整批 &lt;code>new WeakMap()&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>多種獨立的 idempotency 維度&lt;/td>
 &lt;td>DOM 上多 attribute 互相干擾&lt;/td>
 &lt;td>WeakMap 各別管理&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="設計細節">設計細節&lt;/h2>
&lt;h3 id="attribute-命名規範">Attribute 命名規範&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 好：明確 namespace + 用途
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setAttribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;data-search-scoped&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;true&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setAttribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;data-myapp-processed&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;true&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">// 較差：通用名、容易跟其他程式撞
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setAttribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;data-processed&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;true&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setAttribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;processed&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;true&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c1">// 不是 data-* 開頭、可能不被 HTML spec 接受
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>預設用 &lt;code>data-{appname}-{purpose}&lt;/code> 格式 — 即使引入第三方 library 加 attribute、也不會撞名。&lt;/p>
&lt;h3 id="attribute-值的選擇">Attribute 值的選擇&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 用法 1：固定 &amp;#39;true&amp;#39;（最簡）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setAttribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;data-scoped&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;true&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1">// 用法 2：紀錄處理時間 / 版本（debug 友善）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setAttribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;data-scoped&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">String&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">Date&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">()));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setAttribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;data-scoped&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;v2&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">// 用法 3：boolean attribute（無值）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setAttribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;data-scoped&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">// CSS 用 [data-scoped] 即可選中
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>預設用 &lt;code>'true'&lt;/code>、debug 困難時改 timestamp 看處理順序。&lt;/p>
&lt;h3 id="跟-framework-重繪共處">跟 framework 重繪共處&lt;/h3>
&lt;p>Svelte / React / Vue 重繪元素時、&lt;strong>自家 attribute 通常會被保留&lt;/strong>（framework 只管自己的 attribute）— 但有例外：&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>Framework re-render 整段 DOM&lt;/td>
 &lt;td>元素被替換、新元素沒標記 → apply 重跑、合理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Framework patch 既有元素 attribute&lt;/td>
 &lt;td>自家 attribute 保留&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Framework &lt;code>replaceWith&lt;/code> / &lt;code>innerHTML&lt;/code> 重設&lt;/td>
 &lt;td>元素被替換 → 標記消失、apply 重跑、合理&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>核心觀察&lt;/strong>：自家 attribute 跟著元素走 — 元素還在就有、元素被換就沒。這是「正確」行為、不是 bug。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心做法">核心做法</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">shell</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__result:not([data-scoped])&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="c1">// ... 處理
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="nx">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-scoped&#39;</span><span class="p">,</span> <span class="s1">&#39;true&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>apply 函式入口用 <code>:not([data-x])</code> 過濾掉已處理元素、處理完後設 attribute 標記。下次 apply 被觸發時、已處理的元素不會被命中。</p>
<hr>
<h2 id="這個做法存在的價值">這個做法存在的價值</h2>
<p>把「保證只處理一次」的責任從<strong>呼叫端</strong>（要記得只呼叫一次）轉到<strong>元素本身</strong>（看自己有沒有被處理過）。</p>
<p>apply 函式可能被多個源觸發：</p>
<ul>
<li>初始化時呼叫</li>
<li>MutationObserver 偵測到變動觸發</li>
<li>使用者事件觸發</li>
<li>Framework 重繪後重新呼叫</li>
</ul>
<p>任一個源多呼叫就重複處理 — 無法靠呼叫端紀律避免。Idempotency 標記讓 apply 自己防護。</p>
<hr>
<h2 id="適合的情境">適合的情境</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼合理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Production apply 函式、可能被多源觸發</td>
          <td>標記在元素上、不依賴呼叫紀律</td>
      </tr>
      <tr>
          <td>處理動作有副作用（綁 listener、改 class）</td>
          <td>重複觸發會疊加副作用</td>
      </tr>
      <tr>
          <td>元素生命週期跟 attribute 同步（不會被 reset）</td>
          <td>標記跟著元素走、自然清理</td>
      </tr>
      <tr>
          <td>Devtools debug 友善</td>
          <td>attribute 在 inspector 可見</td>
      </tr>
  </tbody>
</table>
<p><strong>核心特徵</strong>：元素的 attribute 跟著元素 DOM 生命週期、元素移除時標記自動消失。</p>
<hr>
<h2 id="不適合的情境">不適合的情境</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼不夠</th>
          <th>改用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫第三方 library</td>
          <td>在使用者 DOM 加自家 attribute、有命名衝突風險</td>
          <td><a href="../pattern-weakmap-idempotency-record/">WeakMap 紀錄</a></td>
      </tr>
      <tr>
          <td>Framework 重繪會清掉 attribute</td>
          <td>標記消失、防護失效</td>
          <td>配合 disconnect/observe 或改 WeakMap</td>
      </tr>
      <tr>
          <td>需要週期性 reset 標記</td>
          <td>attribute 改回需要遍歷所有元素</td>
          <td>WeakMap 可整批 <code>new WeakMap()</code></td>
      </tr>
      <tr>
          <td>多種獨立的 idempotency 維度</td>
          <td>DOM 上多 attribute 互相干擾</td>
          <td>WeakMap 各別管理</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="設計細節">設計細節</h2>
<h3 id="attribute-命名規範">Attribute 命名規範</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 好：明確 namespace + 用途
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-search-scoped&#39;</span><span class="p">,</span> <span class="s1">&#39;true&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-myapp-processed&#39;</span><span class="p">,</span> <span class="s1">&#39;true&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">// 較差：通用名、容易跟其他程式撞
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="nx">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-processed&#39;</span><span class="p">,</span> <span class="s1">&#39;true&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="nx">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;processed&#39;</span><span class="p">,</span> <span class="s1">&#39;true&#39;</span><span class="p">);</span>  <span class="c1">// 不是 data-* 開頭、可能不被 HTML spec 接受
</span></span></span></code></pre></div><p>預設用 <code>data-{appname}-{purpose}</code> 格式 — 即使引入第三方 library 加 attribute、也不會撞名。</p>
<h3 id="attribute-值的選擇">Attribute 值的選擇</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 用法 1：固定 &#39;true&#39;（最簡）
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="nx">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-scoped&#39;</span><span class="p">,</span> <span class="s1">&#39;true&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">// 用法 2：紀錄處理時間 / 版本（debug 友善）
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="nx">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-scoped&#39;</span><span class="p">,</span> <span class="nb">String</span><span class="p">(</span><span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()));</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="nx">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-scoped&#39;</span><span class="p">,</span> <span class="s1">&#39;v2&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1">// 用法 3：boolean attribute（無值）
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="nx">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-scoped&#39;</span><span class="p">,</span> <span class="s1">&#39;&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">// CSS 用 [data-scoped] 即可選中
</span></span></span></code></pre></div><p>預設用 <code>'true'</code>、debug 困難時改 timestamp 看處理順序。</p>
<h3 id="跟-framework-重繪共處">跟 framework 重繪共處</h3>
<p>Svelte / React / Vue 重繪元素時、<strong>自家 attribute 通常會被保留</strong>（framework 只管自己的 attribute）— 但有例外：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>行為</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Framework re-render 整段 DOM</td>
          <td>元素被替換、新元素沒標記 → apply 重跑、合理</td>
      </tr>
      <tr>
          <td>Framework patch 既有元素 attribute</td>
          <td>自家 attribute 保留</td>
      </tr>
      <tr>
          <td>Framework <code>replaceWith</code> / <code>innerHTML</code> 重設</td>
          <td>元素被替換 → 標記消失、apply 重跑、合理</td>
      </tr>
  </tbody>
</table>
<p><strong>核心觀察</strong>：自家 attribute 跟著元素走 — 元素還在就有、元素被換就沒。這是「正確」行為、不是 bug。</p>
<h3 id="例外framework-主動清自家-attribute">例外：framework 主動清自家 attribute</h3>
<p>少數 framework 會 strict 清非預期的 attribute（例如某些 Web Component lib）。檢查方式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-scoped&#39;</span><span class="p">,</span> <span class="s1">&#39;true&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// ... 等 framework patch 一次後
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">el</span><span class="p">.</span><span class="nx">getAttribute</span><span class="p">(</span><span class="s1">&#39;data-scoped&#39;</span><span class="p">));</span>  <span class="c1">// 還在嗎？
</span></span></span></code></pre></div><p>如果消失、改用 <a href="../pattern-weakmap-idempotency-record/">WeakMap 紀錄</a>。</p>
<hr>
<h2 id="跟其他-idempotency-做法的關係">跟其他 idempotency 做法的關係</h2>
<p><a href="../dom-selector-precision/">#14 Selector 精準度</a> 的「過濾」維度有三種做法：</p>
<table>
  <thead>
      <tr>
          <th>做法</th>
          <th>比較</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>本卡片：DOM attribute 標記</td>
          <td>production 預設、devtools 可見、有命名衝突風險</td>
      </tr>
      <tr>
          <td><a href="../pattern-weakmap-idempotency-record/">WeakMap 紀錄</a></td>
          <td>不污染 DOM、適合 library、debug 不便</td>
      </tr>
      <tr>
          <td>依賴外部呼叫者保證</td>
          <td>反模式、無防護、不可靠</td>
      </tr>
  </tbody>
</table>
<p>預設用本卡片、第三方 library / framework 衝突情境升級到 WeakMap。</p>
<hr>
<h2 id="應用範例完整-apply">應用範例：完整 apply</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">apply</span><span class="p">(</span><span class="nx">shell</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">var</span> <span class="nx">newResults</span> <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="s1">&#39;.pagefind-ui__result:not([data-search-scoped])&#39;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="nx">newResults</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">bindClickHandler</span><span class="p">(</span><span class="nx">el</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">addCustomBadge</span><span class="p">(</span><span class="nx">el</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-search-scoped&#39;</span><span class="p">,</span> <span class="s1">&#39;true&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">// 多源觸發都安全
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="nx">init</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;click&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="nx">apply</span><span class="p">(</span><span class="nx">shell</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="nx">observer</span><span class="p">.</span><span class="nx">observe</span><span class="p">(</span><span class="nx">shell</span><span class="p">,</span> <span class="p">...);</span>  <span class="c1">// 觀察到變動觸發 apply
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1"></span><span class="nx">apply</span><span class="p">(</span><span class="nx">shell</span><span class="p">);</span>  <span class="c1">// 初始化時跑一次
</span></span></span></code></pre></div><p>三個觸發點任一個多跑、<code>:not([data-search-scoped])</code> 都會過濾掉已處理元素。</p>
<hr>
<h2 id="應用範例多維度標記">應用範例：多維度標記</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 三個獨立 idempotency 維度、各自 attribute
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="nx">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-search-scoped&#39;</span><span class="p">,</span> <span class="s1">&#39;true&#39;</span><span class="p">);</span>     <span class="c1">// scope filter 處理過
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span><span class="nx">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-search-bound&#39;</span><span class="p">,</span> <span class="s1">&#39;true&#39;</span><span class="p">);</span>      <span class="c1">// event listener 綁過
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="nx">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-search-decorated&#39;</span><span class="p">,</span> <span class="s1">&#39;true&#39;</span><span class="p">);</span>  <span class="c1">// 視覺裝飾加過
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1">// 各 apply 函式只看自己的 attribute
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">applyScope</span><span class="p">(</span><span class="nx">shell</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.x:not([data-search-scoped])&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(...)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kd">function</span> <span class="nx">applyBindings</span><span class="p">(</span><span class="nx">shell</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.x:not([data-search-bound])&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(...)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>每個 idempotency 維度獨立 — 互相不干擾。</p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該套用本 pattern 嗎？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Apply 被多源觸發、產生重複處理 bug</td>
          <td>是 — 直接對應使用情境</td>
      </tr>
      <tr>
          <td>寫第三方 library / 不能污染 DOM</td>
          <td>否 — 改 <a href="../pattern-weakmap-idempotency-record/">WeakMap</a></td>
      </tr>
      <tr>
          <td>Framework 會清自家 attribute</td>
          <td>否 — 改 WeakMap</td>
      </tr>
      <tr>
          <td>想在 devtools inspector 直接看處理狀態</td>
          <td>是 — attribute 可見性是優點</td>
      </tr>
      <tr>
          <td>同元素多種 idempotency 維度</td>
          <td>是 — 多 attribute 各自管理</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：把 idempotency 責任從呼叫端搬到元素本身、attribute 是「便宜可見的旗標」。Production apply 預設用本 pattern、特殊情境（library / framework 衝突）才升級到 WeakMap。</p>
]]></content:encoded></item><item><title>Pattern：WeakMap idempotency 紀錄</title><link>https://tarrragon.github.io/blog/report/pattern-weakmap-idempotency-record/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/pattern-weakmap-idempotency-record/</guid><description>&lt;h2 id="核心做法">核心做法&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">processed&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">WeakMap&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nx">shell&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelectorAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__result&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">processed&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">has&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ... 處理
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">processed&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>把「已處理」狀態紀錄在 JS 的 WeakMap 裡、不寫到 DOM 上。WeakMap key 是元素本身、元素被 GC 時自動清理。&lt;/p>
&lt;hr>
&lt;h2 id="這個做法存在的價值">這個做法存在的價值&lt;/h2>
&lt;p>兩件事 &lt;a href="../pattern-attribute-idempotency-marker/">DOM attribute 標記&lt;/a> 做不到：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>不污染 DOM&lt;/strong>：使用者 DOM 不會被加自家 attribute、適合第三方 library&lt;/li>
&lt;li>&lt;strong>跟 framework 完全解耦&lt;/strong>：framework 怎麼操作 DOM 都不影響 WeakMap 紀錄&lt;/li>
&lt;/ol>
&lt;p>代價是 debug 不便（看不到狀態）、紀錄跟 JS context 綁定（換頁就消失）。&lt;/p>
&lt;hr>
&lt;h2 id="適合的情境">適合的情境&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>為什麼合理&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>寫第三方 library / npm package&lt;/td>
 &lt;td>不在使用者 DOM 加 attribute、避免命名衝突&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Framework 會清非預期的 attribute&lt;/td>
 &lt;td>WeakMap 不在 DOM、framework 動不到&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>需要週期性 reset 紀錄&lt;/td>
 &lt;td>&lt;code>processed = new WeakMap()&lt;/code> 一行重置全部&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>紀錄複雜資料、不只是 boolean&lt;/td>
 &lt;td>WeakMap value 可以是任何物件&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>核心特徵&lt;/strong>：紀錄獨立於 DOM 之外、跟 JS 物件 lifetime 綁定。&lt;/p>
&lt;hr>
&lt;h2 id="不適合的情境">不適合的情境&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>為什麼不夠&lt;/th>
 &lt;th>改用&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>自家 application、devtools debug 重要&lt;/td>
 &lt;td>看不到狀態、debug 困難&lt;/td>
 &lt;td>&lt;a href="../pattern-attribute-idempotency-marker/">DOM attribute 標記&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨頁面 / 跨 session 的 idempotency&lt;/td>
 &lt;td>WeakMap 在 JS context 內、換頁就消失&lt;/td>
 &lt;td>LocalStorage / 後端紀錄&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元素生命週期短、頻繁 GC&lt;/td>
 &lt;td>WeakMap 自動清理可能比預期早&lt;/td>
 &lt;td>改用 Map（但要手動清理）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>紀錄要跟 SSR 同步&lt;/td>
 &lt;td>WeakMap 只活在 client&lt;/td>
 &lt;td>結合 attribute（SSR 階段標記）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="設計細節">設計細節&lt;/h2>
&lt;h3 id="為什麼用-weakmap-不用-map--set">為什麼用 WeakMap 不用 Map / Set&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// WeakMap：key 是元素、元素被 GC 時 entry 自動消失
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">var&lt;/span> &lt;span class="nx">processedW&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">WeakMap&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nx">processedW&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">// el 從 DOM 移除 + 沒其他 reference → GC → WeakMap entry 消失
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">// Map / Set：強引用、阻止 GC
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">var&lt;/span> &lt;span class="nx">processedS&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">Set&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="nx">processedS&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="c1">// el 從 DOM 移除、但 Set 還抓著 → 永久 leak
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>DOM 元素可能動態移除（filter、SPA 路由切換、framework 重繪）— Map / Set 會造成 memory leak。&lt;strong>處理 DOM 元素 idempotency 預設用 WeakMap&lt;/strong>。&lt;/p>
&lt;h3 id="value-的設計">Value 的設計&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 用法 1：純 boolean（最簡）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">processed&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1">// 用法 2：紀錄處理版本（升級時偵測 stale 紀錄）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">processed&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">version&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nb">Date&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">processed&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">has&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">processed&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">version&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="nx">currentVersion&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">// 用法 3：紀錄相關 metadata（避免重複查詢）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">processed&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">bindingsId&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">registerListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">initialClass&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>WeakMap value 可以儲任何資料 — 比 attribute（只能存字串）更彈性。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心做法">核心做法</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">processed</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">WeakMap</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">shell</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__result&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">processed</span><span class="p">.</span><span class="nx">has</span><span class="p">(</span><span class="nx">el</span><span class="p">))</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="c1">// ... 處理
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>  <span class="nx">processed</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>把「已處理」狀態紀錄在 JS 的 WeakMap 裡、不寫到 DOM 上。WeakMap key 是元素本身、元素被 GC 時自動清理。</p>
<hr>
<h2 id="這個做法存在的價值">這個做法存在的價值</h2>
<p>兩件事 <a href="../pattern-attribute-idempotency-marker/">DOM attribute 標記</a> 做不到：</p>
<ol>
<li><strong>不污染 DOM</strong>：使用者 DOM 不會被加自家 attribute、適合第三方 library</li>
<li><strong>跟 framework 完全解耦</strong>：framework 怎麼操作 DOM 都不影響 WeakMap 紀錄</li>
</ol>
<p>代價是 debug 不便（看不到狀態）、紀錄跟 JS context 綁定（換頁就消失）。</p>
<hr>
<h2 id="適合的情境">適合的情境</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼合理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫第三方 library / npm package</td>
          <td>不在使用者 DOM 加 attribute、避免命名衝突</td>
      </tr>
      <tr>
          <td>Framework 會清非預期的 attribute</td>
          <td>WeakMap 不在 DOM、framework 動不到</td>
      </tr>
      <tr>
          <td>需要週期性 reset 紀錄</td>
          <td><code>processed = new WeakMap()</code> 一行重置全部</td>
      </tr>
      <tr>
          <td>紀錄複雜資料、不只是 boolean</td>
          <td>WeakMap value 可以是任何物件</td>
      </tr>
  </tbody>
</table>
<p><strong>核心特徵</strong>：紀錄獨立於 DOM 之外、跟 JS 物件 lifetime 綁定。</p>
<hr>
<h2 id="不適合的情境">不適合的情境</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼不夠</th>
          <th>改用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自家 application、devtools debug 重要</td>
          <td>看不到狀態、debug 困難</td>
          <td><a href="../pattern-attribute-idempotency-marker/">DOM attribute 標記</a></td>
      </tr>
      <tr>
          <td>跨頁面 / 跨 session 的 idempotency</td>
          <td>WeakMap 在 JS context 內、換頁就消失</td>
          <td>LocalStorage / 後端紀錄</td>
      </tr>
      <tr>
          <td>元素生命週期短、頻繁 GC</td>
          <td>WeakMap 自動清理可能比預期早</td>
          <td>改用 Map（但要手動清理）</td>
      </tr>
      <tr>
          <td>紀錄要跟 SSR 同步</td>
          <td>WeakMap 只活在 client</td>
          <td>結合 attribute（SSR 階段標記）</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="設計細節">設計細節</h2>
<h3 id="為什麼用-weakmap-不用-map--set">為什麼用 WeakMap 不用 Map / Set</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// WeakMap：key 是元素、元素被 GC 時 entry 自動消失
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">var</span> <span class="nx">processedW</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">WeakMap</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">processedW</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// el 從 DOM 移除 + 沒其他 reference → GC → WeakMap entry 消失
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">// Map / Set：強引用、阻止 GC
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="kd">var</span> <span class="nx">processedS</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Set</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="nx">processedS</span><span class="p">.</span><span class="nx">add</span><span class="p">(</span><span class="nx">el</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">// el 從 DOM 移除、但 Set 還抓著 → 永久 leak
</span></span></span></code></pre></div><p>DOM 元素可能動態移除（filter、SPA 路由切換、framework 重繪）— Map / Set 會造成 memory leak。<strong>處理 DOM 元素 idempotency 預設用 WeakMap</strong>。</p>
<h3 id="value-的設計">Value 的設計</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 用法 1：純 boolean（最簡）
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="nx">processed</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">// 用法 2：紀錄處理版本（升級時偵測 stale 紀錄）
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="nx">processed</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span> <span class="p">{</span> <span class="nx">version</span><span class="o">:</span> <span class="mi">2</span><span class="p">,</span> <span class="nx">time</span><span class="o">:</span> <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">processed</span><span class="p">.</span><span class="nx">has</span><span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="nx">processed</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="nx">el</span><span class="p">).</span><span class="nx">version</span> <span class="o">===</span> <span class="nx">currentVersion</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1">// 用法 3：紀錄相關 metadata（避免重複查詢）
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="nx">processed</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="nx">bindingsId</span><span class="o">:</span> <span class="nx">registerListener</span><span class="p">(</span><span class="nx">el</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="nx">initialClass</span><span class="o">:</span> <span class="nx">el</span><span class="p">.</span><span class="nx">className</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>WeakMap value 可以儲任何資料 — 比 attribute（只能存字串）更彈性。</p>
<h3 id="debug-替代方案">Debug 替代方案</h3>
<p>attribute 標記可以在 devtools inspector 直接看；WeakMap 看不到。debug 時的替代：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 開發模式同步寫一份 attribute（production build 時拿掉）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">markProcessed</span><span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">processed</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">DEV_MODE</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-debug-processed&#39;</span><span class="p">,</span> <span class="s1">&#39;true&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>或暴露到 console：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">window</span><span class="p">.</span><span class="nx">__debug_processed</span> <span class="o">=</span> <span class="nx">processed</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// console: __debug_processed.has($0)  // 檢查當前選中元素
</span></span></span></code></pre></div><p>這些都是 workaround、不如 attribute 標記直觀。<strong>選 WeakMap 的人通常已經接受這個 debug 成本</strong>。</p>
<h3 id="reset-紀錄">Reset 紀錄</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// WeakMap 整批 reset
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">processed</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">WeakMap</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// 對比 attribute 整批 reset 要遍歷
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="nx">shell</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;[data-scoped]&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nx">el</span><span class="p">.</span><span class="nx">removeAttribute</span><span class="p">(</span><span class="s1">&#39;data-scoped&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>需要週期性 reset（例如 user 切換 mode、所有元素該重新處理）— WeakMap 一行解決、attribute 要遍歷。</p>
<hr>
<h2 id="跟其他-idempotency-做法的關係">跟其他 idempotency 做法的關係</h2>
<p><a href="../dom-selector-precision/">#14 Selector 精準度</a> 的「過濾」維度有三種做法：</p>
<table>
  <thead>
      <tr>
          <th>做法</th>
          <th>比較</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../pattern-attribute-idempotency-marker/">DOM attribute 標記</a></td>
          <td>production 預設、devtools 可見、有命名衝突風險</td>
      </tr>
      <tr>
          <td>本卡片：WeakMap 紀錄</td>
          <td>不污染 DOM、適合 library、debug 不便</td>
      </tr>
      <tr>
          <td>依賴外部呼叫者保證</td>
          <td>反模式、無防護</td>
      </tr>
  </tbody>
</table>
<p>選擇順序：<strong>自家 application</strong> → attribute；<strong>library / framework 衝突</strong> → WeakMap；<strong>反模式不選</strong>。</p>
<hr>
<h2 id="應用範例library-設計">應用範例：library 設計</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 第三方 library export 的 init 函式
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">initSearchEnhancement</span><span class="p">(</span><span class="nx">shell</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kd">var</span> <span class="nx">processed</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">WeakMap</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="kd">function</span> <span class="nx">apply</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.search-result&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="nx">processed</span><span class="p">.</span><span class="nx">has</span><span class="p">(</span><span class="nx">el</span><span class="p">))</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      <span class="nx">enhanceResult</span><span class="p">(</span><span class="nx">el</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">      <span class="nx">processed</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="nx">apply</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="nx">apply</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">shell</span><span class="p">,</span> <span class="p">{</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nx">subtree</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1">// 使用者：
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"></span><span class="nx">initSearchEnhancement</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.my-search&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1">// 不會在使用者 DOM 上加任何 data-* attribute
</span></span></span></code></pre></div><p>使用者 DOM 完全乾淨、library 行為內聚。</p>
<hr>
<h2 id="應用範例版本化處理">應用範例：版本化處理</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">var</span> <span class="nx">processed</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">WeakMap</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">var</span> <span class="nx">CURRENT_VERSION</span> <span class="o">=</span> <span class="mi">3</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kd">function</span> <span class="nx">apply</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.x&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="kd">var</span> <span class="nx">record</span> <span class="o">=</span> <span class="nx">processed</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="nx">el</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">record</span> <span class="o">&amp;&amp;</span> <span class="nx">record</span><span class="p">.</span><span class="nx">version</span> <span class="o">===</span> <span class="nx">CURRENT_VERSION</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="c1">// 升級到新版本（可能需要清舊綁定）
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>    <span class="k">if</span> <span class="p">(</span><span class="nx">record</span><span class="p">)</span> <span class="nx">cleanup</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span> <span class="nx">record</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">enhance</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span> <span class="nx">CURRENT_VERSION</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">processed</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span> <span class="p">{</span> <span class="nx">version</span><span class="o">:</span> <span class="nx">CURRENT_VERSION</span><span class="p">,</span> <span class="nx">time</span><span class="o">:</span> <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>版本變動時 — 不需要遍歷 DOM 清舊 attribute、直接用 WeakMap value 比對。</p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該套用本 pattern 嗎？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫第三方 library / npm package</td>
          <td>是 — 不污染使用者 DOM</td>
      </tr>
      <tr>
          <td>Framework 會 strict 清自家 attribute</td>
          <td>是 — WeakMap 跟 framework 解耦</td>
      </tr>
      <tr>
          <td>紀錄需要儲複雜資料（不只 boolean）</td>
          <td>是 — WeakMap value 可任意</td>
      </tr>
      <tr>
          <td>自家 application、debug 重要</td>
          <td>否 — <a href="../pattern-attribute-idempotency-marker/">attribute 標記</a> 在 inspector 可見</td>
      </tr>
      <tr>
          <td>紀錄要跨頁面持久化</td>
          <td>否 — 改用 storage / 後端</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：WeakMap idempotency 是 attribute 標記的「不污染 DOM 替代品」 — 在 library / framework 衝突情境必要、在自家 application 通常用 attribute 即可。GC 自動清理是 WeakMap 的特性、預設不用 Map / Set 是因為它們會 memory leak。</p>
]]></content:encoded></item><item><title>Pattern：跨 slot 同節點搬遷</title><link>https://tarrragon.github.io/blog/report/pattern-cross-slot-node-relocation/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/pattern-cross-slot-node-relocation/</guid><description>&lt;h2 id="核心做法">核心做法&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">mql&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">window&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">matchMedia&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;(min-width: 1400px)&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">place&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">mql&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">matches&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">desktopSlot&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">appendChild&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">drawer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">insertBefore&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">drawer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">firstChild&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="nx">mql&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;change&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">place&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="nx">place&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// 初始化
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>同一個 DOM 節點在兩個 slot 之間搬移、不複製成兩份。&lt;/p>
&lt;hr>
&lt;h2 id="這個做法存在的價值">這個做法存在的價值&lt;/h2>
&lt;p>Stateful UI（內含 checkbox 勾選、表單值、scroll 位置等 state）跨兩個顯示位置切換時、複製兩份會造成 state 分歧 — 使用者在 desktop 勾的 filter、切到 mobile 看不到勾選狀態。&lt;/p>
&lt;p>搬同一份節點 = state 永遠跟著節點走 = 切換無感。&lt;/p>
&lt;hr>
&lt;h2 id="適合的情境">適合的情境&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>為什麼合理&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Filter UI 跨 viewport 切換顯示位置&lt;/td>
 &lt;td>checkbox state 跟著節點&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Modal 內容 vs 側邊抽屜&lt;/td>
 &lt;td>同一份表單在兩種展示方式間&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Tab UI 跨 desktop / mobile 重新組織&lt;/td>
 &lt;td>各 tab 內 state 不重置&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>任何「同 UI、不同位置」的 responsive 切換&lt;/td>
 &lt;td>不需要 state 同步邏輯&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>核心特徵&lt;/strong>：UI 內含 state、兩個位置展示的是「同一個邏輯單位」、不是「兩個獨立元件」。&lt;/p>
&lt;hr>
&lt;h2 id="不適合的情境">不適合的情境&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &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;/tr>
 &lt;tr>
 &lt;td>UI 純 stateless（純圖示、純文字）&lt;/td>
 &lt;td>複製兩份成本低、無 state 風險&lt;/td>
 &lt;td>CSS-only 雙顯示 + display 切換&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Framework 管的節點&lt;/td>
 &lt;td>整節點搬安全、但複製不安全（id duplicate / framework 困惑）&lt;/td>
 &lt;td>必須搬整節點、不複製&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>兩個位置視覺差異大&lt;/td>
 &lt;td>搬遷後 UI 不適配新位置&lt;/td>
 &lt;td>各自獨立元件&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="設計細節">設計細節&lt;/h2>
&lt;h3 id="appendchild-是搬遷不是複製">&lt;code>appendChild&lt;/code> 是搬遷、不是複製&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">parentA&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">appendChild&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">node&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c1">// node 從原位置消失、出現在 parentA
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>DOM API 的 &lt;code>appendChild&lt;/code> / &lt;code>insertBefore&lt;/code> 是 move、不是 copy — 同一個節點不能同時存在於多個位置。這個特性正是搬遷 pattern 的基礎。&lt;/p>
&lt;h3 id="初始放在哪">初始放在哪&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c">&amp;lt;!-- 預設位置（mobile / fallback）--&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;pagefind-ui&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;drawer&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;filter-panel&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>...&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span> &lt;span class="c">&amp;lt;!-- 初始在這 --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c">&amp;lt;!-- 桌面 slot（空、等待搬入）--&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">aside&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;desktop-filter-slot&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&amp;lt;/&lt;/span>&lt;span class="nt">aside&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>預設放在 fallback 位置 — 當 JS 失敗時仍可見。&lt;/p>
&lt;h3 id="跨-slot-切換的時機">跨 slot 切換的時機&lt;/h3>
&lt;p>&lt;code>matchMedia&lt;/code> event 是 viewport 跨過 breakpoint 的瞬間：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">mql&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">window&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">matchMedia&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;(min-width: 1400px)&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nx">mql&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;change&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">place&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nx">place&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// 初始也跑一次
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>不要用 resize event — 太頻繁、會在 breakpoint 邊界震盪。&lt;code>matchMedia&lt;/code> 只在 cross 的瞬間觸發。&lt;/p>
&lt;h3 id="搬遷時-framework-的-reactivity">搬遷時 framework 的 reactivity&lt;/h3>
&lt;p>如果搬遷的節點是 framework 管的（如 Pagefind 的 svelte 元件）— 整節點搬通常安全、framework 在下次 patch 時看到節點還在、繼續更新內部。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心做法">核心做法</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">var</span> <span class="nx">mql</span> <span class="o">=</span> <span class="nb">window</span><span class="p">.</span><span class="nx">matchMedia</span><span class="p">(</span><span class="s1">&#39;(min-width: 1400px)&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">function</span> <span class="nx">place</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">mql</span><span class="p">.</span><span class="nx">matches</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">desktopSlot</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">filter</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">drawer</span><span class="p">.</span><span class="nx">insertBefore</span><span class="p">(</span><span class="nx">filter</span><span class="p">,</span> <span class="nx">drawer</span><span class="p">.</span><span class="nx">firstChild</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="nx">mql</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;change&#39;</span><span class="p">,</span> <span class="nx">place</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="nx">place</span><span class="p">();</span>  <span class="c1">// 初始化
</span></span></span></code></pre></div><p>同一個 DOM 節點在兩個 slot 之間搬移、不複製成兩份。</p>
<hr>
<h2 id="這個做法存在的價值">這個做法存在的價值</h2>
<p>Stateful UI（內含 checkbox 勾選、表單值、scroll 位置等 state）跨兩個顯示位置切換時、複製兩份會造成 state 分歧 — 使用者在 desktop 勾的 filter、切到 mobile 看不到勾選狀態。</p>
<p>搬同一份節點 = state 永遠跟著節點走 = 切換無感。</p>
<hr>
<h2 id="適合的情境">適合的情境</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼合理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Filter UI 跨 viewport 切換顯示位置</td>
          <td>checkbox state 跟著節點</td>
      </tr>
      <tr>
          <td>Modal 內容 vs 側邊抽屜</td>
          <td>同一份表單在兩種展示方式間</td>
      </tr>
      <tr>
          <td>Tab UI 跨 desktop / mobile 重新組織</td>
          <td>各 tab 內 state 不重置</td>
      </tr>
      <tr>
          <td>任何「同 UI、不同位置」的 responsive 切換</td>
          <td>不需要 state 同步邏輯</td>
      </tr>
  </tbody>
</table>
<p><strong>核心特徵</strong>：UI 內含 state、兩個位置展示的是「同一個邏輯單位」、不是「兩個獨立元件」。</p>
<hr>
<h2 id="不適合的情境">不適合的情境</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼不夠</th>
          <th>改用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>兩個位置展示的是不同元件（雖然視覺類似）</td>
          <td>搬遷會把錯誤元件搬到錯位置</td>
          <td>各自獨立掛載、不搬</td>
      </tr>
      <tr>
          <td>UI 純 stateless（純圖示、純文字）</td>
          <td>複製兩份成本低、無 state 風險</td>
          <td>CSS-only 雙顯示 + display 切換</td>
      </tr>
      <tr>
          <td>Framework 管的節點</td>
          <td>整節點搬安全、但複製不安全（id duplicate / framework 困惑）</td>
          <td>必須搬整節點、不複製</td>
      </tr>
      <tr>
          <td>兩個位置視覺差異大</td>
          <td>搬遷後 UI 不適配新位置</td>
          <td>各自獨立元件</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="設計細節">設計細節</h2>
<h3 id="appendchild-是搬遷不是複製"><code>appendChild</code> 是搬遷、不是複製</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">parentA</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">node</span><span class="p">);</span>  <span class="c1">// node 從原位置消失、出現在 parentA
</span></span></span></code></pre></div><p>DOM API 的 <code>appendChild</code> / <code>insertBefore</code> 是 move、不是 copy — 同一個節點不能同時存在於多個位置。這個特性正是搬遷 pattern 的基礎。</p>
<h3 id="初始放在哪">初始放在哪</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">&lt;!-- 預設位置（mobile / fallback）--&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;pagefind-ui&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;drawer&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;filter-panel&#34;</span><span class="p">&gt;</span>...<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>  <span class="c">&lt;!-- 初始在這 --&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c">&lt;!-- 桌面 slot（空、等待搬入）--&gt;</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">&lt;</span><span class="nt">aside</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;desktop-filter-slot&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">aside</span><span class="p">&gt;</span></span></span></code></pre></div><p>預設放在 fallback 位置 — 當 JS 失敗時仍可見。</p>
<h3 id="跨-slot-切換的時機">跨 slot 切換的時機</h3>
<p><code>matchMedia</code> event 是 viewport 跨過 breakpoint 的瞬間：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">mql</span> <span class="o">=</span> <span class="nb">window</span><span class="p">.</span><span class="nx">matchMedia</span><span class="p">(</span><span class="s1">&#39;(min-width: 1400px)&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">mql</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;change&#39;</span><span class="p">,</span> <span class="nx">place</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">place</span><span class="p">();</span>  <span class="c1">// 初始也跑一次
</span></span></span></code></pre></div><p>不要用 resize event — 太頻繁、會在 breakpoint 邊界震盪。<code>matchMedia</code> 只在 cross 的瞬間觸發。</p>
<h3 id="搬遷時-framework-的-reactivity">搬遷時 framework 的 reactivity</h3>
<p>如果搬遷的節點是 framework 管的（如 Pagefind 的 svelte 元件）— 整節點搬通常安全、framework 在下次 patch 時看到節點還在、繼續更新內部。</p>
<p>詳細安全規則由 <a href="../component-boundary-and-js-impact/">#13 JS 操作 framework 元件：邊界辨識與安全規則</a> 處理。</p>
<h3 id="focus-跟著搬">Focus 跟著搬</h3>
<p>搬遷可能讓鍵盤 focus 暫時失去（視瀏覽器）— 加 save/restore：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">place</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">var</span> <span class="nx">activeBefore</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">activeElement</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">mql</span><span class="p">.</span><span class="nx">matches</span><span class="p">)</span> <span class="nx">desktopSlot</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">filter</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">else</span> <span class="nx">drawer</span><span class="p">.</span><span class="nx">insertBefore</span><span class="p">(</span><span class="nx">filter</span><span class="p">,</span> <span class="nx">drawer</span><span class="p">.</span><span class="nx">firstChild</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">activeBefore</span> <span class="o">&amp;&amp;</span> <span class="nx">filter</span><span class="p">.</span><span class="nx">contains</span><span class="p">(</span><span class="nx">activeBefore</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">activeBefore</span><span class="p">.</span><span class="nx">focus</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>詳細處理由 <a href="../focus-management-on-dom-move/">#37 動態 DOM 移動時的 focus 管理</a> 處理。</p>
<hr>
<h2 id="設計取捨兩個-slot-的-stateful-ui-共用">設計取捨：兩個 slot 的 stateful UI 共用</h2>
<p>四種做法、各自機會成本不同。預設選 A（搬同節點）、其他做法在特定情境合理。</p>
<h3 id="a搬同一節點這個專案的預設">A：搬同一節點（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：<code>matchMedia + appendChild</code> 在兩 slot 間搬同一份節點</li>
<li><strong>選 A 的理由</strong>：state 跟著節點、切換無感、不需要 sync 邏輯</li>
<li><strong>適合</strong>：stateful UI、需要在兩個位置展示同樣內容</li>
<li><strong>代價</strong>：搬遷 callback 在 viewport 跨 breakpoint 時觸發、需要處理 focus / 動畫</li>
<li><strong>詳細</strong>：本卡片</li>
</ul>
<h3 id="bcss-only-雙顯示--display-切換">B：CSS-only 雙顯示 + display 切換</h3>
<ul>
<li><strong>機制</strong>：兩個位置都放同一份節點 (寫兩遍 HTML)、用 <code>@media + display: none</code> 切換顯示</li>
<li><strong>跟 A 的取捨</strong>：B 純 CSS 簡單、A 需要 JS；但 B 對 stateful UI 失敗（兩份 state 各自獨立）</li>
<li><strong>B 比 A 好的情境</strong>：UI 純 stateless（純圖示）、純 CSS 解就夠</li>
</ul>
<h3 id="ccss-only--js-同步-state">C：CSS-only + JS 同步 state</h3>
<ul>
<li><strong>機制</strong>：兩份節點 + JS 監聽 state 變動同步</li>
<li><strong>跟 A 的取捨</strong>：C 比 B 解 state 問題、但同步邏輯複雜（雙向更新、避免循環）</li>
<li><strong>C 比 A 好的情境</strong>：兩個位置的 UI 視覺需要差異（不只是位置不同）</li>
</ul>
<h3 id="djs-完全重建-ui">D：JS 完全重建 UI</h3>
<ul>
<li><strong>機制</strong>：viewport 變動時拆掉舊 UI、在新位置重建一份</li>
<li><strong>成本特別高的原因</strong>：state 在重建時遺失、UI 閃爍、輸入中斷</li>
<li><strong>D 才合理的情境</strong>：UI 是 stateless 的、且重建成本低</li>
</ul>
<hr>
<h2 id="跟其他-pattern-的關係">跟其他 pattern 的關係</h2>
<p><a href="../dom-selector-precision/">#14 Selector 精準度</a> 的「起點」維度有四種做法、本卡片是「跨 slot 搬遷」這個專門情境的補充：</p>
<table>
  <thead>
      <tr>
          <th>議題</th>
          <th>對應 pattern</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Query 的起點</td>
          <td><a href="../pattern-document-query/">#46 document</a> / <a href="../pattern-component-root/">#47 元件根變數</a> / <a href="../pattern-root-as-parameter/">#48 起點當參數</a> / <a href="../pattern-closest-lookup/">#49 closest 反向</a></td>
      </tr>
      <tr>
          <td>Idempotency 過濾</td>
          <td><a href="../pattern-attribute-idempotency-marker/">#50 attribute 標記</a> / <a href="../pattern-weakmap-idempotency-record/">#51 WeakMap</a></td>
      </tr>
      <tr>
          <td>跨 slot 搬遷（本卡片）</td>
          <td>同節點 vs 雙節點 + state 同步</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="應用範例跨-viewport-filter-切換">應用範例：跨 viewport filter 切換</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">setupResponsiveFilter</span><span class="p">(</span><span class="nx">shell</span><span class="p">,</span> <span class="nx">breakpoint</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">var</span> <span class="nx">filter</span> <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__filter-panel&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kd">var</span> <span class="nx">drawer</span> <span class="o">=</span> <span class="nx">shell</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">var</span> <span class="nx">desktopSlot</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-filter-slot&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">filter</span> <span class="o">||</span> <span class="o">!</span><span class="nx">drawer</span> <span class="o">||</span> <span class="o">!</span><span class="nx">desktopSlot</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="kd">var</span> <span class="nx">mql</span> <span class="o">=</span> <span class="nb">window</span><span class="p">.</span><span class="nx">matchMedia</span><span class="p">(</span><span class="s1">&#39;(min-width: &#39;</span> <span class="o">+</span> <span class="nx">breakpoint</span> <span class="o">+</span> <span class="s1">&#39;px)&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="kd">function</span> <span class="nx">place</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="kd">var</span> <span class="nx">activeBefore</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">activeElement</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">mql</span><span class="p">.</span><span class="nx">matches</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">      <span class="nx">desktopSlot</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">filter</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">      <span class="nx">drawer</span><span class="p">.</span><span class="nx">insertBefore</span><span class="p">(</span><span class="nx">filter</span><span class="p">,</span> <span class="nx">drawer</span><span class="p">.</span><span class="nx">firstChild</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">activeBefore</span> <span class="o">&amp;&amp;</span> <span class="nx">filter</span><span class="p">.</span><span class="nx">contains</span><span class="p">(</span><span class="nx">activeBefore</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">      <span class="nx">activeBefore</span><span class="p">.</span><span class="nx">focus</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl">  <span class="nx">place</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">  <span class="nx">mql</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;change&#39;</span><span class="p">,</span> <span class="nx">place</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>完整 pattern：取元件根 + matchMedia + 搬遷 + focus 處理。</p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該套用本 pattern 嗎？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>兩份節點各自 state、用 sync 邏輯保持一致</td>
          <td>是 — 改成搬同節點、移除 sync</td>
      </tr>
      <tr>
          <td>Stateful UI 在 mobile / desktop 兩種 layout 間</td>
          <td>是 — 直接的應用</td>
      </tr>
      <tr>
          <td>切換 viewport 時 UI 閃爍 / 重建</td>
          <td>是 — 改成搬而非重建</td>
      </tr>
      <tr>
          <td>兩個位置展示完全不同的 UI（不是同邏輯）</td>
          <td>否 — 各自獨立元件</td>
      </tr>
      <tr>
          <td>Framework 管的節點</td>
          <td>是 — 整節點搬安全、但要遵守 <a href="../component-boundary-and-js-impact/">#13</a> 的規則</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：Stateful UI 的兩個展示位置共用同一份節點、state 自然跟著走 — 比「兩份節點 + sync 邏輯」乾淨。複製兩份是「state 來源從一變二」的隱形多源（違反 <a href="../single-source-of-truth/">#44 SSoT</a>）。</p>
]]></content:encoded></item><item><title>CSS / JS Boundary — CSS / JS 邊界與 specificity 處理</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/css-js-boundary/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/css-js-boundary/</guid><description>&lt;p>CSS 跟 JS 各自負責什麼、邊界由「值能不能 build-time 定下來」決定。&lt;code>!important&lt;/code> / inline style / specificity 戰是訊號、不是工具。&lt;/p>
&lt;p>適用：寫 / 改 CSS 規則、決定 styling 該放 CSS 還是 JS、跟 vendor CSS 共存、檔案組織。
不適用：純 logic JS（沒涉及 styling）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>自包含聲明&lt;/strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋 CSS-only vs JS-assisted 判準、class toggle 模式、CSS layers、variable 單一位置、檔案拆分。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="何時參閱本文件">何時參閱本文件&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>該做的第一件事&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>不確定值該寫進 CSS 還是 JS&lt;/td>
 &lt;td>問「能 build-time 定下來嗎」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>即將寫 &lt;code>!important&lt;/code>&lt;/td>
 &lt;td>停 — 換 CSS layers 思路&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>即將寫 &lt;code>el.style.setProperty(..., 'important')&lt;/code>&lt;/td>
 &lt;td>停 — 換 class toggle&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Inline &lt;code>&amp;lt;style&amp;gt;&lt;/code> / &lt;code>&amp;lt;script&amp;gt;&lt;/code> 超過 30 行&lt;/td>
 &lt;td>拆出獨立檔案、讓 Hugo / build pipeline 處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CSS variable 在 3 個地方定義&lt;/td>
 &lt;td>集中到單一定義位置、其他地方只引用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Vendor CSS 跟自家 CSS 打 specificity 戰&lt;/td>
 &lt;td>&lt;code>@layer&lt;/code> 包 vendor、自家 unlayered 自動贏&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Runtime 量測值跟 hardcoded 值在同一個對齊基準上混用&lt;/td>
 &lt;td>全選一邊、不要混搭&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼-css--js-邊界要清楚">為什麼 CSS / JS 邊界要清楚&lt;/h2>
&lt;p>樣式邏輯散落在 inline style + CSS file + JS setProperty + &lt;code>!important&lt;/code> 的後果：&lt;/p>
&lt;ol>
&lt;li>改一個顏色要 grep 三個地方、其中一個改不到&lt;/li>
&lt;li>DevTools 看不出「為什麼這個值在這裡」（inline style 沒 class hint、important 是核武）&lt;/li>
&lt;li>升級 vendor 後 specificity 戰失敗、自家規則失效&lt;/li>
&lt;/ol>
&lt;p>清楚的邊界 = &lt;strong>CSS 描述「在某狀態下長什麼樣」、JS 切換狀態（toggle class / 寫 var）&lt;/strong>。樣式定義集中在 CSS、JS 不直接操作 inline style。&lt;/p>
&lt;hr>
&lt;h2 id="邊界判準值能不能-build-time-定下來">邊界判準：值能不能 build-time 定下來&lt;/h2>
&lt;h3 id="css-only值能-build-time-定下來">CSS-only：值能 build-time 定下來&lt;/h3>
&lt;ul>
&lt;li>Design token（&lt;code>--brand-color&lt;/code>、&lt;code>--gap-base&lt;/code>）&lt;/li>
&lt;li>固定 breakpoint / aspect ratio&lt;/li>
&lt;li>元件預設尺寸&lt;/li>
&lt;li>跨狀態的視覺差異（&lt;code>.expanded&lt;/code>、&lt;code>.loading&lt;/code>）&lt;/li>
&lt;/ul>
&lt;p>寫成 CSS variable + class toggle、JS 只負責加減 class。&lt;/p>
&lt;h3 id="js-assisted必須-runtime-才能知道">JS-assisted：必須 runtime 才能知道&lt;/h3>
&lt;ul>
&lt;li>Form 高度（隨字型 / line-height 變動）&lt;/li>
&lt;li>Container 寬度（隨 viewport / sidebar 變動）&lt;/li>
&lt;li>Scroll position&lt;/li>
&lt;li>元素的 bounding rect&lt;/li>
&lt;/ul>
&lt;p>JS 量測後&lt;strong>寫回 CSS variable&lt;/strong>、CSS 仍然只讀變數：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">formHeight&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">form&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getBoundingClientRect&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">height&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">documentElement&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">style&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setProperty&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;--form-height&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="sb">`&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">formHeight&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">px`&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">scope&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">top&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">calc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">form&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">height&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">gap&lt;/span>&lt;span class="p">));&lt;/span> &lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>CSS 不知道值怎麼來的、只知道讀 var — 換 framework / 換量測方式時、CSS 不動。&lt;/p></description><content:encoded><![CDATA[<p>CSS 跟 JS 各自負責什麼、邊界由「值能不能 build-time 定下來」決定。<code>!important</code> / inline style / specificity 戰是訊號、不是工具。</p>
<p>適用：寫 / 改 CSS 規則、決定 styling 該放 CSS 還是 JS、跟 vendor CSS 共存、檔案組織。
不適用：純 logic JS（沒涉及 styling）。</p>
<blockquote>
<p><strong>自包含聲明</strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋 CSS-only vs JS-assisted 判準、class toggle 模式、CSS layers、variable 單一位置、檔案拆分。</p></blockquote>
<hr>
<h2 id="何時參閱本文件">何時參閱本文件</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的第一件事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>不確定值該寫進 CSS 還是 JS</td>
          <td>問「能 build-time 定下來嗎」</td>
      </tr>
      <tr>
          <td>即將寫 <code>!important</code></td>
          <td>停 — 換 CSS layers 思路</td>
      </tr>
      <tr>
          <td>即將寫 <code>el.style.setProperty(..., 'important')</code></td>
          <td>停 — 換 class toggle</td>
      </tr>
      <tr>
          <td>Inline <code>&lt;style&gt;</code> / <code>&lt;script&gt;</code> 超過 30 行</td>
          <td>拆出獨立檔案、讓 Hugo / build pipeline 處理</td>
      </tr>
      <tr>
          <td>CSS variable 在 3 個地方定義</td>
          <td>集中到單一定義位置、其他地方只引用</td>
      </tr>
      <tr>
          <td>Vendor CSS 跟自家 CSS 打 specificity 戰</td>
          <td><code>@layer</code> 包 vendor、自家 unlayered 自動贏</td>
      </tr>
      <tr>
          <td>Runtime 量測值跟 hardcoded 值在同一個對齊基準上混用</td>
          <td>全選一邊、不要混搭</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼-css--js-邊界要清楚">為什麼 CSS / JS 邊界要清楚</h2>
<p>樣式邏輯散落在 inline style + CSS file + JS setProperty + <code>!important</code> 的後果：</p>
<ol>
<li>改一個顏色要 grep 三個地方、其中一個改不到</li>
<li>DevTools 看不出「為什麼這個值在這裡」（inline style 沒 class hint、important 是核武）</li>
<li>升級 vendor 後 specificity 戰失敗、自家規則失效</li>
</ol>
<p>清楚的邊界 = <strong>CSS 描述「在某狀態下長什麼樣」、JS 切換狀態（toggle class / 寫 var）</strong>。樣式定義集中在 CSS、JS 不直接操作 inline style。</p>
<hr>
<h2 id="邊界判準值能不能-build-time-定下來">邊界判準：值能不能 build-time 定下來</h2>
<h3 id="css-only值能-build-time-定下來">CSS-only：值能 build-time 定下來</h3>
<ul>
<li>Design token（<code>--brand-color</code>、<code>--gap-base</code>）</li>
<li>固定 breakpoint / aspect ratio</li>
<li>元件預設尺寸</li>
<li>跨狀態的視覺差異（<code>.expanded</code>、<code>.loading</code>）</li>
</ul>
<p>寫成 CSS variable + class toggle、JS 只負責加減 class。</p>
<h3 id="js-assisted必須-runtime-才能知道">JS-assisted：必須 runtime 才能知道</h3>
<ul>
<li>Form 高度（隨字型 / line-height 變動）</li>
<li>Container 寬度（隨 viewport / sidebar 變動）</li>
<li>Scroll position</li>
<li>元素的 bounding rect</li>
</ul>
<p>JS 量測後<strong>寫回 CSS variable</strong>、CSS 仍然只讀變數：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">formHeight</span> <span class="o">=</span> <span class="nx">form</span><span class="p">.</span><span class="nx">getBoundingClientRect</span><span class="p">().</span><span class="nx">height</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;--form-height&#39;</span><span class="p">,</span> <span class="sb">`</span><span class="si">${</span><span class="nx">formHeight</span><span class="si">}</span><span class="sb">px`</span><span class="p">);</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">scope</span> <span class="p">{</span> <span class="k">top</span><span class="p">:</span> <span class="nb">calc</span><span class="p">(</span><span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">form</span><span class="o">-</span><span class="n">height</span><span class="p">)</span> <span class="o">+</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">gap</span><span class="p">));</span> <span class="p">}</span></span></span></code></pre></div><p>CSS 不知道值怎麼來的、只知道讀 var — 換 framework / 換量測方式時、CSS 不動。</p>
<hr>
<h2 id="模式-1class-toggle-取代-inline-style">模式 1：Class toggle 取代 inline style</h2>
<h3 id="反例">反例</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// JS 直接設 inline style + important
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">showScope</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">scope</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;display&#39;</span><span class="p">,</span> <span class="s1">&#39;block&#39;</span><span class="p">,</span> <span class="s1">&#39;important&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kd">function</span> <span class="nx">hideScope</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nx">scope</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;display&#39;</span><span class="p">,</span> <span class="s1">&#39;none&#39;</span><span class="p">,</span> <span class="s1">&#39;important&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>DevTools 看到 inline style + important、不知道為什麼、難 debug。</p>
<h3 id="對例">對例</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">setScope</span><span class="p">(</span><span class="nx">visible</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">scope</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nx">toggle</span><span class="p">(</span><span class="s1">&#39;is-visible&#39;</span><span class="p">,</span> <span class="nx">visible</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">scope</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">none</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">scope</span><span class="p">.</span><span class="nc">is-visible</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">block</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>樣式留在 CSS、JS 只 toggle state。改視覺只動 CSS、改 logic 只動 JS。</p>
<hr>
<h2 id="模式-2css-layers-取代-specificity-戰">模式 2：CSS Layers 取代 specificity 戰</h2>
<h3 id="反例-1">反例</h3>
<p>自家規則被 vendor 的 <code>.pagefind-ui .target</code> 蓋過、寫 <code>.parent .container .target</code> 加 specificity、再不行加 <code>!important</code>。</p>
<h3 id="對例-1">對例</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">@</span><span class="k">layer</span> <span class="nt">vendor</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">@</span><span class="k">import</span> <span class="nt">url</span><span class="o">(</span><span class="s1">&#39;vendor/pagefind.css&#39;</span><span class="o">)</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c">/* 自家規則 unlayered → 自動贏所有 layered 規則 */</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">.</span><span class="nc">target</span> <span class="p">{</span> <span class="k">color</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">brand</span><span class="p">);</span> <span class="p">}</span></span></span></code></pre></div><p><code>@layer vendor</code> 把 vendor CSS 放進低優先級的 layer、自家 unlayered 規則自動贏。再也不用打 specificity 戰。</p>
<p><code>@layer</code> 在 Chrome 99+ / Firefox 97+ / Safari 15.4+ 全部支援（2022+）。</p>
<hr>
<h2 id="模式-3css-variable-單一定義位置">模式 3：CSS Variable 單一定義位置</h2>
<h3 id="反例-2">反例</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">:</span><span class="nd">root</span> <span class="p">{</span> <span class="nv">--gap</span><span class="p">:</span> <span class="mi">16</span><span class="kt">px</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">results</span> <span class="p">{</span> <span class="nv">--gap</span><span class="p">:</span> <span class="mi">16</span><span class="kt">px</span><span class="p">;</span> <span class="k">padding</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">gap</span><span class="p">);</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">.</span><span class="nc">scope</span> <span class="p">{</span> <span class="nv">--gap</span><span class="p">:</span> <span class="mi">16</span><span class="kt">px</span><span class="p">;</span> <span class="k">margin-top</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">gap</span><span class="p">);</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c">/* 三處定義、改一個地方漏改 */</span></span></span></code></pre></div><h3 id="對例-2">對例</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">:</span><span class="nd">root</span> <span class="p">{</span> <span class="nv">--gap</span><span class="p">:</span> <span class="mi">16</span><span class="kt">px</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">results</span> <span class="p">{</span> <span class="k">padding</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">gap</span><span class="p">);</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">.</span><span class="nc">scope</span> <span class="p">{</span> <span class="k">margin-top</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">gap</span><span class="p">);</span> <span class="p">}</span></span></span></code></pre></div><p>定義集中 <code>:root</code>（global）、<code>.page-search</code>（page-scoped）、或 <code>.pagefind-ui</code>（component-scoped）— <strong>挑最窄能涵蓋所有用途的 selector</strong>。其他地方只引用、不重新定義。</p>
<p>JS 寫 variable 也寫到同個 selector：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;--form-height&#39;</span><span class="p">,</span> <span class="s1">&#39;...&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// 而不是 form.style.setProperty(...) 在 form 上設
</span></span></span></code></pre></div><hr>
<h2 id="模式-4inline-程式碼超過-30-行就拆檔">模式 4：Inline 程式碼超過 30 行就拆檔</h2>
<h3 id="反例-3">反例</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">style</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">.</span><span class="nc">scope</span> <span class="p">{</span> <span class="err">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">.</span><span class="nc">results</span> <span class="p">{</span> <span class="err">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="c">/* ... 50 行 */</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">&lt;/</span><span class="nt">style</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">&lt;</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="kd">function</span> <span class="nx">decorate</span><span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="cm">/* ... 80 行 */</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span></span></span></code></pre></div><p>問題：沒 syntax highlight、沒 minify、沒 fingerprint cache-bust、改一行整個 HTML reload。</p>
<h3 id="對例-3">對例</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl">{{ $css := resources.Get &#34;css/search.css&#34; | minify | fingerprint }}
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;stylesheet&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;{{ $css.RelPermalink }}&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">{{ $js := resources.Get &#34;js/search.js&#34; | minify | fingerprint }}
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">&lt;</span><span class="nt">script</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;{{ $js.RelPermalink }}&#34;</span> <span class="na">defer</span><span class="p">&gt;&lt;/</span><span class="nt">script</span><span class="p">&gt;</span></span></span></code></pre></div><p>獨立檔案 → IDE 支援、build pipeline 處理 minify / fingerprint、cache-bust 自動。</p>
<hr>
<h2 id="模式-5runtime-量測模式統一">模式 5：Runtime 量測模式統一</h2>
<p>對齊基準上的尺寸值要嘛全寫死、要嘛全量測、不要混搭。</p>
<h3 id="反例-4">反例</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">/* form 高度寫死、gap 寫死、scope 用 measured 值 */</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">scope</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">top</span><span class="p">:</span> <span class="nb">calc</span><span class="p">(</span><span class="mi">72</span><span class="kt">px</span> <span class="o">+</span> <span class="mi">16</span><span class="kt">px</span> <span class="o">+</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">scope</span><span class="o">-</span><span class="n">measured</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Form 高度其實會隨字型變動 → 70px 或 76px → scope 跑位。</p>
<h3 id="對例-a全寫死">對例 A：全寫死</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">form</span> <span class="p">{</span> <span class="k">height</span><span class="p">:</span> <span class="mi">72</span><span class="kt">px</span><span class="p">;</span> <span class="p">}</span>  <span class="c">/* 強制固定高度 */</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">scope</span> <span class="p">{</span> <span class="k">top</span><span class="p">:</span> <span class="nb">calc</span><span class="p">(</span><span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">form</span><span class="o">-</span><span class="n">h</span><span class="p">)</span> <span class="o">+</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">gap</span><span class="p">));</span> <span class="p">}</span></span></span></code></pre></div><p>Form 強制固定高度、所有變數都是已知。</p>
<h3 id="對例-b全量測">對例 B：全量測</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">recalc</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">fH</span> <span class="o">=</span> <span class="nx">form</span><span class="p">.</span><span class="nx">getBoundingClientRect</span><span class="p">().</span><span class="nx">height</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kr">const</span> <span class="nx">gap</span> <span class="o">=</span> <span class="nb">parseFloat</span><span class="p">(</span><span class="nx">getComputedStyle</span><span class="p">(</span><span class="nx">form</span><span class="p">).</span><span class="nx">marginBottom</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;--form-h&#39;</span><span class="p">,</span> <span class="sb">`</span><span class="si">${</span><span class="nx">fH</span><span class="si">}</span><span class="sb">px`</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;--gap&#39;</span><span class="p">,</span> <span class="sb">`</span><span class="si">${</span><span class="nx">gap</span><span class="si">}</span><span class="sb">px`</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="k">new</span> <span class="nx">ResizeObserver</span><span class="p">(</span><span class="nx">recalc</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">form</span><span class="p">);</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">scope</span> <span class="p">{</span> <span class="k">top</span><span class="p">:</span> <span class="nb">calc</span><span class="p">(</span><span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">form</span><span class="o">-</span><span class="n">h</span><span class="p">)</span> <span class="o">+</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">gap</span><span class="p">));</span> <span class="p">}</span></span></span></code></pre></div><p>全部 runtime 算、CSS 只讀變數。</p>
<hr>
<h2 id="wrong-vs-right-對照">Wrong vs Right 對照</h2>
<h3 id="範例-1搜尋框背景色客製">範例 1：搜尋框背景色客製</h3>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">input</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;background&#39;</span><span class="p">,</span> <span class="s1">&#39;#fff&#39;</span><span class="p">,</span> <span class="s1">&#39;important&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">input</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;color&#39;</span><span class="p">,</span> <span class="s1">&#39;#000&#39;</span><span class="p">,</span> <span class="s1">&#39;important&#39;</span><span class="p">);</span></span></span></code></pre></div><p><strong>對</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">@</span><span class="k">layer</span> <span class="nt">vendor</span> <span class="p">{</span> <span class="p">@</span><span class="k">import</span> <span class="s1">&#39;pagefind.css&#39;</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">.</span><span class="nc">pagefind-ui__search-input</span> <span class="p">{</span> <span class="k">background</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">bg</span><span class="p">);</span> <span class="k">color</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="kc">text</span><span class="p">);</span> <span class="p">}</span></span></span></code></pre></div><p>JS 不需要參與、純 CSS 解。</p>
<h3 id="範例-2跨-viewport-的-sidebar-切換">範例 2：跨 viewport 的 sidebar 切換</h3>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">window</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;resize&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nb">window</span><span class="p">.</span><span class="nx">innerWidth</span> <span class="o">&gt;=</span> <span class="mi">1400</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">sidebar</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">display</span> <span class="o">=</span> <span class="s1">&#39;block&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">sidebar</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">display</span> <span class="o">=</span> <span class="s1">&#39;none&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p><strong>對</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">sidebar</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">none</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">@</span><span class="k">media</span> <span class="o">(</span><span class="nt">min-width</span><span class="o">:</span> <span class="nt">1400px</span><span class="o">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">.</span><span class="nc">sidebar</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">block</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>值（1400）能 build-time 定下來 → CSS media query 直接寫、不需要 JS resize listener。</p>
<hr>
<h2 id="自檢清單dogfooding">自檢清單（dogfooding）</h2>
<p>寫樣式相關 code 前：</p>
<ul>
<li><input disabled="" type="checkbox"> 我有沒有問「這個值能不能 build-time 定下來」？</li>
<li><input disabled="" type="checkbox"> 我有沒有用 <code>!important</code> / inline <code>setProperty(..., 'important')</code>？（如果有 → 換成 class toggle）</li>
<li><input disabled="" type="checkbox"> 我有沒有跟 vendor CSS 打 specificity 戰？（如果有 → 用 <code>@layer</code>）</li>
<li><input disabled="" type="checkbox"> CSS variable 是不是只在一個地方定義？</li>
<li><input disabled="" type="checkbox"> Inline <code>&lt;style&gt;</code> / <code>&lt;script&gt;</code> 是不是 &lt; 30 行？（超過就拆檔）</li>
<li><input disabled="" type="checkbox"> Runtime 量測跟 hardcoded 值在同一個對齊基準上、是不是只用了一邊？</li>
</ul>
<hr>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>對應的事後檢討（在 <code>content/report/</code>）：</p>
<ul>
<li><a href="/blog/report/css-only-vs-js-assisted/" data-link-title="排版精度的工具選擇：CSS-only vs JS-assisted" data-link-desc="CSS 適合 build-time 可決定的 layout、JS 適合 runtime 才知道的尺寸與 DOM 移動。混淆兩者會讓 layout 跟 framework 渲染週期競爭。本文展開選擇規則。">css-only-vs-js-assisted</a> — 排版精度的工具選擇</li>
<li><a href="/blog/report/class-toggle-over-important/" data-link-title="以 class toggle 取代 inline `display: none !important`" data-link-desc="JS 用 `el.style.setProperty(&#39;display&#39;, &#39;none&#39;, &#39;important&#39;)` 是低層次 hack。在 CSS Layers 環境下、用語意化 class &#43; JS toggle 可以更乾淨、更易 debug。">class-toggle-over-important</a> — class toggle 取代 inline <code>display:none !important</code></li>
<li><a href="/blog/report/css-layers-over-specificity/" data-link-title="CSS Layers 取代 specificity 戰" data-link-desc="用 @import url(&#39;vendor.css&#39;) layer(vendor) 把外部組件 CSS 包進低權層、自家 CSS 留在 unlayered 自動贏 — 不論 specificity 數值。本文展開取代 !important 與雙寫的方法。">css-layers-over-specificity</a> — CSS Layers 取代 specificity 戰</li>
<li><a href="/blog/report/css-variable-single-location/" data-link-title="CSS 變數定義位置統一" data-link-desc="CSS 變數一次定義在離 root 最近的合適位置、其他地方只引用、不重複宣告。改 token 只動一處、避免散落多處難同步。">css-variable-single-location</a> — CSS 變數定義位置統一</li>
<li><a href="/blog/report/extract-css-js-files/" data-link-title="CSS / JS 拆出獨立檔案" data-link-desc="Hugo template 內 inline CSS / JS 超過 30 行就值得拆檔、走 resources pipeline。本文展開拆檔的理由、步驟、與得益。">extract-css-js-files</a> — CSS / JS 拆出獨立檔案</li>
<li><a href="/blog/report/runtime-measurement-unification/" data-link-title="runtime 量測模式統一" data-link-desc="對齊基準上的所有元素、要嘛全部寫死、要嘛全部用 ResizeObserver 量測 — 不要混搭。混搭時某些字型 / theme 變化會打破對齊、且難以重現。">runtime-measurement-unification</a> — runtime 量測模式統一</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.1.0</p>
]]></content:encoded></item><item><title>Frontend with Playwright — SKILL 入口</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/skill/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/skill/</guid><description>&lt;p>框架無關的前端開發協議 + Playwright 驗證。原則適用於 vanilla HTML/CSS/JS、Vue、React、jQuery — 因為核心是「DOM / CSS / JS 三者的本質行為」加上「Playwright 用 live DOM 量測驗證」、不依賴特定框架的渲染機制。&lt;/p>
&lt;p>協議的核心命題：&lt;strong>先讀真實狀態、再寫規則；先量再改、不要靠假設&lt;/strong>。前端 bug 多半來自「寫 CSS 時假設的 DOM 結構與實際不符」、「JS 改完元素被 framework 還原」、「listener 觸發頻率失控」。Playwright 把這些假設變成可驗證的量測值。&lt;/p>
&lt;hr>
&lt;h2 id="core-pillars支柱">Core Pillars（支柱）&lt;/h2>
&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;strong>Read Before Write&lt;/strong> 先讀真實狀態&lt;/td>
 &lt;td>寫 CSS 前用 playwright/DevTools 量真實 DOM；寫 JS 前確認 framework 邊界&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>CSS-First, JS-Augment&lt;/strong> CSS 為主、JS 補強&lt;/td>
 &lt;td>能 build-time 算的進 CSS、必須 runtime 量測的進 JS、邊界清楚不混搭&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Measure, Don&amp;rsquo;t Assume&lt;/strong> 量測、不要假設&lt;/td>
 &lt;td>Layout / 行為 / 互動三層、用 playwright &lt;code>browser_evaluate&lt;/code> 把假設變已知&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="principles原則速查">Principles（原則速查）&lt;/h2>
&lt;p>讀者在本區塊能完成大方向判斷；具體展開（步驟 / 範例）依下方「觸發路由」進對應 reference。&lt;/p>
&lt;h3 id="1-寫-css-前先確認-dom-topology">1. 寫 CSS 前先確認 DOM topology&lt;/h3>
&lt;p>Class name 是約定、不是結構保證。寫 CSS 規則之前、用 playwright &lt;code>browser_evaluate&lt;/code> 讀目標元素的 ancestor chain — 確認它在 DOM tree 的哪個位置、parent / sibling / 共用的 grid cell 是什麼。&lt;/p>
&lt;p>Selector 設計三維度：&lt;strong>起點（document / 元件根 / 函式參數 / closest）+ 範圍（直接子節點 / 子孫）+ 過濾（attribute / 已處理標記）&lt;/strong>。預設用最精準的、有證據再放寬。&lt;/p>
&lt;h3 id="2-css--js-的邊界由值能否-build-time-定下來決定">2. CSS / JS 的邊界由「值能否 build-time 定下來」決定&lt;/h3>
&lt;p>能在 build time 算出來的值（design token、固定 breakpoint、靜態尺寸）→ 寫進 CSS variable / static rule。&lt;strong>必須 runtime 才能知道的值&lt;/strong>（form 高度、scroll 位置、container 寬度）→ JS 量測後寫回 CSS variable、CSS 仍然只讀變數。&lt;/p>
&lt;p>JS 的職責是 &lt;strong>toggle class / 寫 var&lt;/strong>、不是設 inline style。&lt;code>!important&lt;/code> / inline &lt;code>display: none&lt;/code> 是 anti-pattern — 改用 class toggle 把樣式留在 CSS。Vendor CSS 用 &lt;code>@layer&lt;/code> 包起來、自家 unlayered 自動贏 specificity。&lt;/p>
&lt;h3 id="3-playwright-在開發循環的三個位置">3. Playwright 在開發循環的三個位置&lt;/h3>
&lt;p>&lt;strong>位置 1：假設驗證&lt;/strong>（寫 CSS 前）— 讀 ancestor chain、確認結構符合假設。
&lt;strong>位置 2：行為驗證&lt;/strong>（規則寫完後）— 讀 bounding rect / computed style、確認 layout 結果。
&lt;strong>位置 3：互動驗證&lt;/strong>（dispatch event 後讀 state）— 模擬 input / click、量化驗證互動結果。&lt;/p></description><content:encoded><![CDATA[<p>框架無關的前端開發協議 + Playwright 驗證。原則適用於 vanilla HTML/CSS/JS、Vue、React、jQuery — 因為核心是「DOM / CSS / JS 三者的本質行為」加上「Playwright 用 live DOM 量測驗證」、不依賴特定框架的渲染機制。</p>
<p>協議的核心命題：<strong>先讀真實狀態、再寫規則；先量再改、不要靠假設</strong>。前端 bug 多半來自「寫 CSS 時假設的 DOM 結構與實際不符」、「JS 改完元素被 framework 還原」、「listener 觸發頻率失控」。Playwright 把這些假設變成可驗證的量測值。</p>
<hr>
<h2 id="core-pillars支柱">Core Pillars（支柱）</h2>
<table>
  <thead>
      <tr>
          <th>支柱</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Read Before Write</strong> 先讀真實狀態</td>
          <td>寫 CSS 前用 playwright/DevTools 量真實 DOM；寫 JS 前確認 framework 邊界</td>
      </tr>
      <tr>
          <td><strong>CSS-First, JS-Augment</strong> CSS 為主、JS 補強</td>
          <td>能 build-time 算的進 CSS、必須 runtime 量測的進 JS、邊界清楚不混搭</td>
      </tr>
      <tr>
          <td><strong>Measure, Don&rsquo;t Assume</strong> 量測、不要假設</td>
          <td>Layout / 行為 / 互動三層、用 playwright <code>browser_evaluate</code> 把假設變已知</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="principles原則速查">Principles（原則速查）</h2>
<p>讀者在本區塊能完成大方向判斷；具體展開（步驟 / 範例）依下方「觸發路由」進對應 reference。</p>
<h3 id="1-寫-css-前先確認-dom-topology">1. 寫 CSS 前先確認 DOM topology</h3>
<p>Class name 是約定、不是結構保證。寫 CSS 規則之前、用 playwright <code>browser_evaluate</code> 讀目標元素的 ancestor chain — 確認它在 DOM tree 的哪個位置、parent / sibling / 共用的 grid cell 是什麼。</p>
<p>Selector 設計三維度：<strong>起點（document / 元件根 / 函式參數 / closest）+ 範圍（直接子節點 / 子孫）+ 過濾（attribute / 已處理標記）</strong>。預設用最精準的、有證據再放寬。</p>
<h3 id="2-css--js-的邊界由值能否-build-time-定下來決定">2. CSS / JS 的邊界由「值能否 build-time 定下來」決定</h3>
<p>能在 build time 算出來的值（design token、固定 breakpoint、靜態尺寸）→ 寫進 CSS variable / static rule。<strong>必須 runtime 才能知道的值</strong>（form 高度、scroll 位置、container 寬度）→ JS 量測後寫回 CSS variable、CSS 仍然只讀變數。</p>
<p>JS 的職責是 <strong>toggle class / 寫 var</strong>、不是設 inline style。<code>!important</code> / inline <code>display: none</code> 是 anti-pattern — 改用 class toggle 把樣式留在 CSS。Vendor CSS 用 <code>@layer</code> 包起來、自家 unlayered 自動贏 specificity。</p>
<h3 id="3-playwright-在開發循環的三個位置">3. Playwright 在開發循環的三個位置</h3>
<p><strong>位置 1：假設驗證</strong>（寫 CSS 前）— 讀 ancestor chain、確認結構符合假設。
<strong>位置 2：行為驗證</strong>（規則寫完後）— 讀 bounding rect / computed style、確認 layout 結果。
<strong>位置 3：互動驗證</strong>（dispatch event 後讀 state）— 模擬 input / click、量化驗證互動結果。</p>
<p>第 2 次同個版型 bug → 把 query 寫成 playwright 測試固化、CI 防回歸。</p>
<h3 id="4-與-framework-managed-dom-共處的邊界辨識">4. 與 framework-managed DOM 共處的邊界辨識</h3>
<p>把 framework 子樹當「禁區」、客製 UI 注入到 framework 邊界外、用 CSS 控制視覺位置（absolute / margin / grid）。框架重渲染時、邊界外的客製 UI 不被 reconcile 清掉。</p>
<p><strong>JS 操作的邊界穩定性</strong>（從穩到不穩）：reparent 整節點 &gt; 改 inline style &gt; 改 attribute &gt; 改 textContent &gt; 改 innerHTML &gt; 改 framework 子節點。穩定性低的需要 MutationObserver 重做、或乾脆別碰。</p>
<p><strong>外部組件客製的合作層次</strong>（穩定性梯度）：CSS variable / API &gt; class hook &gt; boundary DOM &gt; 內部結構。離公共介面越近、升級越穩。</p>
<h3 id="5-reactive-監聽器的頻率盤點">5. Reactive 監聽器的頻率盤點</h3>
<p>MutationObserver 三維度：<strong>root（最窄）、options（最少）、debounce（最長可接受）</strong>。預設 <code>observer.observe(scope, { childList: true })</code>、不寫 <code>subtree: true</code> 除非有 case。</p>
<p>Polling（<code>setTimeout</code> / <code>setInterval</code>）有事件可監聽就替換成 MutationObserver — 0 latency / 0 idle CPU。Reactive perf debug 從 <code>console.count(callbackName)</code> 起、確認觸發頻率符合預期。</p>
<p>效能風險點四面向：<strong>iteration 成本（500 results × regex test）、reflow 成本（&gt;16ms 觸發 jank）、listener 頻率（如上）、resource 載入時序（lazy chunk vs critical path）</strong>。</p>
<h3 id="6-a11y-三道防線">6. A11y 三道防線</h3>
<p><strong>鍵盤可達性</strong>：visible focus indicator、邏輯 tab 順序、modal 有 escape 路徑。三者缺一不可。
<strong>動態 a11y</strong>：JS reparent / hide 時保存並還原 focus；變動內容用 <code>aria-live=&quot;polite&quot;</code> 廣播給 screen reader。
<strong>Native &gt; ARIA</strong>：能用 <code>&lt;button&gt;</code> / <code>&lt;fieldset&gt;</code> / <code>&lt;dialog&gt;</code> 就不要自己組 ARIA role — native HTML 自帶 keyboard / focus / a11y tree、ARIA 是補強不是替代。</p>
<hr>
<h2 id="when-to-consult-this-skill觸發路由">When to Consult This Skill（觸發路由）</h2>
<table>
  <thead>
      <tr>
          <th>觸發情境</th>
          <th>讀哪份 reference</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>要寫 CSS 規則、需要先確認 DOM 結構 / selector 該怎麼寫</td>
          <td><code>references/dom-topology-first.md</code></td>
      </tr>
      <tr>
          <td>不確定 selector 該多寬、命中其他元素</td>
          <td><code>references/dom-topology-first.md</code></td>
      </tr>
      <tr>
          <td>不確定值該寫進 CSS 還是 JS、CSS layers / variable / class toggle 取捨</td>
          <td><code>references/css-js-boundary.md</code></td>
      </tr>
      <tr>
          <td>用 <code>!important</code> / inline style 解 specificity</td>
          <td><code>references/css-js-boundary.md</code></td>
      </tr>
      <tr>
          <td>要用 playwright 驗證 layout / 假設 / 互動</td>
          <td><code>references/playwright-in-loop.md</code></td>
      </tr>
      <tr>
          <td>Layout bug 第 2 次出現、想寫成測試</td>
          <td><code>references/playwright-in-loop.md</code></td>
      </tr>
      <tr>
          <td>客製 UI 被 framework 還原、不知道該注入到哪</td>
          <td><code>references/framework-coexistence.md</code></td>
      </tr>
      <tr>
          <td>要客製外部組件（pagefind / vendor library）</td>
          <td><code>references/framework-coexistence.md</code></td>
      </tr>
      <tr>
          <td>使用者反映卡頓、CPU 100%、scroll lag、resize jank</td>
          <td><code>references/reactive-performance.md</code></td>
      </tr>
      <tr>
          <td>要設計 MutationObserver / event listener 範圍</td>
          <td><code>references/reactive-performance.md</code></td>
      </tr>
      <tr>
          <td>要驗收鍵盤 / screen reader / motor / 視覺 a11y</td>
          <td><code>references/accessibility-and-focus.md</code></td>
      </tr>
      <tr>
          <td>JS reparent 後 focus 跑掉、aria-live 沒朗讀</td>
          <td><code>references/accessibility-and-focus.md</code></td>
      </tr>
      <tr>
          <td>設計 filter / sort / count 操作、source 是分批 / streaming</td>
          <td><code>references/data-flow-and-filter-composition.md</code></td>
      </tr>
      <tr>
          <td>「Load more 後畫面閃但內容沒變」的 silent 缺口</td>
          <td><code>references/data-flow-and-filter-composition.md</code>（層錯位）</td>
      </tr>
      <tr>
          <td>Backend / 演算法 / map-reduce 的 post-filter 漏項</td>
          <td><code>references/data-flow-and-filter-composition.md</code>（跨領域同結構）</td>
      </tr>
  </tbody>
</table>
<p>每份 reference 自包含：以該情境為核心、把六大原則翻譯成可直接套用的協議步驟與範例。閱讀任一 reference 不需要回來看其他 reference。</p>
<hr>
<h2 id="success-criteriam1-m2-認知負擔類">Success Criteria（M1-M2 認知負擔類）</h2>
<table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>定義</th>
          <th>目標</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>M1</strong></td>
          <td>從 SKILL.md 出發、解決一個觸發情境需要開幾個檔案</td>
          <td>≤ 2</td>
      </tr>
      <tr>
          <td><strong>M2</strong></td>
          <td>隨機抽一份 reference、不讀其他 reference 能否獨立套用</td>
          <td>100%</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="directory-index">Directory Index</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">frontend-with-playwright/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── SKILL.md                                    # 本檔：六大原則速查 + 觸發路由
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">└── references/
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    ├── dom-topology-first.md                   # 情境 1：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 設計
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    ├── css-js-boundary.md                      # 情境 2：CSS-only vs JS-assisted、class toggle、layers、variable 單一位置、檔案拆分
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    ├── playwright-in-loop.md                   # 情境 3：playwright 三個位置（假設 / 行為 / 互動驗證）+ 寫成 layout test
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    ├── framework-coexistence.md                # 情境 4：custom UI 留 framework 邊界外、外部組件四層合作、JS 操作邊界辨識
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    ├── reactive-performance.md                 # 情境 5：observer scope、polling→observer、頻率盤點、iteration / regex / reflow
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    ├── accessibility-and-focus.md              # 情境 6：focus on DOM move、keyboard 三要素、aria-live、native HTML &gt; ARIA
</span></span><span class="line"><span class="ln">10</span><span class="cl">    └── data-flow-and-filter-composition.md     # 情境 7：Filter × Source 層錯位 + 五策略 + 跨領域（前端 / 後端 / 演算法 / DB）</span></span></code></pre></div><hr>
<h2 id="reading-order建議閱讀順序">Reading Order（建議閱讀順序）</h2>
<ol>
<li>第一次接觸 → 從本 SKILL.md 的「三大支柱 + 六大原則」讀起</li>
<li>進入實際情境 → 依觸發路由讀對應 reference（只讀一份）</li>
<li>想驗證自己有沒有套用對 → 用該 reference 結尾的 self-check checklist 自評</li>
</ol>
<hr>
<h2 id="跟-requirement-protocol-的關係">跟 requirement-protocol 的關係</h2>
<p><code>requirement-protocol</code> 是上層的「對話協議」（澄清需求、失敗轉折、覆寫成本、工具切換時機）；本 skill 是下層的「前端執行協議」（DOM / CSS / JS / Playwright 的具體做法）。</p>
<p>當情境是「不確定該怎麼跟使用者溝通」 → 讀 requirement-protocol。
當情境是「知道要做什麼、不確定前端該怎麼實作驗證」 → 讀本 skill。
兩個 skill 的 <code>playwright</code> 段落互補：<code>requirement-protocol/tool-switching-timing</code> 講「何時切」、本 skill 的 <code>playwright-in-loop</code> 講「切了之後具體寫什麼 query」。</p>
<p><code>requirement-protocol/clarifying-ambiguous-instructions</code> 的「類型 5：篩選類」跟本 skill 的 <code>data-flow-and-filter-composition</code> 互補：上層講「該怎麼澄清」、本層講「澄清完該怎麼實作」。</p>
<hr>
<h2 id="相關抽象層原則在-contentreport">相關抽象層原則（在 content/report/）</h2>
<p>本 skill 的協議建立在幾條抽象層原則上：</p>
<ul>
<li><a href="/blog/report/two-occurrence-threshold/" data-link-title="2 次門檻：第一次是運氣、第二次是訊號" data-link-desc="同一個問題出現第 2 次時、就該停下來把處理層級升一階 — 從推理升到量測、從手動驗證升到自動化、從同方向嘗試升到換思路。第 1 次失敗的資訊不足、第 2 次提供「重複出現」的證據、值得付出升級成本。本文是 #11 / #15 / #20 / #23 四篇實作的共同抽象。">#42 2 次門檻</a> — 第 1 次失敗是運氣、第 2 次是訊號（playwright 切換時機的根據）</li>
<li><a href="/blog/report/minimum-necessary-scope-is-sanity-defense/" data-link-title="最小必要範圍是 sanity 防線：保護行為可預測性" data-link-desc="縮 selector 範圍、observer 範圍、JS 操作範圍 — 不是為了效能、是為了讓行為可預測、不被未來變動打破。本文是 #13 / #14 / #29 三篇實作的共同抽象。">#43 最小必要範圍</a> — selector / observer / 操作邊界從窄起（DOM 設計、Reactive 效能的根據）</li>
<li><a href="/blog/report/single-source-of-truth/" data-link-title="Single Source of Truth：值的住址只能有一處" data-link-desc="同一個值（CSS token、視覺基準、runtime 量測）的權威來源只能有一個位置 — 多源時會分歧、會漏改、會讓讀者不知道哪個生效。本文是 #3 / #26 / #27 三篇實作的共同抽象。">#44 SSOT</a> — 值的住址只能一處（CSS 變數、量測一致性的根據）</li>
<li><a href="/blog/report/external-component-collaboration-layers/" data-link-title="跟外部組件合作的層次：離介面越近、合作越穩" data-link-desc="客製外部組件的穩定性與「離組件作者保證的對外介面多遠」成反比。每往內推一層、依賴前提增加、升級風險上升、可逆性下降。本文是 #1 / #5 / #19 / #24 四篇實作的共同抽象。">#45 外部組件合作四層</a> — 離公共介面越近越穩（framework 共處的根據）</li>
<li><a href="/blog/report/compose-feature-at-source-layer/" data-link-title="Feature 操作要跟 Source 同層合成" data-link-desc="Filter / sort / count / transform / search 是 stream 操作、必須跟 stream 的 materialization 同層或更上游合成。在下游做 = 操作 subset 不是 stream。本原則跨前端 UI、後端 API、演算法管線通用、不只是視覺層 vs 資料層。">#64 同層合成</a> — Stream 操作必須跟 materialization 同層（Filter × Source 的本質）</li>
<li><a href="/blog/report/ease-of-writing-vs-intent-alignment/" data-link-title="寫作便利度跟意圖對齊反相關" data-link-desc="寫程式時最容易寫出的版本、通常是離意圖最遠的版本。便利度建立在「現有上下文 / 已 materialize 資料 / 已存在 API」上、而意圖對齊需要找到正確的層、處理上游、跨抽象層 — 兩者方向相反。識別這個反相關 = 識別自己掉進「容易寫的陷阱」。">#67 寫作便利度跟意圖對齊反相關</a> — 容易寫的位置通常是錯位的位置（meta-principle、解釋為什麼層錯位 / 寬 selector / inline style 等便利寫法都會出問題）</li>
<li><a href="/blog/report/verification-timeline-checkpoints/" data-link-title="驗收的時間軸：四個 checkpoint" data-link-desc="驗收不是單一動作、是分散在四個時點（寫之前 / 開發中 / ship 前 / ship 後）的累積判斷。每個 checkpoint 能 catch 不同類型的失敗、成本不同。早期 checkpoint 抓越多、晚期 checkpoint 越輕鬆。實務上常常 collapse 成「寫的時候 &#43; ship 後出問題才修」、跳過寫之前 / ship 前。">#68 驗收的時間軸：四個 checkpoint</a> — Layout test 屬 Ship 前 checkpoint 的具體做法</li>
<li><a href="/blog/report/test-first-red-before-green/" data-link-title="Test-First：先看到 RED 才相信 GREEN" data-link-desc="一個只看過 GREEN 的測試是「未驗證的訊號」、不是「會抓回歸的測試」。必須先在「該失敗的版本」上看到 RED、再在「該通過的版本」上看到 GREEN — 兩次跑都對、才能相信測試真的 catch 到該 catch 的東西。跳過 RED 等於把驗收標準降到「跑得通」、漏掉「測試自己有沒有 bug」這層。">#69 Test-First：先看到 RED 才相信 GREEN</a> — Playwright 測試的驗證協議：寫完測試 + 第一次跑就 GREEN 是警訊、要先在 buggy code 上看到 RED 才相信測試 catch 到該 catch 的東西</li>
<li><a href="/blog/report/url-as-state-container/" data-link-title="URL 是 stateful UI 的儲存層 — 哪些 state 該寫進 URL" data-link-desc="互動式 UI 的 state 散落在多層（in-memory / URL / localStorage / server / index）、每層有不同特性。可分享 / 可恢復 / 可導航的 state 該寫進 URL — 不寫進 = silent 把這些特性犧牲掉。本文展開「state 的儲存層選擇」協議與 URL 的具體位置。">#70 URL 是 stateful UI 的儲存層</a> — 互動式 UI 的可分享 / 可恢復 / 可導航 state 該寫進 URL（搜尋 / filter / tab / sort / pagination 都該檢視）</li>
<li><a href="/blog/report/tab-order-mental-model-alignment/" data-link-title="Tab Order = DOM Order = Mental Model 三者對齊" data-link-desc="Tab 順序由 DOM 順序決定（除非用 tabindex 強制覆寫）。三者該對齊：DOM 順序、tab 順序、使用者 mental model 的互動順序。三者不一致時、優先重排 DOM 而非用 tabindex — tabindex &gt; 0 是反模式（[#52]）。">#71 Tab Order = DOM Order = Mental Model 三者對齊</a> — DOM 順序預設 = tab 順序、不對齊時優先重排 DOM、tabindex &gt; 0 是反模式</li>
<li><a href="/blog/report/external-trigger-for-high-roi-work/" data-link-title="高 ROI 無外部觸發的工作會被結構性跳過" data-link-desc="工作有兩個獨立維度：ROI 高低 &#43; 是否有外部觸發。高 ROI &#43; 無觸發 = ROI 的承諾、拖延的現實。靠紀律不可行 — 結構性偏差需要結構性對策（外部觸發 / CI / hook / 排程 / pair）。本卡是 #67 便利反相關、#68 checkpoint 跳過、#69 RED 跳過的共同上位原則。">#72 高 ROI 無外部觸發的工作會被結構性跳過</a> — meta-原則：寫測試 / refactor / a11y review / Ship 前 case 設計都需要外部觸發（CI / pre-commit / PR template）、不是靠紀律</li>
<li><a href="/blog/report/search-engine-matching-mode-mismatch/" data-link-title="搜尋引擎的匹配模式跟使用者預期的對齊" data-link-desc="搜尋引擎的匹配模式（prefix / substring / fuzzy / semantic）各有不同。預設多半是 prefix（為了 index size）、但使用者被 Google 訓練成預期 substring。沒對齊 = silent 失敗：搜「pre」找不到 backpressure。本卡展開五種匹配模式、跟使用者意圖的對齊協議、五個合成策略。">#73 搜尋引擎的匹配模式跟使用者預期的對齊</a> — Search feature 的 capability 維度：prefix vs substring vs fuzzy vs semantic 各自取捨、預設多為 prefix（為 index size）、跟使用者預期不對齊 = silent 失敗</li>
<li><a href="/blog/report/decision-dialogue-dimensions/" data-link-title="決策對話的五個維度：保持完整選擇空間" data-link-desc="對話中的「決策」不是單一動作、是多維度選擇空間：呈現格式 / 策略疊加 / 批次邊界 / 時間軸 / 選項類型。預設多半 collapse 到最窄格（開放問 &#43; 單策略 &#43; 一次完成 &#43; 立刻決 &#43; 單選）、塞使用者進最少自由度的盒子。本卡是 #74-#78 的上層串連 — 五張卡各對應一個維度的鬆綁。">#79 決策對話的五維度</a> — 設計取捨呈現給使用者時的 meta-框架（呈現 / 策略疊加 / 批次 / 時間 / 選項類型）— 「設計取捨段落」常用的五策略表 + 推薦 + 「先 ship X、Y 下輪」就是這五維度的展現</li>
<li><a href="/blog/report/literal-interception-vs-behavioral-refinement/" data-link-title="字面攔截 vs 行為精煉：驗證手段跟錯誤層次的對齊" data-link-desc="驗證手段必須跟錯誤層次對齊：字面錯誤（typo / syntax / 缺欄位）用 hook / lint / CI 攔截；行為錯誤（思考偏差 / 判斷錯位 / collapse 反模式）用 multi-pass spiral 收斂。強行用 hook 蓋行為錯誤 = 給出 false confidence、反而比沒保護危險。本卡是 #72 結構性對策在「驗證粒度」維度的 ceiling — 不是所有錯誤都該被攔截。">#82 字面攔截 vs 行為精煉</a> — playwright 測試是字面驗證（input → output 比對）、抓不到「為什麼這個 selector 設計錯」這類行為錯誤、需要 multi-pass review 配合</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.4.0 — 接入 #79 決策對話五維度（對應 #74-#78 系列）；協助前端設計取捨段落的呈現格式對齊 user-facing 決策協議
<strong>Version</strong>: 0.3.0 — 接入 #69-#73：相關抽象層原則段補 Test-First (#69)、URL state (#70)、tab order (#71)、外部觸發 meta (#72)、search 匹配模式 (#73)
<strong>Version</strong>: 0.2.0 — 接入 #55-#68 系列：新增第 7 份 reference <code>data-flow-and-filter-composition</code>（涵蓋 Filter × Source 層錯位 + 五策略 + 跨前端 / 後端 / 演算法 / DB 領域範例）；description 補跨領域 stream 操作觸發詞；SKILL.md 加「相關抽象層原則」段（#42-45 + #64 + #67-68）；強調「不只前端、stream 操作通用」
<strong>Version</strong>: 0.1.0 — 從 <code>content/report/</code> 50+ 篇事後檢討萃取「前端網頁開發 + Playwright 驗證」這條主軸；六份 references 對應「DOM topology / CSS-JS 邊界 / Playwright 三位置 / framework 共處 / Reactive 效能 / A11y」六個情境</p>
]]></content:encoded></item><item><title>Frontend with Playwright — 框架無關的前端開發 + Playwright 驗證</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/</guid><description>&lt;h2 id="這個資料夾是什麼">這個資料夾是什麼&lt;/h2>
&lt;p>&lt;code>frontend-with-playwright&lt;/code> 是一套前端開發協議 skill，原生位置在 &lt;a href="https://github.com/tarrragon/blog/tree/main/.claude/skills/frontend-with-playwright">&lt;code>.claude/skills/frontend-with-playwright/&lt;/code>&lt;/a> 供 Claude runtime 呼叫；這份是&lt;strong>同內容的文章版本&lt;/strong>，讓人類讀者也能直接在 blog 閱讀。&lt;/p>
&lt;p>原則框架無關 — 適用 vanilla HTML/CSS/JS、Vue、React、jQuery — 因為核心是「DOM / CSS / JS 三者的本質行為」加上「Playwright 用 live DOM 量測驗證」、不依賴特定框架的渲染機制。源頭是 &lt;a href="https://tarrragon.github.io/blog/report/" data-link-title="Report — 開發過程的事後檢討" data-link-desc="blog 開發過程中、把實際遇到的版型 / 整合 / 框架共處等情境、整理成『應該怎麼做、沒這樣做會有什麼麻煩』的事後檢討。每篇皆為正向指引、幫助下一輪同類任務跳過反覆試錯。">&lt;code>content/report/&lt;/code>&lt;/a> 累積的 50+ 篇事後檢討、由本 skill 的六份 reference 萃取對應六個情境的協議步驟。&lt;/p>
&lt;h2 id="閱讀順序">閱讀順序&lt;/h2>
&lt;h3 id="場景-1第一次接觸">場景 1：第一次接觸&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>順序&lt;/th>
 &lt;th>檔案&lt;/th>
 &lt;th>目的&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/skill/" data-link-title="Frontend with Playwright — SKILL 入口" data-link-desc="框架無關的前端開發 &amp;#43; Playwright 驗證 SKILL 入口：三大支柱、六大原則速查、六份情境 reference 的觸發路由。">SKILL.md&lt;/a>&lt;/td>
 &lt;td>三大支柱 + 六大原則速查、觸發路由表&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>依情境挑一份 reference（見下表）&lt;/td>
 &lt;td>把原則翻譯成可套用的協議步驟、模板與範例&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>該 reference 結尾的 self-check checklist&lt;/td>
 &lt;td>自評有沒有按協議走&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="場景-2已熟悉協議想直接解決當前任務">場景 2：已熟悉協議、想直接解決當前任務&lt;/h3>
&lt;p>直接依觸發情境跳對應 reference：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>觸發情境&lt;/th>
 &lt;th>reference&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>要寫 CSS 規則、需要先確認 DOM 結構 / selector 該怎麼寫&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/dom-topology-first/" data-link-title="DOM Topology First — 寫 CSS 前先確認 DOM 結構" data-link-desc="frontend-with-playwright reference：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 三維度設計、起點四選一、idempotency 兩選一。">dom-topology-first&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定 selector 該多寬、命中其他元素&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/dom-topology-first/" data-link-title="DOM Topology First — 寫 CSS 前先確認 DOM 結構" data-link-desc="frontend-with-playwright reference：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 三維度設計、起點四選一、idempotency 兩選一。">dom-topology-first&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定值該寫進 CSS 還是 JS、CSS layers / variable / class toggle 取捨&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/css-js-boundary/" data-link-title="CSS / JS Boundary — CSS / JS 邊界與 specificity 處理" data-link-desc="frontend-with-playwright reference：CSS-only vs JS-assisted 判準、class toggle 取代 inline style、CSS layers 取代 specificity 戰、variable 單一定義位置、檔案拆分。">css-js-boundary&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>用 &lt;code>!important&lt;/code> / inline style 解 specificity&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/css-js-boundary/" data-link-title="CSS / JS Boundary — CSS / JS 邊界與 specificity 處理" data-link-desc="frontend-with-playwright reference：CSS-only vs JS-assisted 判準、class toggle 取代 inline style、CSS layers 取代 specificity 戰、variable 單一定義位置、檔案拆分。">css-js-boundary&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>要用 playwright 驗證 layout / 假設 / 互動&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/playwright-in-loop/" data-link-title="Playwright in the Development Loop — 開發循環的三個位置" data-link-desc="frontend-with-playwright reference：Playwright 三個位置（假設 / 行為 / 互動驗證）的 evaluate 範例、寫成 layout test 的時機與模板、最低門檻 setup。">playwright-in-loop&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Layout bug 第 2 次出現、想寫成測試&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/playwright-in-loop/" data-link-title="Playwright in the Development Loop — 開發循環的三個位置" data-link-desc="frontend-with-playwright reference：Playwright 三個位置（假設 / 行為 / 互動驗證）的 evaluate 範例、寫成 layout test 的時機與模板、最低門檻 setup。">playwright-in-loop&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>客製 UI 被 framework 還原、不知道該注入到哪&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/framework-coexistence/" data-link-title="Framework Coexistence — 跟 framework-managed DOM 共處" data-link-desc="frontend-with-playwright reference：framework 邊界辨識、JS 操作四級安全度、客製 UI 注入到邊界外、外部組件四層合作（公共介面 → 邊界 → 邊界 DOM → 內部結構）。">framework-coexistence&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>要客製外部組件（pagefind / vendor library）&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/framework-coexistence/" data-link-title="Framework Coexistence — 跟 framework-managed DOM 共處" data-link-desc="frontend-with-playwright reference：framework 邊界辨識、JS 操作四級安全度、客製 UI 注入到邊界外、外部組件四層合作（公共介面 → 邊界 → 邊界 DOM → 內部結構）。">framework-coexistence&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>使用者反映卡頓、CPU 100%、scroll lag、resize jank&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/reactive-performance/" data-link-title="Reactive Performance — Reactive 效能盤點與優化" data-link-desc="frontend-with-playwright reference：MutationObserver 三維度、polling → observer、iteration / regex 成本、layout reflow、resource 載入時序、reactive listener 盤點協議。">reactive-performance&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>要設計 MutationObserver / event listener 範圍&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/reactive-performance/" data-link-title="Reactive Performance — Reactive 效能盤點與優化" data-link-desc="frontend-with-playwright reference：MutationObserver 三維度、polling → observer、iteration / regex 成本、layout reflow、resource 載入時序、reactive listener 盤點協議。">reactive-performance&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>要驗收鍵盤 / screen reader / motor / 視覺 a11y&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/accessibility-and-focus/" data-link-title="Accessibility and Focus — A11y 三道防線" data-link-desc="frontend-with-playwright reference：鍵盤可達性三要素、focus management on DOM move、aria-live 動態廣播、Native HTML &amp;gt; ARIA、視覺 / motor a11y。">accessibility-and-focus&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>JS reparent 後 focus 跑掉、aria-live 沒朗讀&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/accessibility-and-focus/" data-link-title="Accessibility and Focus — A11y 三道防線" data-link-desc="frontend-with-playwright reference：鍵盤可達性三要素、focus management on DOM move、aria-live 動態廣播、Native HTML &amp;gt; ARIA、視覺 / motor a11y。">accessibility-and-focus&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>設計 filter / sort / count 操作、source 是分批 / streaming&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &amp;#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「Load more 後畫面閃但內容沒變」的 silent 缺口&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &amp;#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition&lt;/a>（層錯位）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Backend / 演算法 / map-reduce 的 post-filter 漏項&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &amp;#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition&lt;/a>（跨領域同結構）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每份 reference 自包含：讀任一份不需要回頭讀其他 reference。&lt;/p></description><content:encoded><![CDATA[<h2 id="這個資料夾是什麼">這個資料夾是什麼</h2>
<p><code>frontend-with-playwright</code> 是一套前端開發協議 skill，原生位置在 <a href="https://github.com/tarrragon/blog/tree/main/.claude/skills/frontend-with-playwright"><code>.claude/skills/frontend-with-playwright/</code></a> 供 Claude runtime 呼叫；這份是<strong>同內容的文章版本</strong>，讓人類讀者也能直接在 blog 閱讀。</p>
<p>原則框架無關 — 適用 vanilla HTML/CSS/JS、Vue、React、jQuery — 因為核心是「DOM / CSS / JS 三者的本質行為」加上「Playwright 用 live DOM 量測驗證」、不依賴特定框架的渲染機制。源頭是 <a href="/blog/report/" data-link-title="Report — 開發過程的事後檢討" data-link-desc="blog 開發過程中、把實際遇到的版型 / 整合 / 框架共處等情境、整理成『應該怎麼做、沒這樣做會有什麼麻煩』的事後檢討。每篇皆為正向指引、幫助下一輪同類任務跳過反覆試錯。"><code>content/report/</code></a> 累積的 50+ 篇事後檢討、由本 skill 的六份 reference 萃取對應六個情境的協議步驟。</p>
<h2 id="閱讀順序">閱讀順序</h2>
<h3 id="場景-1第一次接觸">場景 1：第一次接觸</h3>
<table>
  <thead>
      <tr>
          <th>順序</th>
          <th>檔案</th>
          <th>目的</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td><a href="/blog/skills/frontend-with-playwright/skill/" data-link-title="Frontend with Playwright — SKILL 入口" data-link-desc="框架無關的前端開發 &#43; Playwright 驗證 SKILL 入口：三大支柱、六大原則速查、六份情境 reference 的觸發路由。">SKILL.md</a></td>
          <td>三大支柱 + 六大原則速查、觸發路由表</td>
      </tr>
      <tr>
          <td>2</td>
          <td>依情境挑一份 reference（見下表）</td>
          <td>把原則翻譯成可套用的協議步驟、模板與範例</td>
      </tr>
      <tr>
          <td>3</td>
          <td>該 reference 結尾的 self-check checklist</td>
          <td>自評有沒有按協議走</td>
      </tr>
  </tbody>
</table>
<h3 id="場景-2已熟悉協議想直接解決當前任務">場景 2：已熟悉協議、想直接解決當前任務</h3>
<p>直接依觸發情境跳對應 reference：</p>
<table>
  <thead>
      <tr>
          <th>觸發情境</th>
          <th>reference</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>要寫 CSS 規則、需要先確認 DOM 結構 / selector 該怎麼寫</td>
          <td><a href="/blog/skills/frontend-with-playwright/dom-topology-first/" data-link-title="DOM Topology First — 寫 CSS 前先確認 DOM 結構" data-link-desc="frontend-with-playwright reference：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 三維度設計、起點四選一、idempotency 兩選一。">dom-topology-first</a></td>
      </tr>
      <tr>
          <td>不確定 selector 該多寬、命中其他元素</td>
          <td><a href="/blog/skills/frontend-with-playwright/dom-topology-first/" data-link-title="DOM Topology First — 寫 CSS 前先確認 DOM 結構" data-link-desc="frontend-with-playwright reference：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 三維度設計、起點四選一、idempotency 兩選一。">dom-topology-first</a></td>
      </tr>
      <tr>
          <td>不確定值該寫進 CSS 還是 JS、CSS layers / variable / class toggle 取捨</td>
          <td><a href="/blog/skills/frontend-with-playwright/css-js-boundary/" data-link-title="CSS / JS Boundary — CSS / JS 邊界與 specificity 處理" data-link-desc="frontend-with-playwright reference：CSS-only vs JS-assisted 判準、class toggle 取代 inline style、CSS layers 取代 specificity 戰、variable 單一定義位置、檔案拆分。">css-js-boundary</a></td>
      </tr>
      <tr>
          <td>用 <code>!important</code> / inline style 解 specificity</td>
          <td><a href="/blog/skills/frontend-with-playwright/css-js-boundary/" data-link-title="CSS / JS Boundary — CSS / JS 邊界與 specificity 處理" data-link-desc="frontend-with-playwright reference：CSS-only vs JS-assisted 判準、class toggle 取代 inline style、CSS layers 取代 specificity 戰、variable 單一定義位置、檔案拆分。">css-js-boundary</a></td>
      </tr>
      <tr>
          <td>要用 playwright 驗證 layout / 假設 / 互動</td>
          <td><a href="/blog/skills/frontend-with-playwright/playwright-in-loop/" data-link-title="Playwright in the Development Loop — 開發循環的三個位置" data-link-desc="frontend-with-playwright reference：Playwright 三個位置（假設 / 行為 / 互動驗證）的 evaluate 範例、寫成 layout test 的時機與模板、最低門檻 setup。">playwright-in-loop</a></td>
      </tr>
      <tr>
          <td>Layout bug 第 2 次出現、想寫成測試</td>
          <td><a href="/blog/skills/frontend-with-playwright/playwright-in-loop/" data-link-title="Playwright in the Development Loop — 開發循環的三個位置" data-link-desc="frontend-with-playwright reference：Playwright 三個位置（假設 / 行為 / 互動驗證）的 evaluate 範例、寫成 layout test 的時機與模板、最低門檻 setup。">playwright-in-loop</a></td>
      </tr>
      <tr>
          <td>客製 UI 被 framework 還原、不知道該注入到哪</td>
          <td><a href="/blog/skills/frontend-with-playwright/framework-coexistence/" data-link-title="Framework Coexistence — 跟 framework-managed DOM 共處" data-link-desc="frontend-with-playwright reference：framework 邊界辨識、JS 操作四級安全度、客製 UI 注入到邊界外、外部組件四層合作（公共介面 → 邊界 → 邊界 DOM → 內部結構）。">framework-coexistence</a></td>
      </tr>
      <tr>
          <td>要客製外部組件（pagefind / vendor library）</td>
          <td><a href="/blog/skills/frontend-with-playwright/framework-coexistence/" data-link-title="Framework Coexistence — 跟 framework-managed DOM 共處" data-link-desc="frontend-with-playwright reference：framework 邊界辨識、JS 操作四級安全度、客製 UI 注入到邊界外、外部組件四層合作（公共介面 → 邊界 → 邊界 DOM → 內部結構）。">framework-coexistence</a></td>
      </tr>
      <tr>
          <td>使用者反映卡頓、CPU 100%、scroll lag、resize jank</td>
          <td><a href="/blog/skills/frontend-with-playwright/reactive-performance/" data-link-title="Reactive Performance — Reactive 效能盤點與優化" data-link-desc="frontend-with-playwright reference：MutationObserver 三維度、polling → observer、iteration / regex 成本、layout reflow、resource 載入時序、reactive listener 盤點協議。">reactive-performance</a></td>
      </tr>
      <tr>
          <td>要設計 MutationObserver / event listener 範圍</td>
          <td><a href="/blog/skills/frontend-with-playwright/reactive-performance/" data-link-title="Reactive Performance — Reactive 效能盤點與優化" data-link-desc="frontend-with-playwright reference：MutationObserver 三維度、polling → observer、iteration / regex 成本、layout reflow、resource 載入時序、reactive listener 盤點協議。">reactive-performance</a></td>
      </tr>
      <tr>
          <td>要驗收鍵盤 / screen reader / motor / 視覺 a11y</td>
          <td><a href="/blog/skills/frontend-with-playwright/accessibility-and-focus/" data-link-title="Accessibility and Focus — A11y 三道防線" data-link-desc="frontend-with-playwright reference：鍵盤可達性三要素、focus management on DOM move、aria-live 動態廣播、Native HTML &gt; ARIA、視覺 / motor a11y。">accessibility-and-focus</a></td>
      </tr>
      <tr>
          <td>JS reparent 後 focus 跑掉、aria-live 沒朗讀</td>
          <td><a href="/blog/skills/frontend-with-playwright/accessibility-and-focus/" data-link-title="Accessibility and Focus — A11y 三道防線" data-link-desc="frontend-with-playwright reference：鍵盤可達性三要素、focus management on DOM move、aria-live 動態廣播、Native HTML &gt; ARIA、視覺 / motor a11y。">accessibility-and-focus</a></td>
      </tr>
      <tr>
          <td>設計 filter / sort / count 操作、source 是分批 / streaming</td>
          <td><a href="/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition</a></td>
      </tr>
      <tr>
          <td>「Load more 後畫面閃但內容沒變」的 silent 缺口</td>
          <td><a href="/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition</a>（層錯位）</td>
      </tr>
      <tr>
          <td>Backend / 演算法 / map-reduce 的 post-filter 漏項</td>
          <td><a href="/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition</a>（跨領域同結構）</td>
      </tr>
  </tbody>
</table>
<p>每份 reference 自包含：讀任一份不需要回頭讀其他 reference。</p>
<h2 id="跟-requirement-protocol-的關係">跟 requirement-protocol 的關係</h2>
<p><a href="/blog/skills/requirement-protocol/" data-link-title="Requirement Protocol — 需求確認到實作的對話協議" data-link-desc="從需求確認到實作的對話協議：模糊指令澄清、可決定 vs 該確認、失敗 2 次轉折、覆寫成本告知、revert checkpoint、漸進驗證、工具切換時機。六大原則 &#43; 五份情境 reference。">requirement-protocol</a> 是上層的「對話協議」（澄清需求、失敗轉折、覆寫成本、工具切換時機）；本 skill 是下層的「前端執行協議」（DOM / CSS / JS / Playwright 的具體做法）。</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>該讀哪個 skill</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>不確定該怎麼跟使用者溝通、需求模糊、失敗該怎麼轉折</td>
          <td>requirement-protocol</td>
      </tr>
      <tr>
          <td>知道要做什麼、不確定前端該怎麼實作驗證</td>
          <td>frontend-with-playwright（本 skill）</td>
      </tr>
  </tbody>
</table>
<p>兩個 skill 的 <code>playwright</code> 段落互補：requirement-protocol 講「何時切」、本 skill 講「切了之後具體寫什麼 query」。</p>
<h2 id="與-blog-專案其他資料的關係">與 blog 專案其他資料的關係</h2>
<table>
  <thead>
      <tr>
          <th>位置</th>
          <th>角色</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>.claude/skills/frontend-with-playwright/</code></td>
          <td>實際 skill — Claude runtime 呼叫的檔案來源</td>
      </tr>
      <tr>
          <td><code>content/skills/frontend-with-playwright/</code>（本處）</td>
          <td>文章版本 — 人類讀者在 blog 閱讀</td>
      </tr>
      <tr>
          <td><a href="/blog/report/" data-link-title="Report — 開發過程的事後檢討" data-link-desc="blog 開發過程中、把實際遇到的版型 / 整合 / 框架共處等情境、整理成『應該怎麼做、沒這樣做會有什麼麻煩』的事後檢討。每篇皆為正向指引、幫助下一輪同類任務跳過反覆試錯。"><code>content/report/</code></a></td>
          <td>50+ 篇事後檢討、本 skill 的素材來源；reference 結尾連回對應篇</td>
      </tr>
      <tr>
          <td><a href="/blog/skills/requirement-protocol/" data-link-title="Requirement Protocol — 需求確認到實作的對話協議" data-link-desc="從需求確認到實作的對話協議：模糊指令澄清、可決定 vs 該確認、失敗 2 次轉折、覆寫成本告知、revert checkpoint、漸進驗證、工具切換時機。六大原則 &#43; 五份情境 reference。"><code>content/skills/requirement-protocol/</code></a></td>
          <td>上層對話協議 skill</td>
      </tr>
  </tbody>
</table>
<h2 id="last-updated">Last Updated</h2>
<p>2026-04-26 — v0.2.0 接入 #55-#68 系列：新增第 7 份 reference <code>data-flow-and-filter-composition</code>（Filter × Source 層錯位 + 五策略 + 跨前端 / 後端 / 演算法 / DB 範例）；強調原則跨領域通用、不只前端。</p>
<p>歷史版本：</p>
<ul>
<li>2026-04-26 — v0.1.0 初版：六份 references 對應「DOM topology / CSS-JS 邊界 / Playwright 三位置 / framework 共處 / Reactive 效能 / A11y」六個情境</li>
</ul>
]]></content:encoded></item><item><title>Hugo 部落格側邊章節導航 (TOC) 完整實現指南</title><link>https://tarrragon.github.io/blog/posts/hugo-%E9%83%A8%E8%90%BD%E6%A0%BC%E5%81%B4%E9%82%8A%E7%AB%A0%E7%AF%80%E5%B0%8E%E8%88%AA-toc-%E5%AE%8C%E6%95%B4%E5%AF%A6%E7%8F%BE%E6%8C%87%E5%8D%97/</link><pubDate>Wed, 08 Oct 2025 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/posts/hugo-%E9%83%A8%E8%90%BD%E6%A0%BC%E5%81%B4%E9%82%8A%E7%AB%A0%E7%AF%80%E5%B0%8E%E8%88%AA-toc-%E5%AE%8C%E6%95%B4%E5%AF%A6%E7%8F%BE%E6%8C%87%E5%8D%97/</guid><description>&lt;h2 id="概述">概述&lt;/h2>
&lt;p>因為文章太長，閱讀困難，所以看到別人部落格有的TOC功能，就找AI復刻&lt;/p>
&lt;h3 id="需求">需求&lt;/h3>
&lt;ul>
&lt;li>使用TOC快速定位&lt;/li>
&lt;li>TOC隨著本文滾動定位當前位置&lt;/li>
&lt;li>手機寬度下不顯示TOC，改用回到頁首的懸浮按鈕取代&lt;/li>
&lt;/ul>
&lt;h2 id="1-hugo-配置設定">1. Hugo 配置設定&lt;/h2>
&lt;h3 id="11-啟用-toc-功能">1.1 啟用 TOC 功能&lt;/h3>
&lt;p>在 &lt;code>hugo.toml&lt;/code> 中啟用 TOC 功能：&lt;/p>


&lt;details>
 &lt;summary>點擊查看配置程式碼&lt;/summary>
 

```toml
[markup]
 [markup.tableOfContents]
 startLevel = 2
 endLevel = 4
 ordered = false
```


&lt;/details>
&lt;p>&lt;strong>參數說明&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>&lt;code>startLevel = 2&lt;/code>：從 H2 標題開始生成 TOC&lt;/li>
&lt;li>&lt;code>endLevel = 4&lt;/code>：到 H4 標題結束&lt;/li>
&lt;li>&lt;code>ordered = false&lt;/code>：使用無序列表格式&lt;/li>
&lt;/ul>
&lt;h2 id="2-自定義文章佈局">2. 自定義文章佈局&lt;/h2>
&lt;h3 id="21-建立自定義-singlehtml">2.1 建立自定義 single.html&lt;/h3>
&lt;p>在 &lt;code>layouts/_default/single.html&lt;/code> 中實現新的佈局結構：&lt;/p>


&lt;details>
 &lt;summary>點擊查看完整 HTML 佈局程式碼&lt;/summary>
 

```html
{{ define "main" }}
&lt;!-- 側邊章節導航 - 獨立於主內容區域 -->
&lt;aside class="toc-sidebar">
 &lt;h3>章節目錄&lt;/h3>
 {{ if .TableOfContents }}
 {{ .TableOfContents }}
 {{ else }}
 &lt;p style="color: rgba(255, 255, 255, 0.5); font-size: 0.85rem; margin: 0;">
 此文章沒有章節標題
 &lt;/p>
 {{ end }}
&lt;/aside>

&lt;!-- 文章內容 - 保持原有的置中佈局 -->
&lt;article class="article-content">
 {{ if not .Params.menu }}
 &lt;h1>{{ .Title }}&lt;/h1>
 &lt;p class="byline">
 &lt;time datetime='{{ .Date.Format "2006-01-02" }}' pubdate>
 {{ .Date.Format (default "2006-01-02" .Site.Params.dateFormat) }}
 &lt;/time>
 {{ with .Params.author }}· {{.}}{{ end }}
 &lt;/p>
 {{ end }}
 
 &lt;content>
 {{ .Content }}
 &lt;/content>
 
 &lt;p>
 {{ range (.GetTerms "tags") }}
 &lt;a class="blog-tags" href="{{ .RelPermalink }}">#{{ lower .LinkTitle }}&lt;/a>
 {{ end }}
 &lt;/p>
 
 {{ if not .Params.hideReply }}
 {{ with .Site.Params.author.email }}
 &lt;p>
 &lt;a href='mailto:{{ . }}?subject={{ i18n "email-subject" }}"{{ default $.Site.Title $.Page.Title }}"'>
 {{ i18n "email-reply" }} ↪
 &lt;/a>
 &lt;/p>
 {{ end }}
 {{ end }}
&lt;/article>

&lt;!-- 回到頂部按鈕 -->
&lt;button id="back-to-top" class="back-to-top-btn" aria-label="回到頂部">
 &lt;svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 &lt;path d="m18 15-6-6-6 6"/>
 &lt;/svg>
&lt;/button>

&lt;!-- 章節導航互動腳本 -->
&lt;script>
document.addEventListener('DOMContentLoaded', function() {
 // 檢查是否在手機版（隱藏 TOC 時不需要執行）
 const isMobile = window.innerWidth &lt;= 768;
 
 if (isMobile) {
 return; // 手機版不執行 TOC 相關功能
 }

 // 確保所有標題都有 ID
 const headings = document.querySelectorAll('.article-content h2, .article-content h3, .article-content h4');
 
 headings.forEach(function(heading) {
 // 如果沒有 ID，則生成一個
 if (!heading.id) {
 // 從標題文字生成 ID
 const text = heading.textContent.trim();
 const id = text.toLowerCase()
 .replace(/[^\w\s-]/g, '') // 移除特殊字符
 .replace(/\s+/g, '-') // 空格替換為連字符
 .replace(/-+/g, '-') // 多個連字符合併為一個
 .replace(/^-|-$/g, ''); // 移除開頭和結尾的連字符
 
 if (id) {
 heading.id = id;
 }
 }
 });

 // 更新側邊導航連結的 href
 const tocLinks = document.querySelectorAll('.toc-sidebar a[href^="#"]');
 tocLinks.forEach(function(link) {
 const href = link.getAttribute('href');
 if (href &amp;&amp; href.startsWith('#')) {
 const targetId = href.substring(1);
 const targetElement = document.getElementById(targetId);
 if (targetElement) {
 link.addEventListener('click', function(e) {
 e.preventDefault();
 targetElement.scrollIntoView({
 behavior: 'smooth',
 block: 'start'
 });
 });
 }
 }
 });

 // 滾動時高亮當前章節並自動滾動側邊欄
 function updateActiveSection() {
 const sections = document.querySelectorAll('.article-content h2, .article-content h3, .article-content h4');
 const tocLinks = document.querySelectorAll('.toc-sidebar a[href^="#"]');
 const tocSidebar = document.querySelector('.toc-sidebar');
 
 let currentSection = '';
 const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
 
 sections.forEach(function(section) {
 const sectionTop = section.offsetTop - 100; // 提前 100px 觸發
 if (scrollTop >= sectionTop) {
 currentSection = section.id;
 }
 });
 
 // 移除所有 active 類別
 tocLinks.forEach(function(link) {
 link.classList.remove('active');
 });
 
 // 為當前章節添加 active 類別並自動滾動側邊欄
 if (currentSection) {
 const activeLink = document.querySelector('.toc-sidebar a[href="#' + currentSection + '"]');
 if (activeLink) {
 activeLink.classList.add('active');
 
 // 自動滾動側邊欄到當前章節位置
 if (tocSidebar &amp;&amp; activeLink) {
 // 獲取側邊欄的滾動容器信息
 const sidebarScrollTop = tocSidebar.scrollTop;
 const sidebarHeight = tocSidebar.clientHeight;
 const sidebarScrollHeight = tocSidebar.scrollHeight;
 
 // 獲取當前連結在側邊欄中的位置
 const linkOffsetTop = activeLink.offsetTop;
 const linkHeight = activeLink.offsetHeight;
 
 // 計算連結相對於側邊欄可視區域的位置
 const linkTop = linkOffsetTop - sidebarScrollTop;
 const linkBottom = linkTop + linkHeight;
 
 // 設定緩衝區域（側邊欄高度的 20%）
 const bufferZone = Math.max(20, sidebarHeight * 0.2);
 const safeTop = bufferZone;
 const safeBottom = sidebarHeight - bufferZone;
 
 // 檢查是否需要滾動
 let needsScroll = false;
 let targetScrollTop = sidebarScrollTop;
 
 if (linkTop &lt; safeTop) {
 // 連結太靠近頂部，滾動到連結上方預留緩衝空間
 targetScrollTop = linkOffsetTop - bufferZone;
 needsScroll = true;
 } else if (linkBottom > safeBottom) {
 // 連結太靠近底部，滾動到連結下方預留緩衝空間
 targetScrollTop = linkOffsetTop + linkHeight - sidebarHeight + bufferZone;
 needsScroll = true;
 }
 
 // 如果需要滾動，執行滾動
 if (needsScroll) {
 // 確保滾動位置在有效範圍內
 const maxScrollTop = sidebarScrollHeight - sidebarHeight;
 targetScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop));
 
 // 只有當目標位置與當前位置差距足夠大時才滾動
 if (Math.abs(targetScrollTop - sidebarScrollTop) > 10) {
 tocSidebar.scrollTop = targetScrollTop;
 }
 }
 }
 }
 }
 }

 // 監聽滾動事件
 window.addEventListener('scroll', updateActiveSection);
 
 // 初始化時執行一次
 updateActiveSection();
});

// 回到頂部按鈕功能（所有裝置都支援）
document.addEventListener('DOMContentLoaded', function() {
 const backToTopBtn = document.getElementById('back-to-top');
 
 if (!backToTopBtn) return;
 
 // 顯示/隱藏按鈕
 function toggleBackToTopBtn() {
 const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
 
 if (scrollTop > 300) {
 backToTopBtn.style.display = 'flex';
 backToTopBtn.classList.add('visible');
 } else {
 backToTopBtn.style.display = 'none';
 backToTopBtn.classList.remove('visible');
 }
 }
 
 // 回到頂部功能
 function scrollToTop() {
 window.scrollTo({
 top: 0,
 behavior: 'smooth'
 });
 }
 
 // 綁定事件
 window.addEventListener('scroll', toggleBackToTopBtn);
 backToTopBtn.addEventListener('click', scrollToTop);
 
 // 初始化
 toggleBackToTopBtn();
});
&lt;/script>
{{ end }}
```


&lt;/details>
&lt;h2 id="3-css-樣式設計">3. CSS 樣式設計&lt;/h2>
&lt;h3 id="31-側邊欄樣式">3.1 側邊欄樣式&lt;/h3>
&lt;p>在 &lt;code>layouts/partials/custom_head.html&lt;/code> 中添加 CSS：&lt;/p></description><content:encoded><![CDATA[<h2 id="概述">概述</h2>
<p>因為文章太長，閱讀困難，所以看到別人部落格有的TOC功能，就找AI復刻</p>
<h3 id="需求">需求</h3>
<ul>
<li>使用TOC快速定位</li>
<li>TOC隨著本文滾動定位當前位置</li>
<li>手機寬度下不顯示TOC，改用回到頁首的懸浮按鈕取代</li>
</ul>
<h2 id="1-hugo-配置設定">1. Hugo 配置設定</h2>
<h3 id="11-啟用-toc-功能">1.1 啟用 TOC 功能</h3>
<p>在 <code>hugo.toml</code> 中啟用 TOC 功能：</p>


<details>
  <summary>點擊查看配置程式碼</summary>
  

```toml
[markup]
  [markup.tableOfContents]
    startLevel = 2
    endLevel = 4
    ordered = false
```


</details>
<p><strong>參數說明</strong>：</p>
<ul>
<li><code>startLevel = 2</code>：從 H2 標題開始生成 TOC</li>
<li><code>endLevel = 4</code>：到 H4 標題結束</li>
<li><code>ordered = false</code>：使用無序列表格式</li>
</ul>
<h2 id="2-自定義文章佈局">2. 自定義文章佈局</h2>
<h3 id="21-建立自定義-singlehtml">2.1 建立自定義 single.html</h3>
<p>在 <code>layouts/_default/single.html</code> 中實現新的佈局結構：</p>


<details>
  <summary>點擊查看完整 HTML 佈局程式碼</summary>
  

```html
{{ define "main" }}
<!-- 側邊章節導航 - 獨立於主內容區域 -->
<aside class="toc-sidebar">
  <h3>章節目錄</h3>
  {{ if .TableOfContents }}
    {{ .TableOfContents }}
  {{ else }}
    <p style="color: rgba(255, 255, 255, 0.5); font-size: 0.85rem; margin: 0;">
      此文章沒有章節標題
    </p>
  {{ end }}
</aside>

<!-- 文章內容 - 保持原有的置中佈局 -->
<article class="article-content">
  {{ if not .Params.menu }}
  <h1>{{ .Title }}</h1>
  <p class="byline">
    <time datetime='{{ .Date.Format "2006-01-02" }}' pubdate>
      {{ .Date.Format (default "2006-01-02" .Site.Params.dateFormat) }}
    </time>
    {{ with .Params.author }}· {{.}}{{ end }}
  </p>
  {{ end }}
  
  <content>
    {{ .Content }}
  </content>
  
  <p>
    {{ range (.GetTerms "tags") }}
      <a class="blog-tags" href="{{ .RelPermalink }}">#{{ lower .LinkTitle }}</a>
    {{ end }}
  </p>
  
  {{ if not .Params.hideReply }}
  {{ with .Site.Params.author.email }}
    <p>
      <a href='mailto:{{ . }}?subject={{ i18n "email-subject" }}"{{ default $.Site.Title $.Page.Title }}"'>
        {{ i18n "email-reply" }} ↪
      </a>
    </p>
  {{ end }}
  {{ end }}
</article>

<!-- 回到頂部按鈕 -->
<button id="back-to-top" class="back-to-top-btn" aria-label="回到頂部">
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
    <path d="m18 15-6-6-6 6"/>
  </svg>
</button>

<!-- 章節導航互動腳本 -->
<script>
document.addEventListener('DOMContentLoaded', function() {
  // 檢查是否在手機版（隱藏 TOC 時不需要執行）
  const isMobile = window.innerWidth <= 768;
  
  if (isMobile) {
    return; // 手機版不執行 TOC 相關功能
  }

  // 確保所有標題都有 ID
  const headings = document.querySelectorAll('.article-content h2, .article-content h3, .article-content h4');
  
  headings.forEach(function(heading) {
    // 如果沒有 ID，則生成一個
    if (!heading.id) {
      // 從標題文字生成 ID
      const text = heading.textContent.trim();
      const id = text.toLowerCase()
        .replace(/[^\w\s-]/g, '') // 移除特殊字符
        .replace(/\s+/g, '-')     // 空格替換為連字符
        .replace(/-+/g, '-')      // 多個連字符合併為一個
        .replace(/^-|-$/g, '');   // 移除開頭和結尾的連字符
      
      if (id) {
        heading.id = id;
      }
    }
  });

  // 更新側邊導航連結的 href
  const tocLinks = document.querySelectorAll('.toc-sidebar a[href^="#"]');
  tocLinks.forEach(function(link) {
    const href = link.getAttribute('href');
    if (href && href.startsWith('#')) {
      const targetId = href.substring(1);
      const targetElement = document.getElementById(targetId);
      if (targetElement) {
        link.addEventListener('click', function(e) {
          e.preventDefault();
          targetElement.scrollIntoView({
            behavior: 'smooth',
            block: 'start'
          });
        });
      }
    }
  });

  // 滾動時高亮當前章節並自動滾動側邊欄
  function updateActiveSection() {
    const sections = document.querySelectorAll('.article-content h2, .article-content h3, .article-content h4');
    const tocLinks = document.querySelectorAll('.toc-sidebar a[href^="#"]');
    const tocSidebar = document.querySelector('.toc-sidebar');
    
    let currentSection = '';
    const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
    
    sections.forEach(function(section) {
      const sectionTop = section.offsetTop - 100; // 提前 100px 觸發
      if (scrollTop >= sectionTop) {
        currentSection = section.id;
      }
    });
    
    // 移除所有 active 類別
    tocLinks.forEach(function(link) {
      link.classList.remove('active');
    });
    
    // 為當前章節添加 active 類別並自動滾動側邊欄
    if (currentSection) {
      const activeLink = document.querySelector('.toc-sidebar a[href="#' + currentSection + '"]');
      if (activeLink) {
        activeLink.classList.add('active');
        
        // 自動滾動側邊欄到當前章節位置
        if (tocSidebar && activeLink) {
          // 獲取側邊欄的滾動容器信息
          const sidebarScrollTop = tocSidebar.scrollTop;
          const sidebarHeight = tocSidebar.clientHeight;
          const sidebarScrollHeight = tocSidebar.scrollHeight;
          
          // 獲取當前連結在側邊欄中的位置
          const linkOffsetTop = activeLink.offsetTop;
          const linkHeight = activeLink.offsetHeight;
          
          // 計算連結相對於側邊欄可視區域的位置
          const linkTop = linkOffsetTop - sidebarScrollTop;
          const linkBottom = linkTop + linkHeight;
          
          // 設定緩衝區域（側邊欄高度的 20%）
          const bufferZone = Math.max(20, sidebarHeight * 0.2);
          const safeTop = bufferZone;
          const safeBottom = sidebarHeight - bufferZone;
          
          // 檢查是否需要滾動
          let needsScroll = false;
          let targetScrollTop = sidebarScrollTop;
          
          if (linkTop < safeTop) {
            // 連結太靠近頂部，滾動到連結上方預留緩衝空間
            targetScrollTop = linkOffsetTop - bufferZone;
            needsScroll = true;
          } else if (linkBottom > safeBottom) {
            // 連結太靠近底部，滾動到連結下方預留緩衝空間
            targetScrollTop = linkOffsetTop + linkHeight - sidebarHeight + bufferZone;
            needsScroll = true;
          }
          
          // 如果需要滾動，執行滾動
          if (needsScroll) {
            // 確保滾動位置在有效範圍內
            const maxScrollTop = sidebarScrollHeight - sidebarHeight;
            targetScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop));
            
            // 只有當目標位置與當前位置差距足夠大時才滾動
            if (Math.abs(targetScrollTop - sidebarScrollTop) > 10) {
              tocSidebar.scrollTop = targetScrollTop;
            }
          }
        }
      }
    }
  }

  // 監聽滾動事件
  window.addEventListener('scroll', updateActiveSection);
  
  // 初始化時執行一次
  updateActiveSection();
});

// 回到頂部按鈕功能（所有裝置都支援）
document.addEventListener('DOMContentLoaded', function() {
  const backToTopBtn = document.getElementById('back-to-top');
  
  if (!backToTopBtn) return;
  
  // 顯示/隱藏按鈕
  function toggleBackToTopBtn() {
    const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
    
    if (scrollTop > 300) {
      backToTopBtn.style.display = 'flex';
      backToTopBtn.classList.add('visible');
    } else {
      backToTopBtn.style.display = 'none';
      backToTopBtn.classList.remove('visible');
    }
  }
  
  // 回到頂部功能
  function scrollToTop() {
    window.scrollTo({
      top: 0,
      behavior: 'smooth'
    });
  }
  
  // 綁定事件
  window.addEventListener('scroll', toggleBackToTopBtn);
  backToTopBtn.addEventListener('click', scrollToTop);
  
  // 初始化
  toggleBackToTopBtn();
});
</script>
{{ end }}
```


</details>
<h2 id="3-css-樣式設計">3. CSS 樣式設計</h2>
<h3 id="31-側邊欄樣式">3.1 側邊欄樣式</h3>
<p>在 <code>layouts/partials/custom_head.html</code> 中添加 CSS：</p>


<details>
  <summary>點擊查看側邊欄 CSS 樣式</summary>
  

```css
/* 側邊章節導航樣式 - 獨立側邊欄 */
.toc-sidebar {
  position: fixed;
  top: 50%;
  right: 20px;
  transform: translateY(-50%);
  width: 280px;
  max-height: 80vh;
  overflow-y: auto;
  padding: 1.5rem;
  background: rgba(0, 0, 0, 0.8);
  backdrop-filter: blur(10px);
  border-radius: 12px;
  border: 1px solid rgba(255, 255, 255, 0.15);
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
  z-index: 1000;
  transition: all 0.3s ease;
}

.toc-sidebar:hover {
  background: rgba(0, 0, 0, 0.9);
  border-color: rgba(255, 255, 255, 0.25);
}

/* 文章內容保持原有佈局 */
.article-content {
  max-width: 800px;
  margin: 0 auto;
  padding: 0 1rem;
}

.toc-sidebar h3 {
  margin: 0 0 1rem 0;
  font-size: 1rem;
  font-weight: 600;
  color: var(--primary-color, #fff);
  border-bottom: 1px solid rgba(255, 255, 255, 0.2);
  padding-bottom: 0.5rem;
}

.toc-sidebar ul {
  list-style: none;
  padding: 0;
  margin: 0;
}

.toc-sidebar li {
  margin: 0.25rem 0;
}

.toc-sidebar a {
  display: block;
  padding: 0.25rem 0.5rem;
  color: rgba(255, 255, 255, 0.7);
  text-decoration: none;
  border-radius: 4px;
  transition: all 0.2s ease;
  font-size: 0.9rem;
  line-height: 1.4;
}

.toc-sidebar a:hover {
  background: rgba(255, 255, 255, 0.1);
  color: var(--primary-color, #fff);
}

.toc-sidebar a.active {
  background: rgba(255, 255, 255, 0.15);
  color: var(--primary-color, #fff);
  font-weight: 500;
}

/* 不同層級的縮排 */
.toc-sidebar ul ul {
  margin-left: 1rem;
  border-left: 1px solid rgba(255, 255, 255, 0.1);
  padding-left: 0.5rem;
}

.toc-sidebar ul ul ul {
  margin-left: 1rem;
}
```


</details>
<h3 id="32-回到頂部按鈕樣式">3.2 回到頂部按鈕樣式</h3>


<details>
  <summary>點擊查看回到頂部按鈕 CSS 樣式</summary>
  

```css
/* 回到頂部按鈕樣式 */
.back-to-top-btn {
  position: fixed;
  bottom: 2rem;
  right: 2rem;
  width: 50px;
  height: 50px;
  background: rgba(0, 0, 0, 0.8);
  backdrop-filter: blur(10px);
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 50%;
  color: white;
  cursor: pointer;
  display: none;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  transition: all 0.3s ease;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}

.back-to-top-btn:hover {
  background: rgba(0, 0, 0, 0.9);
  border-color: rgba(255, 255, 255, 0.4);
  transform: translateY(-2px);
  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
}

.back-to-top-btn:active {
  transform: translateY(0);
}

.back-to-top-btn.visible {
  display: flex;
  animation: fadeInUp 0.3s ease;
}

@keyframes fadeInUp {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
```


</details>
<h3 id="33-響應式設計">3.3 響應式設計</h3>


<details>
  <summary>點擊查看響應式設計 CSS 樣式</summary>
  

```css
/* 響應式設計 */
@media (max-width: 1024px) {
  .toc-sidebar {
    width: 240px;
    right: 15px;
  }
}

@media (max-width: 768px) {
  /* 手機版隱藏 TOC */
  .toc-sidebar {
    display: none;
  }
  
  /* 確保文章內容在手機版有足夠的邊距 */
  .article-content {
    max-width: 100%;
    padding: 0 1.5rem;
  }
  
  .back-to-top-btn {
    bottom: 1.5rem;
    right: 1.5rem;
    width: 45px;
    height: 45px;
  }
}

/* 平板版調整 */
@media (max-width: 1024px) and (min-width: 769px) {
  .toc-sidebar {
    width: 220px;
    padding: 1rem;
    font-size: 0.9rem;
  }
  
  .toc-sidebar h3 {
    font-size: 0.9rem;
  }
  
  .toc-sidebar a {
    font-size: 0.85rem;
    padding: 0.2rem 0.4rem;
  }
}
```


</details>
<h2 id="4-標題間距優化">4. 標題間距優化</h2>
<h3 id="41-改善文章可讀性">4.1 改善文章可讀性</h3>


<details>
  <summary>點擊查看標題間距 CSS 樣式</summary>
  

```css
/* 調整標題間距 */
.article-content h2 {
  margin-top: 4.5rem;
  margin-bottom: 1.5rem;
  padding-top: 0.5rem;
  padding-bottom: 0.5rem;
}

.article-content h3 {
  margin-top: 2.5rem;
  margin-bottom: 1.2rem;
  padding-top: 0.4rem;
  padding-bottom: 0.4rem;
}

.article-content h4 {
  margin-top: 2rem;
  margin-bottom: 1rem;
  padding-top: 0.3rem;
  padding-bottom: 0.3rem;
}

/* 第一個標題不需要上邊距 */
.article-content h2:first-child,
.article-content h3:first-child,
.article-content h4:first-child {
  margin-top: 0;
}

/* 段落與標題之間的間距 */
.article-content p {
  margin-bottom: 1.2rem;
  line-height: 1.6;
}

/* 列表與標題之間的間距 */
.article-content ul,
.article-content ol {
  margin-top: 1rem;
  margin-bottom: 1.5rem;
}

.article-content li {
  margin-bottom: 0.5rem;
  line-height: 1.5;
}

/* 確保標題有正確的錨點 ID */
.article-content h2,
.article-content h3,
.article-content h4 {
  scroll-margin-top: 2rem;
}
```


</details>
<h2 id="5-需求描述">5. 需求描述</h2>
<h3 id="51-桌面版功能">5.1 桌面版功能</h3>
<ul>
<li><strong>固定側邊欄</strong>：右側固定位置的章節目錄</li>
<li><strong>自動高亮</strong>：滾動時自動高亮當前章節</li>
<li><strong>智能滾動</strong>：側邊欄自動滾動到當前章節位置</li>
<li><strong>平滑跳轉</strong>：點擊章節標題平滑滾動到對應位置</li>
</ul>
<h3 id="52-平板版功能">5.2 平板版功能</h3>
<ul>
<li><strong>縮小側邊欄</strong>：較窄的側邊欄（220px）</li>
<li><strong>保持所有功能</strong>：與桌面版相同的導航功能</li>
</ul>
<h3 id="53-手機版功能">5.3 手機版功能</h3>
<ul>
<li><strong>隱藏 TOC</strong>：手機寬度不足以顯示TOC</li>
<li><strong>回到頂部按鈕</strong>：使用懸浮按鈕讓使用者至少可以快速回到開頭</li>
<li><strong>響應式佈局</strong>：文章內容全寬顯示</li>
</ul>
<h2 id="6-技術實現細節">6. 技術實現細節</h2>
<h3 id="61-自動滾動算法">6.1 自動滾動算法</h3>
<ul>
<li>使用動態緩衝區域（側邊欄高度的 20%）</li>
<li>智能判斷是否需要滾動</li>
<li>避免微小震盪的閾值保護</li>
</ul>
<h3 id="62-效能優化">6.2 效能優化</h3>
<ul>
<li>手機版不執行 TOC 相關功能</li>
<li>滾動事件節流處理</li>
<li>條件式 DOM 操作</li>
</ul>
<h3 id="63-無障礙設計">6.3 無障礙設計</h3>
<ul>
<li>正確的 ARIA 標籤</li>
<li>鍵盤導航支援</li>
<li>語義化 HTML 結構</li>
</ul>
]]></content:encoded></item></channel></rss>