<?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>Frontend with Playwright — 框架無關的前端開發 + Playwright 驗證 on Tarragon</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/</link><description>Recent content in Frontend with Playwright — 框架無關的前端開發 + Playwright 驗證 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Sun, 26 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/index.xml" rel="self" type="application/rss+xml"/><item><title>Accessibility and Focus — A11y 三道防線</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/accessibility-and-focus/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/accessibility-and-focus/</guid><description>&lt;p>A11y 三道防線：靜態（鍵盤可達性三要素）、動態（focus 跟 aria-live）、優先 Native HTML &amp;gt; ARIA。鍵盤 / 視覺 / motor / 認知都納入。&lt;/p>
&lt;p>適用：寫互動 UI、JS reparent / hide 元素、自製 component（modal / dropdown / tabs）、客製外部組件後檢查 a11y。
不適用：純後端 / 純資料流（沒有使用者直接互動）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>自包含聲明&lt;/strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋鍵盤可達性三要素、focus management 模板、aria-live 設計、native HTML 優先原則。&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>自製 modal / dropdown / tabs / accordion&lt;/td>
 &lt;td>先看有沒有 &lt;code>&amp;lt;dialog&amp;gt;&lt;/code> / &lt;code>&amp;lt;details&amp;gt;&lt;/code> 能用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>JS reparent 或 hide 元素&lt;/td>
 &lt;td>保存 focus、操作後還原&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>動態變動內容（搜尋結果、filter 切換、status 訊息）&lt;/td>
 &lt;td>加 &lt;code>aria-live&lt;/code> region&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>使用者反映「鍵盤跑掉」「Tab 順序怪」&lt;/td>
 &lt;td>檢查 visible focus indicator + tab order&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>即將寫 &lt;code>role=&amp;quot;button&amp;quot;&lt;/code> &lt;code>role=&amp;quot;dialog&amp;quot;&lt;/code> 等 ARIA role&lt;/td>
 &lt;td>停 — 看 native HTML 能不能用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>行動裝置誤點&lt;/td>
 &lt;td>檢查 hit target 大小（最小 44×44 px）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼-a11y-是預設不是補丁">為什麼 a11y 是預設不是補丁&lt;/h2>
&lt;p>A11y 不是「完整功能後再加上」、是&lt;strong>設計時就決定的結構&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>用 &lt;code>&amp;lt;button&amp;gt;&lt;/code> vs &lt;code>&amp;lt;div onclick&amp;gt;&lt;/code> → 鍵盤 / focus / a11y tree 自帶 vs 全部要自己補&lt;/li>
&lt;li>modal 用 &lt;code>&amp;lt;dialog&amp;gt;&lt;/code> vs 自己組 → focus trap / escape / scrollable / inert 自帶 vs 全部要自己補&lt;/li>
&lt;li>動態內容變動有 aria-live vs 沒 → screen reader 知道 vs 不知道&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>事後補 a11y 比事前設計貴 5-10 倍&lt;/strong>。寫之前先選對結構、後續成本低。&lt;/p>
&lt;hr>
&lt;h2 id="防線-1靜態鍵盤可達性三要素">防線 1：靜態鍵盤可達性三要素&lt;/h2>
&lt;p>鍵盤使用者要能用、三個元素缺一不可：&lt;/p>
&lt;h3 id="要素-1visible-focus-indicator">要素 1：Visible focus indicator&lt;/h3>





&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">/* 反例：去掉預設 focus outline */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nt">button&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="nd">focus&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">outline&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;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="c">/* 對例：可見的 focus indicator */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="nt">button&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="nd">focus-visible&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">outline&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">2&lt;/span>&lt;span class="kt">px&lt;/span> &lt;span class="kc">solid&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">focus&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="kc">color&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">outline-offset&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">2&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">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>:focus-visible&lt;/code>（鍵盤 focus）跟 &lt;code>:focus&lt;/code>（含滑鼠 click 後）區分 — 滑鼠使用者不需要看到 outline、鍵盤使用者必須看到。&lt;/p>
&lt;h3 id="要素-2邏輯-tab-順序">要素 2：邏輯 Tab 順序&lt;/h3>
&lt;p>Tab 順序預設由 DOM tree 決定。如果視覺順序跟 DOM 順序不同（例如用 CSS grid 重排），考慮：&lt;/p>
&lt;ul>
&lt;li>重排 DOM 順序對齊視覺&lt;/li>
&lt;li>用 &lt;code>tabindex=&amp;quot;0&amp;quot;&lt;/code> 讓元素可 focus（不要用 &amp;gt; 0）&lt;/li>
&lt;li>不要用 &lt;code>tabindex=&amp;quot;-1&amp;quot;&lt;/code> 跳過該 focus 的元素&lt;/li>
&lt;/ul>
&lt;h3 id="要素-3modal--drawer-有-escape-路徑">要素 3：Modal / drawer 有 escape 路徑&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">dialog&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;keydown&amp;#39;&lt;/span>&lt;span class="p">,&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">=&amp;gt;&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">e&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">key&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s1">&amp;#39;Escape&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">dialog&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">close&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;code>&amp;lt;dialog&amp;gt;&lt;/code> native — &lt;code>Escape&lt;/code> 自帶。&lt;/p></description><content:encoded><![CDATA[<p>A11y 三道防線：靜態（鍵盤可達性三要素）、動態（focus 跟 aria-live）、優先 Native HTML &gt; ARIA。鍵盤 / 視覺 / motor / 認知都納入。</p>
<p>適用：寫互動 UI、JS reparent / hide 元素、自製 component（modal / dropdown / tabs）、客製外部組件後檢查 a11y。
不適用：純後端 / 純資料流（沒有使用者直接互動）。</p>
<blockquote>
<p><strong>自包含聲明</strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋鍵盤可達性三要素、focus management 模板、aria-live 設計、native HTML 優先原則。</p></blockquote>
<hr>
<h2 id="何時參閱本文件">何時參閱本文件</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的第一件事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自製 modal / dropdown / tabs / accordion</td>
          <td>先看有沒有 <code>&lt;dialog&gt;</code> / <code>&lt;details&gt;</code> 能用</td>
      </tr>
      <tr>
          <td>JS reparent 或 hide 元素</td>
          <td>保存 focus、操作後還原</td>
      </tr>
      <tr>
          <td>動態變動內容（搜尋結果、filter 切換、status 訊息）</td>
          <td>加 <code>aria-live</code> region</td>
      </tr>
      <tr>
          <td>使用者反映「鍵盤跑掉」「Tab 順序怪」</td>
          <td>檢查 visible focus indicator + tab order</td>
      </tr>
      <tr>
          <td>即將寫 <code>role=&quot;button&quot;</code> <code>role=&quot;dialog&quot;</code> 等 ARIA role</td>
          <td>停 — 看 native HTML 能不能用</td>
      </tr>
      <tr>
          <td>行動裝置誤點</td>
          <td>檢查 hit target 大小（最小 44×44 px）</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼-a11y-是預設不是補丁">為什麼 a11y 是預設不是補丁</h2>
<p>A11y 不是「完整功能後再加上」、是<strong>設計時就決定的結構</strong>：</p>
<ul>
<li>用 <code>&lt;button&gt;</code> vs <code>&lt;div onclick&gt;</code> → 鍵盤 / focus / a11y tree 自帶 vs 全部要自己補</li>
<li>modal 用 <code>&lt;dialog&gt;</code> vs 自己組 → focus trap / escape / scrollable / inert 自帶 vs 全部要自己補</li>
<li>動態內容變動有 aria-live vs 沒 → screen reader 知道 vs 不知道</li>
</ul>
<p><strong>事後補 a11y 比事前設計貴 5-10 倍</strong>。寫之前先選對結構、後續成本低。</p>
<hr>
<h2 id="防線-1靜態鍵盤可達性三要素">防線 1：靜態鍵盤可達性三要素</h2>
<p>鍵盤使用者要能用、三個元素缺一不可：</p>
<h3 id="要素-1visible-focus-indicator">要素 1：Visible focus indicator</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">/* 反例：去掉預設 focus outline */</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nt">button</span><span class="p">:</span><span class="nd">focus</span> <span class="p">{</span> <span class="k">outline</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">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c">/* 對例：可見的 focus indicator */</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nt">button</span><span class="p">:</span><span class="nd">focus-visible</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="k">outline</span><span class="p">:</span> <span class="mi">2</span><span class="kt">px</span> <span class="kc">solid</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">focus</span><span class="o">-</span><span class="kc">color</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="k">outline-offset</span><span class="p">:</span> <span class="mi">2</span><span class="kt">px</span><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>:focus-visible</code>（鍵盤 focus）跟 <code>:focus</code>（含滑鼠 click 後）區分 — 滑鼠使用者不需要看到 outline、鍵盤使用者必須看到。</p>
<h3 id="要素-2邏輯-tab-順序">要素 2：邏輯 Tab 順序</h3>
<p>Tab 順序預設由 DOM tree 決定。如果視覺順序跟 DOM 順序不同（例如用 CSS grid 重排），考慮：</p>
<ul>
<li>重排 DOM 順序對齊視覺</li>
<li>用 <code>tabindex=&quot;0&quot;</code> 讓元素可 focus（不要用 &gt; 0）</li>
<li>不要用 <code>tabindex=&quot;-1&quot;</code> 跳過該 focus 的元素</li>
</ul>
<h3 id="要素-3modal--drawer-有-escape-路徑">要素 3：Modal / drawer 有 escape 路徑</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">dialog</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;keydown&#39;</span><span class="p">,</span> <span class="p">(</span><span class="nx">e</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="nx">e</span><span class="p">.</span><span class="nx">key</span> <span class="o">===</span> <span class="s1">&#39;Escape&#39;</span><span class="p">)</span> <span class="nx">dialog</span><span class="p">.</span><span class="nx">close</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>或用 <code>&lt;dialog&gt;</code> native — <code>Escape</code> 自帶。</p>
<hr>
<h2 id="防線-2動態-a11y">防線 2：動態 a11y</h2>
<h3 id="focus-management-on-dom-move">Focus management on DOM move</h3>
<p>JS reparent / hide 元素時、focus 會跑掉（落到 body）。需要保存與還原：</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">moveFilter</span><span class="p">(</span><span class="nx">targetSlot</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">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;.filter&#39;</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">focused</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"> 4</span><span class="cl">  <span class="kr">const</span> <span class="nx">wasFilterFocused</span> <span class="o">=</span> <span class="nx">filter</span><span class="p">.</span><span class="nx">contains</span><span class="p">(</span><span class="nx">focused</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="nx">targetSlot</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">filter</span><span class="p">);</span>  <span class="c1">// reparent
</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="k">if</span> <span class="p">(</span><span class="nx">wasFilterFocused</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">focused</span><span class="p">.</span><span class="nx">focus</span><span class="p">();</span>  <span class="c1">// 還原 focus
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>  <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="aria-live-廣播動態變動">aria-live 廣播動態變動</h3>
<p>Screen reader 預設不會朗讀「DOM 變動」、要明確告訴它：</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="c">&lt;!-- polite：等使用者操作完才朗讀（搜尋結果數量、filter 切換） --&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">aria-live</span><span class="o">=</span><span class="s">&#34;polite&#34;</span> <span class="na">aria-atomic</span><span class="o">=</span><span class="s">&#34;true&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  顯示 12 筆結果
</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="p">&gt;</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="c">&lt;!-- assertive：立刻打斷朗讀（錯誤訊息、緊急狀態） --&gt;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">aria-live</span><span class="o">=</span><span class="s">&#34;assertive&#34;</span> <span class="na">role</span><span class="o">=</span><span class="s">&#34;alert&#34;</span><span class="p">&gt;</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">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div><p><code>aria-atomic=&quot;true&quot;</code> 整段重讀（不只朗讀變動的部分）。</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="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;results&#34;</span> <span class="na">aria-live</span><span class="o">=</span><span class="s">&#34;polite&#34;</span> <span class="na">aria-atomic</span><span class="o">=</span><span class="s">&#34;false&#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">p</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;status&#34;</span><span class="p">&gt;</span>顯示 <span class="p">&lt;</span><span class="nt">span</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;count&#34;</span><span class="p">&gt;</span>12<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span> 筆結果<span class="p">&lt;/</span><span class="nt">p</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">ul</span><span class="p">&gt;</span>...<span class="p">&lt;/</span><span class="nt">ul</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="p">&gt;</span></span></span></code></pre></div><p>JS 更新 <code>#count</code> 的 textContent 時、screen reader 朗讀「顯示 12 筆結果」。</p>
<hr>
<h2 id="防線-3native-html--aria">防線 3：Native HTML &gt; ARIA</h2>
<h3 id="為什麼優先-native">為什麼優先 Native</h3>
<table>
  <thead>
      <tr>
          <th>元素</th>
          <th>Native 自帶</th>
          <th>ARIA 補強需要</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>&lt;button&gt;</code></td>
          <td>Tab focus、Enter/Space 觸發、a11y role、disabled 狀態</td>
          <td><code>role=&quot;button&quot;</code> + tabindex + keydown listener + aria-disabled</td>
      </tr>
      <tr>
          <td><code>&lt;dialog&gt;</code></td>
          <td>Modal focus trap、Escape 關閉、<code>::backdrop</code>、<code>inert</code> 外層</td>
          <td><code>role=&quot;dialog&quot;</code> + aria-modal + 自寫 focus trap + Escape handler + inert polyfill</td>
      </tr>
      <tr>
          <td><code>&lt;details&gt;</code></td>
          <td>Toggle 展開、鍵盤、a11y</td>
          <td><code>role=&quot;region&quot;</code> + aria-expanded + 自寫 click handler + keyboard support</td>
      </tr>
      <tr>
          <td><code>&lt;fieldset&gt;+&lt;legend&gt;</code></td>
          <td>群組 a11y、screen reader 讀 legend</td>
          <td><code>role=&quot;radiogroup&quot;</code> + aria-labelledby</td>
      </tr>
      <tr>
          <td><code>&lt;input type=&quot;...&quot;&gt;</code></td>
          <td>各種 input 的 native UX、validation、a11y</td>
          <td>全部自寫</td>
      </tr>
  </tbody>
</table>
<h3 id="何時用-aria">何時用 ARIA</h3>
<p>ARIA 是補強、不是替代：</p>
<ul>
<li>用 native 但 a11y tree 還不夠（標 aria-label / aria-describedby 補語意）</li>
<li>真的沒有 native 元素（complex composite widget、tabs、tree）</li>
<li>動態變動需要廣播（aria-live）</li>
</ul>
<h3 id="範例自製-toggle-還是-native-checkbox">範例：自製 toggle 還是 native checkbox</h3>
<p><strong>錯</strong>：</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;toggle&#34;</span> <span class="na">role</span><span class="o">=</span><span class="s">&#34;switch&#34;</span> <span class="na">tabindex</span><span class="o">=</span><span class="s">&#34;0&#34;</span> <span class="na">aria-checked</span><span class="o">=</span><span class="s">&#34;false&#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">span</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;track&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">span</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="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</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">5</span><span class="cl">  <span class="nx">toggle</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></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nx">toggle</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;keydown&#39;</span><span class="p">,</span> <span class="nx">e</span> <span class="p">=&gt;</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">e</span><span class="p">.</span><span class="nx">key</span> <span class="o">===</span> <span class="s1">&#39;Enter&#39;</span> <span class="o">||</span> <span class="nx">e</span><span class="p">.</span><span class="nx">key</span> <span class="o">===</span> <span class="s1">&#39; &#39;</span><span class="p">)</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">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span></span></span></code></pre></div><p><strong>對</strong>：</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">label</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;toggle&#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">input</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;checkbox&#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">span</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;track&#34;</span> <span class="na">aria-hidden</span><span class="o">=</span><span class="s">&#34;true&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">span</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">span</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;visually-hidden&#34;</span><span class="p">&gt;</span>啟用 dark mode<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">&lt;/</span><span class="nt">label</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">toggle</span> <span class="nt">input</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">opacity</span><span class="p">:</span> <span class="mi">0</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">toggle</span> <span class="nt">input</span><span class="p">:</span><span class="nd">checked</span> <span class="o">+</span> <span class="p">.</span><span class="nc">track</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">brand</span><span class="p">);</span> <span class="p">}</span></span></span></code></pre></div><p>Native checkbox 自帶 keyboard / focus / state、CSS 把它隱藏、視覺用 <code>.track</code> 呈現。</p>
<hr>
<h2 id="視覺--motor-a11y">視覺 / Motor a11y</h2>
<h3 id="視覺輔助">視覺輔助</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">/* 對比度 */</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="p">:</span><span class="nd">root</span> <span class="p">{</span> <span class="nv">--text</span><span class="p">:</span> <span class="mh">#1a202c</span><span class="p">;</span> <span class="nv">--bg</span><span class="p">:</span> <span class="mh">#fff</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c">/* WCAG AA: 普通文字 4.5:1、大文字 3:1 */</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">/* 字型放大時不破版 */</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">.</span><span class="nc">container</span> <span class="p">{</span> <span class="k">max-width</span><span class="p">:</span> <span class="mi">60</span><span class="kt">ch</span><span class="p">;</span> <span class="p">}</span>  <span class="c">/* ch 跟字型同步 */</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">.</span><span class="nc">text</span> <span class="p">{</span> <span class="k">font-size</span><span class="p">:</span> <span class="mi">1</span><span class="kt">rem</span><span class="p">;</span> <span class="k">line-height</span><span class="p">:</span> <span class="mf">1.6</span><span class="p">;</span> <span class="p">}</span>  <span class="c">/* rem 跟使用者設定同步 */</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="c">/* prefers-reduced-motion */</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">@</span><span class="k">media</span> <span class="o">(</span><span class="nt">prefers-reduced-motion</span><span class="o">:</span> <span class="nt">reduce</span><span class="o">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="o">*</span> <span class="p">{</span> <span class="k">animation-duration</span><span class="p">:</span> <span class="mf">0.01</span><span class="kt">ms</span> <span class="cp">!important</span><span class="p">;</span> <span class="k">transition-duration</span><span class="p">:</span> <span class="mf">0.01</span><span class="kt">ms</span> <span class="cp">!important</span><span class="p">;</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><h3 id="motor--hit-target">Motor / Hit target</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">/* 觸控 hit target 最小 44×44 px (WCAG AAA) */</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nt">button</span><span class="o">,</span> <span class="nt">a</span><span class="o">,</span> <span class="o">[</span><span class="nt">role</span><span class="o">=</span><span class="s2">&#34;button&#34;</span><span class="o">]</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">min-height</span><span class="p">:</span> <span class="mi">44</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">min-width</span><span class="p">:</span> <span class="mi">44</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="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="c">/* 兩個 hit target 之間留 8px+ 間距、避免誤點 */</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">.</span><span class="nc">toolbar</span> <span class="o">&gt;</span> <span class="o">*</span> <span class="o">+</span> <span class="o">*</span> <span class="p">{</span> <span class="k">margin-left</span><span class="p">:</span> <span class="mi">8</span><span class="kt">px</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><hr>
<h2 id="wrong-vs-right-對照">Wrong vs Right 對照</h2>
<h3 id="範例-1自製-dropdown">範例 1：自製 dropdown</h3>
<p><strong>錯</strong>：</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;dropdown&#34;</span> <span class="na">tabindex</span><span class="o">=</span><span class="s">&#34;0&#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">span</span><span class="p">&gt;</span>選單<span class="p">&lt;/</span><span class="nt">span</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;menu&#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;item&#34;</span><span class="p">&gt;</span>選項 1<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&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="na">class</span><span class="o">=</span><span class="s">&#34;item&#34;</span><span class="p">&gt;</span>選項 2<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 class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div><p>問題：no native focus、no keyboard、no a11y role、screen reader 不知道是 menu。</p>
<p><strong>對</strong>：</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">button</span> <span class="na">aria-haspopup</span><span class="o">=</span><span class="s">&#34;menu&#34;</span> <span class="na">aria-expanded</span><span class="o">=</span><span class="s">&#34;false&#34;</span> <span class="na">aria-controls</span><span class="o">=</span><span class="s">&#34;menu1&#34;</span><span class="p">&gt;</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">&lt;/</span><span class="nt">button</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">ul</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;menu1&#34;</span> <span class="na">role</span><span class="o">=</span><span class="s">&#34;menu&#34;</span> <span class="na">hidden</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="p">&lt;</span><span class="nt">li</span> <span class="na">role</span><span class="o">=</span><span class="s">&#34;menuitem&#34;</span><span class="p">&gt;&lt;</span><span class="nt">button</span><span class="p">&gt;</span>選項 1<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;&lt;/</span><span class="nt">li</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">li</span> <span class="na">role</span><span class="o">=</span><span class="s">&#34;menuitem&#34;</span><span class="p">&gt;&lt;</span><span class="nt">button</span><span class="p">&gt;</span>選項 2<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;&lt;/</span><span class="nt">li</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">&lt;/</span><span class="nt">ul</span><span class="p">&gt;</span></span></span></code></pre></div><p>或如果是「選擇一個」 → <code>&lt;select&gt;</code> native。</p>
<h3 id="範例-2filter-切換沒-a11y-broadcast">範例 2：filter 切換沒 a11y broadcast</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">button</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="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;.result&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">r</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="nx">r</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">tag</span> <span class="o">===</span> <span class="nx">currentFilter</span> <span class="o">?</span> <span class="s1">&#39;block&#39;</span> <span class="o">:</span> <span class="s1">&#39;none&#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="p">});</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">// screen reader 不知道結果變了
</span></span></span></code></pre></div><p><strong>對</strong>：</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;results&#34;</span> <span class="na">aria-live</span><span class="o">=</span><span class="s">&#34;polite&#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">p</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;status&#34;</span><span class="p">&gt;</span>顯示 <span class="p">&lt;</span><span class="nt">span</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;count&#34;</span><span class="p">&gt;</span>12<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span> 筆結果（filter: <span class="p">&lt;</span><span class="nt">span</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;filter&#34;</span><span class="p">&gt;</span>全部<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>）<span class="p">&lt;/</span><span class="nt">p</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="p">&gt;</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">button</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="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="c1">// ... filter logic
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="s1">&#39;count&#39;</span><span class="p">).</span><span class="nx">textContent</span> <span class="o">=</span> <span class="nx">visibleCount</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">getElementById</span><span class="p">(</span><span class="s1">&#39;filter&#39;</span><span class="p">).</span><span class="nx">textContent</span> <span class="o">=</span> <span class="nx">currentFilter</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="c1">// aria-live 自動朗讀
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><h3 id="範例-3js-移動元素-focus-跑掉">範例 3：JS 移動元素 focus 跑掉</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="c1">// resize 時把 filter 從 mobile drawer 移到 desktop sidebar
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">mediaQuery</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="p">()</span> <span class="p">=&gt;</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">mediaQuery</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">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">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">appendChild</span><span class="p">(</span><span class="nx">filter</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="c1">// 如果 filter 內的某個 input 有 focus、reparent 後 focus 落到 body
</span></span></span></code></pre></div><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">mediaQuery</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="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="kr">const</span> <span class="nx">focused</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="kr">const</span> <span class="nx">wasInFilter</span> <span class="o">=</span> <span class="nx">filter</span><span class="p">.</span><span class="nx">contains</span><span class="p">(</span><span class="nx">focused</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="k">if</span> <span class="p">(</span><span class="nx">mediaQuery</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"> 6</span><span class="cl">    <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"> 7</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"> 8</span><span class="cl">    <span class="nx">drawer</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"> 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="k">if</span> <span class="p">(</span><span class="nx">wasInFilter</span><span class="p">)</span> <span class="nx">focused</span><span class="p">.</span><span class="nx">focus</span><span class="p">();</span>  <span class="c1">// 還原 focus
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><hr>
<h2 id="自檢清單dogfooding">自檢清單（dogfooding）</h2>
<p>寫互動 UI 時：</p>
<ul>
<li><input disabled="" type="checkbox"> 用 <code>&lt;button&gt;</code> <code>&lt;dialog&gt;</code> <code>&lt;details&gt;</code> <code>&lt;fieldset&gt;</code> 取代自製 ARIA 結構？</li>
<li><input disabled="" type="checkbox"> visible focus indicator 沒被 <code>outline: none</code> 拿掉？</li>
<li><input disabled="" type="checkbox"> Tab 順序符合視覺順序（沒用 <code>tabindex &gt; 0</code>）？</li>
<li><input disabled="" type="checkbox"> Modal / drawer 有 Escape 關閉路徑？</li>
<li><input disabled="" type="checkbox"> JS reparent / hide 時保存與還原 focus？</li>
<li><input disabled="" type="checkbox"> 動態變動內容用 <code>aria-live</code> 廣播？</li>
<li><input disabled="" type="checkbox"> 對比度 ≥ 4.5:1（普通文字）？</li>
<li><input disabled="" type="checkbox"> Hit target ≥ 44×44 px？</li>
<li><input disabled="" type="checkbox"> <code>prefers-reduced-motion</code> 時關掉動畫？</li>
</ul>
<hr>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>對應的事後檢討（在 <code>content/report/</code>）：</p>
<ul>
<li><a href="/blog/report/focus-management-on-dom-move/" data-link-title="動態 DOM 移動時的 focus 管理" data-link-desc="Filter slot 跨 viewport 搬節點、scope filter 隱藏結果 — 這類 DOM 變動會讓鍵盤 focus 跑掉或停在不可見位置。本文盤點動態 DOM 對 focus 的影響與檢查方法。">focus-management-on-dom-move</a> — 動態 DOM 移動時的 focus 管理</li>
<li><a href="/blog/report/aria-live-for-dynamic-content/" data-link-title="Screen reader 與動態內容變動的 live region 設計" data-link-desc="Scope filter 切換、結果數量變動 — screen reader 使用者看不到視覺變動、需要 aria-live region 主動朗讀。本文盤點 live region 的設計選擇與適用情境。">aria-live-for-dynamic-content</a> — Screen reader 與動態內容變動的 live region 設計</li>
<li><a href="/blog/report/native-html-over-aria-role/" data-link-title="Native HTML element 優先於 ARIA role 的取捨" data-link-desc="用 `&lt;fieldset&gt;&lt;legend&gt;` 比 `&lt;div role=&#34;radiogroup&#34;&gt;` 安全、用 `&lt;button&gt;` 比 `&lt;div role=&#34;button&#34;&gt;` 直接 — native element 自帶完整無障礙語意與行為。本文盤點 ARIA role 是 fallback、不是 default。">native-html-over-aria-role</a> — Native HTML element 優先於 ARIA role 的取捨</li>
<li><a href="/blog/report/keyboard-accessibility/" data-link-title="鍵盤可達性：focus indicator、tab 順序、escape 路徑" data-link-desc="鍵盤使用者用 tab / shift&#43;tab 導航、enter / space 激活、esc 退出。三件事決定可不可用：focus 是否可見、tab 順序是否合理、modal / overlay 有沒有 escape 路徑。本文盤點搜尋頁的鍵盤 a11y 風險點。">keyboard-accessibility</a> — 鍵盤可達性：focus indicator、tab 順序、escape 路徑</li>
<li><a href="/blog/report/motor-accessibility-hit-target/" data-link-title="Motor 可達性：hit target、間距、誤點防護" data-link-desc="Hit target 太小會讓行動裝置使用者誤點、motor 障礙使用者更甚。WCAG AAA 建議 ≥ 44×44px、間距足夠避免誤觸。本文展開 hit target 設計與相關 motor a11y 風險點。">motor-accessibility-hit-target</a> — Motor 可達性：hit target、間距、誤點防護</li>
<li><a href="/blog/report/visual-aids-contrast-zoom-responsive/" data-link-title="視覺輔助：對比度、放大、字型 zoom 的 layout 適配" data-link-desc="色弱、低對比敏感、低視力使用者跟一般使用者「看到的不是同一個 UI」 — 對比度足夠嗎、絕對定位元件在放大模式下是否可達、字型放大 200% 後 layout 還好嗎。本文盤點視覺呈現面的 a11y 風險點。">visual-aids-contrast-zoom-responsive</a> — 視覺輔助：對比度、放大、字型 zoom 的 layout 適配</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.1.0</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>Data Flow and Filter Composition — Filter × Source 層錯位與五策略</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/</guid><description>&lt;p>設計 filter / sort / count / transform 等 stream 操作時、確保操作位置跟資料源同層、避免層錯位產生 silent 缺口。原則跨 UI / 後端 / 演算法管線通用 — 不只是前端問題。&lt;/p>
&lt;p>適用：前端 paginated UI 加 filter、後端 API + middleware filter、演算法 pipeline 加 transform、map-reduce 加 post-filter、資料庫 materialized view 加 query。
不適用：純運算演算法（沒有 stream / 沒有 materialization 概念）、純 React state 管理（沒有外部 source）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>自包含聲明&lt;/strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋層錯位識別、五策略選擇、跨領域範例、playwright 驗證方法。&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>即將寫 &lt;code>forEach(el =&amp;gt; el.hidden = !matches(el))&lt;/code>&lt;/td>
 &lt;td>停 — 確認 source 是不是分批 / streaming&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Source 是 &lt;code>pagefind.search()&lt;/code> / &lt;code>paginatedFetch()&lt;/code> / &lt;code>for await&lt;/code>&lt;/td>
 &lt;td>filter 必須跟 source 同層、不能在 view 層後處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「filter 後 0 筆但 source 還有未載入」可能發生&lt;/td>
 &lt;td>必須補自動續抓 / 推進 query / 誠實 UX&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Backend middleware / response wrapper 加 filter&lt;/td>
 &lt;td>推進 ORM query / SQL &lt;code>WHERE&lt;/code>、不在 response 後&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>演算法 pipeline 末端 filter&lt;/td>
 &lt;td>推進 pipeline stage 內、stream-aware&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Map-reduce 完成後加 post-filter&lt;/td>
 &lt;td>推進 map / reduce 階段&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「畫面 / 結果對了但邊界 case 怪」&lt;/td>
 &lt;td>識別這是層錯位、不是 bug 修補能解&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼-filter--source-是個結構性議題">為什麼 filter × source 是個結構性議題&lt;/h2>
&lt;p>Filter 操作的定義是「從 stream 中過濾出符合條件的元素」 — &lt;strong>stream&lt;/strong> 是隱含的對象。當 stream 被分層 materialize 時、filter 套在哪一層、決定它能「看見」的元素範圍：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>層&lt;/th>
 &lt;th>能看到的範圍&lt;/th>
 &lt;th>filter 結果的語意&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Source 層&lt;/td>
 &lt;td>完整 stream&lt;/td>
 &lt;td>「stream 中所有符合的」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Materialization 中&lt;/td>
 &lt;td>已 materialize 的部分&lt;/td>
 &lt;td>「目前載入的符合的」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>下游（view / response）&lt;/td>
 &lt;td>Materialized 之後 + downstream filter 之前的子集&lt;/td>
 &lt;td>「下游可見的子集中符合的」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>使用者 / 呼叫者意圖的「filter」通常是第一層（stream 全集）— 但寫程式當下手邊的對象通常是第三層（已 materialize 的 subset）。&lt;strong>寫起來最便利的位置 ≠ 對齊意圖的位置&lt;/strong>。&lt;/p>
&lt;p>這是 &lt;a href="https://tarrragon.github.io/blog/report/ease-of-writing-vs-intent-alignment/" data-link-title="寫作便利度跟意圖對齊反相關" data-link-desc="寫程式時最容易寫出的版本、通常是離意圖最遠的版本。便利度建立在「現有上下文 / 已 materialize 資料 / 已存在 API」上、而意圖對齊需要找到正確的層、處理上游、跨抽象層 — 兩者方向相反。識別這個反相關 = 識別自己掉進「容易寫的陷阱」。">#67 寫作便利度跟意圖對齊反相關&lt;/a> 在 stream 操作上的具體展現。&lt;/p></description><content:encoded><![CDATA[<p>設計 filter / sort / count / transform 等 stream 操作時、確保操作位置跟資料源同層、避免層錯位產生 silent 缺口。原則跨 UI / 後端 / 演算法管線通用 — 不只是前端問題。</p>
<p>適用：前端 paginated UI 加 filter、後端 API + middleware filter、演算法 pipeline 加 transform、map-reduce 加 post-filter、資料庫 materialized view 加 query。
不適用：純運算演算法（沒有 stream / 沒有 materialization 概念）、純 React state 管理（沒有外部 source）。</p>
<blockquote>
<p><strong>自包含聲明</strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋層錯位識別、五策略選擇、跨領域範例、playwright 驗證方法。</p></blockquote>
<hr>
<h2 id="何時參閱本文件">何時參閱本文件</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的第一件事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即將寫 <code>forEach(el =&gt; el.hidden = !matches(el))</code></td>
          <td>停 — 確認 source 是不是分批 / streaming</td>
      </tr>
      <tr>
          <td>Source 是 <code>pagefind.search()</code> / <code>paginatedFetch()</code> / <code>for await</code></td>
          <td>filter 必須跟 source 同層、不能在 view 層後處理</td>
      </tr>
      <tr>
          <td>「filter 後 0 筆但 source 還有未載入」可能發生</td>
          <td>必須補自動續抓 / 推進 query / 誠實 UX</td>
      </tr>
      <tr>
          <td>Backend middleware / response wrapper 加 filter</td>
          <td>推進 ORM query / SQL <code>WHERE</code>、不在 response 後</td>
      </tr>
      <tr>
          <td>演算法 pipeline 末端 filter</td>
          <td>推進 pipeline stage 內、stream-aware</td>
      </tr>
      <tr>
          <td>Map-reduce 完成後加 post-filter</td>
          <td>推進 map / reduce 階段</td>
      </tr>
      <tr>
          <td>「畫面 / 結果對了但邊界 case 怪」</td>
          <td>識別這是層錯位、不是 bug 修補能解</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼-filter--source-是個結構性議題">為什麼 filter × source 是個結構性議題</h2>
<p>Filter 操作的定義是「從 stream 中過濾出符合條件的元素」 — <strong>stream</strong> 是隱含的對象。當 stream 被分層 materialize 時、filter 套在哪一層、決定它能「看見」的元素範圍：</p>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>能看到的範圍</th>
          <th>filter 結果的語意</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source 層</td>
          <td>完整 stream</td>
          <td>「stream 中所有符合的」</td>
      </tr>
      <tr>
          <td>Materialization 中</td>
          <td>已 materialize 的部分</td>
          <td>「目前載入的符合的」</td>
      </tr>
      <tr>
          <td>下游（view / response）</td>
          <td>Materialized 之後 + downstream filter 之前的子集</td>
          <td>「下游可見的子集中符合的」</td>
      </tr>
  </tbody>
</table>
<p>使用者 / 呼叫者意圖的「filter」通常是第一層（stream 全集）— 但寫程式當下手邊的對象通常是第三層（已 materialize 的 subset）。<strong>寫起來最便利的位置 ≠ 對齊意圖的位置</strong>。</p>
<p>這是 <a href="/blog/report/ease-of-writing-vs-intent-alignment/" data-link-title="寫作便利度跟意圖對齊反相關" data-link-desc="寫程式時最容易寫出的版本、通常是離意圖最遠的版本。便利度建立在「現有上下文 / 已 materialize 資料 / 已存在 API」上、而意圖對齊需要找到正確的層、處理上游、跨抽象層 — 兩者方向相反。識別這個反相關 = 識別自己掉進「容易寫的陷阱」。">#67 寫作便利度跟意圖對齊反相關</a> 在 stream 操作上的具體展現。</p>
<hr>
<h2 id="跨領域同個結構五個情境">跨領域：同個結構、五個情境</h2>
<h3 id="情境-1前端-ui--pagefind-paginated-search">情境 1：前端 UI + Pagefind paginated search</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">// 反例：post-filter on view layer
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">all</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">pagefind</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">query</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">all</span><span class="p">.</span><span class="nx">results</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="nx">start</span><span class="p">,</span> <span class="nx">start</span> <span class="o">+</span> <span class="mi">10</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">render</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">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#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">5</span><span class="cl">  <span class="nx">el</span><span class="p">.</span><span class="nx">hidden</span> <span class="o">=</span> <span class="o">!</span><span class="nx">matches</span><span class="p">(</span><span class="nx">el</span><span class="p">);</span>  <span class="c1">// view 層 filter
</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="c1">// 第二批全 hidden、使用者看到「load more 沒效果」
</span></span></span></code></pre></div><h3 id="情境-2後端-api--orm-middleware">情境 2：後端 API + ORM middleware</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 反例：middleware 在 pagination 之後 filter</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nd">@app.route</span><span class="p">(</span><span class="s2">&#34;/posts&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">def</span> <span class="nf">posts</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">page</span> <span class="o">=</span> <span class="n">Post</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">paginate</span><span class="p">(</span><span class="n">page</span><span class="o">=</span><span class="mi">1</span><span class="p">,</span> <span class="n">per_page</span><span class="o">=</span><span class="mi">10</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 class="n">p</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">page</span><span class="o">.</span><span class="n">items</span> <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">author</span> <span class="o">==</span> <span class="s2">&#34;author_x&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="c1"># 漏掉沒在這頁的符合項</span></span></span></code></pre></div><h3 id="情境-3async-iterator--taken">情境 3：Async iterator + take(N)</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 反例：先 take 後 filter</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">items</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">itertools</span><span class="o">.</span><span class="n">islice</span><span class="p">(</span><span class="n">stream</span><span class="p">(),</span> <span class="mi">100</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">filtered</span> <span class="o">=</span> <span class="p">[</span><span class="n">x</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">items</span> <span class="k">if</span> <span class="n">matches</span><span class="p">(</span><span class="n">x</span><span class="p">)]</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># stream 後面可能還有符合的、但被 take 100 切斷了</span></span></span></code></pre></div><h3 id="情境-4map-reduce--post-reduce-filter">情境 4：Map-reduce + post-reduce filter</h3>





<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">[shards] → [map output] → [reduce]
</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">                         [filter]  ← 已是 reduce 後的結果</span></span></code></pre></div><p>Filter 應該在 map 階段（per-shard）或 reduce 內、不是 reduce 後。</p>
<h3 id="情境-5materialized-view--select">情境 5：Materialized view + SELECT</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- 反例：在 stale view 上 filter
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">posts_summary</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">author_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">42</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- view 可能是某個時點的 snapshot、漏掉之後寫入的 posts
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 對例：filter 推進原表
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">posts</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">author_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">42</span><span class="p">;</span></span></span></code></pre></div><p><strong>五個情境共用結構</strong>：source 是分層 materialize 的、filter 套在下游 → silent 缺口。</p>
<hr>
<h2 id="五種解法策略">五種解法策略</h2>
<p>詳細展開見 <a href="/blog/report/filter-source-composition-strategies/" data-link-title="Filter × Source 的合成策略五選一" data-link-desc="Filter 跟 paginated / streaming source 合成的五種策略、各自機會成本不同：A 推進 query / B 自動續抓 / C 預先 index / D 誠實 UX / E 接受語意縮小。沒有絕對最佳、看 source capabilities、match 密度、UX 容忍度而定。">#59 Filter × Source 合成策略五選一</a>。本卡只列總覽：</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>一句話</th>
          <th>對 source 的需求</th>
          <th>工程量</th>
          <th>UX 影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A</td>
          <td>把 filter 推進 source 的 query</td>
          <td>必須支援該 filter 條件</td>
          <td>中-高</td>
          <td>透明（無感）</td>
      </tr>
      <tr>
          <td>B</td>
          <td>自動續抓直到湊滿 N 個 match</td>
          <td>任何分批 source</td>
          <td>中</td>
          <td>透明（稍慢）</td>
      </tr>
      <tr>
          <td>C</td>
          <td>預先建獨立 index（每種 mode 一份）</td>
          <td>能控 source 的 build pipeline</td>
          <td>高</td>
          <td>透明（最快）</td>
      </tr>
      <tr>
          <td>D</td>
          <td>誠實 UX 顯示「已掃 N / 命中 K」</td>
          <td>任何 source</td>
          <td>低</td>
          <td>顯眼（多按鈕）</td>
      </tr>
      <tr>
          <td>E</td>
          <td>明示語意縮小（filter 範圍 = 已載入）</td>
          <td>任何 source</td>
          <td>最低</td>
          <td>隱性語意縮小</td>
      </tr>
  </tbody>
</table>
<p>選擇順序：<strong>A → C → B → D → E</strong>（不寫不告知的 silent 縮小、那是反模式）。</p>
<p>對應的 pattern 卡片：<a href="/blog/report/pattern-fetch-until-quota/" data-link-title="Pattern：自動續抓直到湊滿 quota" data-link-desc="Pattern 卡片：分批 source &#43; post-filter 時、自動續抓直到湊滿 N 個 match。含上限保護、進度顯示、可中斷三個必要元件。對應 #59 策略 B 的具體實作。">#60 自動續抓</a> / <a href="/blog/report/pattern-query-side-pushdown/" data-link-title="Pattern：把 filter 推進 query 引擎" data-link-desc="Pattern 卡片：把 client-side filter 推進 source 的 query 引擎、由 source 直接回符合的。對應 #59 策略 A 的具體實作。前提是 source capabilities 支援該 filter 條件、否則要評估重 index。">#61 推進 query</a> / <a href="/blog/report/pattern-honest-progress-ui/" data-link-title="Pattern：誠實進度 UX（已掃 N / 命中 K / 共 M）" data-link-desc="Pattern 卡片：當 filter 跟 source 必然有層錯位、用三數字（已掃 N / 命中 K / 共 M）讓使用者看見掃描範圍、避免誤以為「沒命中」。對應 #59 策略 D 的具體實作。">#62 誠實進度 UX</a> / <a href="/blog/report/pattern-multiple-indexes/" data-link-title="Pattern：預先建獨立 index（每種 mode 一份）" data-link-desc="Pattern 卡片：build time 為每種 filter mode 各建一份 source / index、runtime 切換 mode = 切 source。對應 #59 策略 C 的具體實作。前提是能控 source 的 build pipeline、且 mode 數量有限。">#65 多 index</a> / <a href="/blog/report/pattern-explicit-semantic-narrowing/" data-link-title="Pattern：明示語意縮小（不承諾全集）" data-link-desc="Pattern 卡片：當 filter 必然只能在 subset 上做、明確告訴使用者「這只在已載入範圍內找」、不假裝是全集 filter。對應 #59 策略 E 的具體實作。重點是「明示」、silent 縮小是反模式。">#66 明示語意縮小</a></p>
<hr>
<h2 id="三變數決定策略選擇">三變數決定策略選擇</h2>
<p>選 A / B / C / D / E 看三個變數：</p>
<h3 id="變數-1source-capabilities">變數 1：Source capabilities</h3>
<p>Source 支援哪些 server-side filter？</p>
<ul>
<li>支援該 filter 條件 → A 最優</li>
<li>不支援、能控 build → 評估 C</li>
<li>都不行 → B / D / E</li>
</ul>
<h3 id="變數-2match-密度">變數 2：Match 密度</h3>
<p>每抓一批、預期多少筆 match？</p>
<ul>
<li>高密度（每批 ≥ 1 個 match）→ B 自動續抓 OK</li>
<li>稀疏（要抓很多批才湊到一個）→ B 會拉爆、用 D / E</li>
<li>不可預期 → 加上限保護的 B + fallback 到 D</li>
</ul>
<h3 id="變數-3ux-容忍度">變數 3：UX 容忍度</h3>
<p>使用者能接受多顯眼的「掃描範圍」UX？</p>
<ul>
<li>完全不行（filter 是核心互動）→ A / C</li>
<li>可以顯示三數字 → D</li>
<li>一次性文字告知就行 → E</li>
</ul>
<hr>
<h2 id="playwright-驗證-filter--source-行為">Playwright 驗證 filter × source 行為</h2>
<p>寫完 filter 後、用 playwright 驗證是否有層錯位 silent 缺口。</p>
<h3 id="驗證-1load-more-後-filter-後是否該有結果">驗證 1：「Load more 後 filter 後是否該有結果」</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="kr">async</span> <span class="p">({</span> <span class="nx">page</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="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">click</span><span class="p">(</span><span class="s1">&#39;[data-scope=&#34;title&#34;]&#39;</span><span class="p">);</span>  <span class="c1">// 選 title-only
</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></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>  <span class="kr">const</span> <span class="nx">before</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">loaded</span><span class="o">:</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">$$eval</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">,</span> <span class="nx">els</span> <span class="p">=&gt;</span> <span class="nx">els</span><span class="p">.</span><span class="nx">length</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">visible</span><span class="o">:</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">$$eval</span><span class="p">(</span><span class="s1">&#39;.result:not([hidden])&#39;</span><span class="p">,</span> <span class="nx">els</span> <span class="p">=&gt;</span> <span class="nx">els</span><span class="p">.</span><span class="nx">length</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="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">click</span><span class="p">(</span><span class="s1">&#39;button.load-more&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForTimeout</span><span class="p">(</span><span class="mi">500</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="kr">const</span> <span class="nx">after</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">loaded</span><span class="o">:</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">$$eval</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">,</span> <span class="nx">els</span> <span class="p">=&gt;</span> <span class="nx">els</span><span class="p">.</span><span class="nx">length</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">visible</span><span class="o">:</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">$$eval</span><span class="p">(</span><span class="s1">&#39;.result:not([hidden])&#39;</span><span class="p">,</span> <span class="nx">els</span> <span class="p">=&gt;</span> <span class="nx">els</span><span class="p">.</span><span class="nx">length</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="c1">// 層錯位徵兆：loaded 增加、visible 沒增加
</span></span></span><span class="line"><span class="ln">20</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">21</span><span class="cl">    <span class="nx">deltaLoaded</span><span class="o">:</span> <span class="nx">after</span><span class="p">.</span><span class="nx">loaded</span> <span class="o">-</span> <span class="nx">before</span><span class="p">.</span><span class="nx">loaded</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="nx">deltaVisible</span><span class="o">:</span> <span class="nx">after</span><span class="p">.</span><span class="nx">visible</span> <span class="o">-</span> <span class="nx">before</span><span class="p">.</span><span class="nx">visible</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="nx">isSilentGap</span><span class="o">:</span> <span class="nx">after</span><span class="p">.</span><span class="nx">loaded</span> <span class="o">&gt;</span> <span class="nx">before</span><span class="p">.</span><span class="nx">loaded</span> <span class="o">&amp;&amp;</span> <span class="nx">after</span><span class="p">.</span><span class="nx">visible</span> <span class="o">===</span> <span class="nx">before</span><span class="p">.</span><span class="nx">visible</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="驗證-2稀疏-case-是否拉爆">驗證 2：「稀疏 case 是否拉爆」</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">// 用一個極少 match 的 query 觸發 B 策略
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=very_rare_keyword&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">click</span><span class="p">(</span><span class="s1">&#39;[data-scope=&#34;title&#34;]&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="kr">const</span> <span class="nx">startTime</span> <span class="o">=</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">5</span><span class="cl"><span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForSelector</span><span class="p">(</span><span class="s1">&#39;.scan-status&#39;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">timeout</span><span class="o">:</span> <span class="mi">10000</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">// 應該在 5s 內顯示「已掃完、共命中 K 個」、不該無限續抓
</span></span></span></code></pre></div><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="kr">const</span> <span class="nx">statusVisible</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.filter-status&#39;</span><span class="p">).</span><span class="nx">textContent</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// 應該明示 loading / partial / end / empty 之一、不只是 spinner
</span></span></span></code></pre></div><p>寫成 playwright test 固化 — 未來架構改動時 CI 立刻發現 regression（<a href="/blog/report/layout-tests-with-playwright/" data-link-title="用前端測試把排版問題自動化" data-link-desc="排版問題傳統靠人眼檢查、容易遺漏邊界 case。當一個版型被 debug 兩次以上、就值得寫成 playwright 測試把規範固定下來。本文展開測試替代手動檢查的時機。">#15 layout-tests-with-playwright</a>）。</p>
<hr>
<h2 id="設計決策的-checklist">設計決策的 checklist</h2>
<p>寫 filter 之前、跑這份 checklist：</p>
<ul>
<li><input disabled="" type="checkbox"> Source 是不是分批 / streaming / cached / lazy？（<a href="/blog/report/data-source-shape-defines-feature-shape/" data-link-title="資料源的形狀決定 feature 的形狀" data-link-desc="Feature 設計要服從資料源的形狀（一次性 / 分批 / streaming / cached）— 不能憑 UI 想要的形狀去倒推資料層。憑 UI 倒推 = 在錯誤的層解錯誤的問題、產生 #55 層錯位類 bug。">#63 資料源形狀</a>）</li>
<li><input disabled="" type="checkbox"> Filter 的定義域是已載入子集還是 source 全集？（使用者意圖三問、見 <a href="/blog/report/filter-instruction-clarification/" data-link-title="篩選類指令的澄清時機" data-link-desc="「依 X 篩選」這類指令必須先澄清三件事才能寫：定義域（已載入 / 全部 / 子集）、資料分批方式、空狀態的語意。三問跑完才寫、否則必然寫成視覺層 post-filter、撞上 #55 層錯位。">#58</a>）</li>
<li><input disabled="" type="checkbox"> Source 是否支援 server-side filter？（決定能不能用 A）</li>
<li><input disabled="" type="checkbox"> Match 密度可預期嗎？（決定 B 是否可行）</li>
<li><input disabled="" type="checkbox"> 三狀態（loading / empty / end）UX 怎麼區分？（<a href="/blog/report/loading-empty-end-state-distinction/" data-link-title="Loading / Empty / End 三狀態的區分" data-link-desc="「還沒抓」「沒命中」「抓完無更多」三個狀態語意不同、UX 必須區分。共用同個畫面（「空白」或 spinner）會讓使用者無法判斷下一步。本文展開三狀態的內在屬性與 UX 規則。">#57</a>）</li>
<li><input disabled="" type="checkbox"> 對於「filter 後 0 筆」的情境、使用者能否區分「沒命中」vs「還沒抓到」？</li>
</ul>
<hr>
<h2 id="wrong-vs-right-對照">Wrong vs Right 對照</h2>
<h3 id="範例-1搜尋頁-title-only-filter">範例 1：搜尋頁 title-only filter</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="c1">// pagefind 分批載入、view 層 post-filter
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><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="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</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">results</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">pagefind</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">input</span><span class="p">.</span><span class="nx">value</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nx">results</span><span class="p">.</span><span class="nx">results</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">10</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">render</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="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.scope-title&#39;</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="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">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#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"> 9</span><span class="cl">    <span class="kr">const</span> <span class="nx">title</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;.title&#39;</span><span class="p">).</span><span class="nx">textContent</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">el</span><span class="p">.</span><span class="nx">hidden</span> <span class="o">=</span> <span class="o">!</span><span class="nx">title</span><span class="p">.</span><span class="nx">includes</span><span class="p">(</span><span class="nx">query</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></code></pre></div><p>第二批 8 筆 title 不含 query → 全 hidden、使用者看到「load more 沒效果」。</p>
<p><strong>對</strong>（策略 C：多 index + 切換）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Build 階段</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">pagefind --site public --output-subdir _pagefind-all
</span></span><span class="line"><span class="ln">3</span><span class="cl">pagefind --site public --root-selector <span class="s2">&#34;article h1, article h2&#34;</span> --output-subdir _pagefind-title</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="kr">const</span> <span class="nx">indexes</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nx">all</span><span class="o">:</span> <span class="kr">await</span> <span class="kr">import</span><span class="p">(</span><span class="s1">&#39;/_pagefind-all/pagefind.js&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">title</span><span class="o">:</span> <span class="kr">await</span> <span class="kr">import</span><span class="p">(</span><span class="s1">&#39;/_pagefind-title/pagefind.js&#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></span><span class="line"><span class="ln"> 6</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="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="kr">const</span> <span class="nx">pf</span> <span class="o">=</span> <span class="nx">currentScope</span> <span class="o">===</span> <span class="s1">&#39;title&#39;</span> <span class="o">?</span> <span class="nx">indexes</span><span class="p">.</span><span class="nx">title</span> <span class="o">:</span> <span class="nx">indexes</span><span class="p">.</span><span class="nx">all</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="kr">const</span> <span class="nx">results</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">pf</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">input</span><span class="p">.</span><span class="nx">value</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="c1">// results 已是「該 scope 的全集」、無層錯位
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>  <span class="nx">results</span><span class="p">.</span><span class="nx">results</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">10</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">render</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p><strong>對</strong>（策略 D：誠實進度 UX、保留 view 層 filter）：</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;filter-status&#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">strong</span><span class="p">&gt;</span>24<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span> / <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>~150<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span> 筆 — 命中 <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>3<span class="p">&lt;/</span><span class="nt">strong</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">button</span><span class="p">&gt;</span>再掃一批<span class="p">&lt;/</span><span class="nt">button</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="p">&gt;</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="c1">// view 層 filter 保留、但 UI 顯示掃描範圍 + 提供續抓
</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">updateStatus</span><span class="p">()</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">all</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="kr">const</span> <span class="nx">visible</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result:not([hidden])&#39;</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">querySelector</span><span class="p">(</span><span class="s1">&#39;.scanned&#39;</span><span class="p">).</span><span class="nx">textContent</span> <span class="o">=</span> <span class="nx">all</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</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;.matched&#39;</span><span class="p">).</span><span class="nx">textContent</span> <span class="o">=</span> <span class="nx">visible</span><span class="p">.</span><span class="nx">length</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><h3 id="範例-2後端-api-filter">範例 2：後端 API filter</h3>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="nd">@app.route</span><span class="p">(</span><span class="s2">&#34;/posts&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">def</span> <span class="nf">list_posts</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">page</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">args</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;page&#39;</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">posts</span> <span class="o">=</span> <span class="n">Post</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">paginate</span><span class="p">(</span><span class="n">page</span><span class="o">=</span><span class="n">page</span><span class="p">,</span> <span class="n">per_page</span><span class="o">=</span><span class="mi">10</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="n">author</span> <span class="o">:=</span> <span class="n">request</span><span class="o">.</span><span class="n">args</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;author&#39;</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 class="n">p</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">posts</span><span class="o">.</span><span class="n">items</span> <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">author</span> <span class="o">==</span> <span class="n">author</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">return</span> <span class="n">posts</span><span class="o">.</span><span class="n">items</span></span></span></code></pre></div><p>中間的 list comprehension 在 pagination 之後 filter — 漏掉沒在這頁的符合項。</p>
<p><strong>對</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="nd">@app.route</span><span class="p">(</span><span class="s2">&#34;/posts&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">def</span> <span class="nf">list_posts</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">query</span> <span class="o">=</span> <span class="n">Post</span><span class="o">.</span><span class="n">objects</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">if</span> <span class="n">author</span> <span class="o">:=</span> <span class="n">request</span><span class="o">.</span><span class="n">args</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;author&#39;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="o">.</span><span class="n">filter_by</span><span class="p">(</span><span class="n">author</span><span class="o">=</span><span class="n">author</span><span class="p">)</span>  <span class="c1"># 推進 ORM</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="n">page</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">args</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;page&#39;</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">return</span> <span class="n">query</span><span class="o">.</span><span class="n">paginate</span><span class="p">(</span><span class="n">page</span><span class="o">=</span><span class="n">page</span><span class="p">,</span> <span class="n">per_page</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span><span class="o">.</span><span class="n">items</span></span></span></code></pre></div><p>Filter 在 query 層、pagination 在 filter 之後、無層錯位。</p>
<hr>
<h2 id="自檢清單dogfooding">自檢清單（dogfooding）</h2>
<p>寫 filter / sort / count / transform 前：</p>
<ul>
<li><input disabled="" type="checkbox"> 我有沒有問「這個操作的對象是哪一層的 stream」？</li>
<li><input disabled="" type="checkbox"> Source 是分批的嗎？是 → filter 必須同層或推進上游</li>
<li><input disabled="" type="checkbox"> 寫了 view 層 filter？檢查：稀疏 case 會不會 silent 失敗？</li>
<li><input disabled="" type="checkbox"> 用了 B（自動續抓）？有沒有 MAX_BATCHES + MAX_TIME_MS 上限保護？</li>
<li><input disabled="" type="checkbox"> UX 能否區分「載入中 / 沒命中 / 還沒抓到 / 抓完了」四狀態？</li>
<li><input disabled="" type="checkbox"> Playwright 驗證有沒有覆蓋「稀疏 case」「load more 後 visible 是否變」？</li>
</ul>
<hr>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>問題分析：</p>
<ul>
<li><a href="/blog/report/view-layer-filter-vs-source-layer/" data-link-title="Filter 與 Source 的抽象層錯位" data-link-desc="Filter 必須跟它過濾的資料源在同一層運作。視覺層的 filter 套在資料層分批產出的 source 上、會在「一筆」的定義上產生語意縫 — 使用者要的「全部符合」變成「目前載入的符合」、然後 silent 失敗。本文展開層錯位的識別與糾正。">#55 Filter 與 Source 的抽象層錯位</a> — 根因</li>
<li><a href="/blog/report/visual-completion-vs-functional-completion/" data-link-title="視覺完成 ≠ 功能完成" data-link-desc="「畫面對了」是視覺驗收訊號、不是功能驗收訊號。視覺完成早於功能完成、容易掩蓋語意缺口。本文展開兩者的區分與識別「畫面對但功能漏」的訊號。">#56 視覺完成 ≠ 功能完成</a> — 「畫面對」是低資訊量訊號</li>
<li><a href="/blog/report/loading-empty-end-state-distinction/" data-link-title="Loading / Empty / End 三狀態的區分" data-link-desc="「還沒抓」「沒命中」「抓完無更多」三個狀態語意不同、UX 必須區分。共用同個畫面（「空白」或 spinner）會讓使用者無法判斷下一步。本文展開三狀態的內在屬性與 UX 規則。">#57 Loading / Empty / End 三狀態的區分</a> — UX 落地</li>
</ul>
<p>指令澄清（在 requirement-protocol skill）：</p>
<ul>
<li><a href="/blog/report/filter-instruction-clarification/" data-link-title="篩選類指令的澄清時機" data-link-desc="「依 X 篩選」這類指令必須先澄清三件事才能寫：定義域（已載入 / 全部 / 子集）、資料分批方式、空狀態的語意。三問跑完才寫、否則必然寫成視覺層 post-filter、撞上 #55 層錯位。">#58 篩選類指令的澄清時機</a> — 三問模板</li>
</ul>
<p>解法策略：</p>
<ul>
<li><a href="/blog/report/filter-source-composition-strategies/" data-link-title="Filter × Source 的合成策略五選一" data-link-desc="Filter 跟 paginated / streaming source 合成的五種策略、各自機會成本不同：A 推進 query / B 自動續抓 / C 預先 index / D 誠實 UX / E 接受語意縮小。沒有絕對最佳、看 source capabilities、match 密度、UX 容忍度而定。">#59 Filter × Source 合成策略五選一</a> — 總覽</li>
<li><a href="/blog/report/pattern-fetch-until-quota/" data-link-title="Pattern：自動續抓直到湊滿 quota" data-link-desc="Pattern 卡片：分批 source &#43; post-filter 時、自動續抓直到湊滿 N 個 match。含上限保護、進度顯示、可中斷三個必要元件。對應 #59 策略 B 的具體實作。">#60-#62, #65-#66 五張 Pattern 卡片</a> — 各策略具體實作</li>
</ul>
<p>抽象原則：</p>
<ul>
<li><a href="/blog/report/data-source-shape-defines-feature-shape/" data-link-title="資料源的形狀決定 feature 的形狀" data-link-desc="Feature 設計要服從資料源的形狀（一次性 / 分批 / streaming / cached）— 不能憑 UI 想要的形狀去倒推資料層。憑 UI 倒推 = 在錯誤的層解錯誤的問題、產生 #55 層錯位類 bug。">#63 資料源的形狀決定 feature 的形狀</a> — 形狀是硬約束</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 Feature 操作要跟 Source 同層合成</a> — 跨領域通用原則</li>
<li><a href="/blog/report/ease-of-writing-vs-intent-alignment/" data-link-title="寫作便利度跟意圖對齊反相關" data-link-desc="寫程式時最容易寫出的版本、通常是離意圖最遠的版本。便利度建立在「現有上下文 / 已 materialize 資料 / 已存在 API」上、而意圖對齊需要找到正確的層、處理上游、跨抽象層 — 兩者方向相反。識別這個反相關 = 識別自己掉進「容易寫的陷阱」。">#67 寫作便利度跟意圖對齊反相關</a> — meta-principle</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> — 驗收策略</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.1.0</p>
]]></content:encoded></item><item><title>DOM Topology First — 寫 CSS 前先確認 DOM 結構</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/dom-topology-first/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/dom-topology-first/</guid><description>&lt;p>寫 CSS 規則之前、先讀真實 DOM tree — class name 是約定、不是結構保證。Selector 設計從最精準起步、有證據再放寬。&lt;/p>
&lt;p>適用：寫 / 改 CSS 規則、設計 JS query selector、判斷是否該改 layout 結構。
不適用：純邏輯演算法（沒有 DOM）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>自包含聲明&lt;/strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋 DOM 量測方法、selector 三維度設計、四種起點的取捨。&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 規則但只看過 class name、沒看過真實 DOM&lt;/td>
 &lt;td>playwright 量 ancestor chain&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Selector 命中超出預期的元素&lt;/td>
 &lt;td>把 selector 加上起點 + 範圍 + 過濾三維度&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>規則寫了但不生效&lt;/td>
 &lt;td>DevTools Computed → 看誰實際贏了&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Class name 含 &lt;code>__inner&lt;/code> &lt;code>__wrapper&lt;/code> 但不確定是直接子節點&lt;/td>
 &lt;td>playwright 讀 parent / child 關係&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>想用 &lt;code>document.querySelectorAll('.target')&lt;/code>&lt;/td>
 &lt;td>先評估「起點要不要從元件根」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼-dom-topology-要先確認">為什麼 DOM topology 要先確認&lt;/h2>
&lt;p>CSS 行為由「規則 + DOM tree + 樣式繼承 + 框架渲染」四個變數共同決定。&lt;strong>靜態推理只能基於假設的 DOM tree&lt;/strong> — 假設錯了、推理就錯。&lt;/p>
&lt;p>Class name 是命名約定 — &lt;code>pagefind-ui__drawer&lt;/code> 看起來像 &lt;code>.pagefind-ui&lt;/code> 的 child，但實際可能是 &lt;code>pagefind-ui__form&lt;/code> 的 child。命名告訴你「這是 drawer」、不告訴你「在哪一層」。&lt;/p>
&lt;p>跳過 DOM 確認的代價：寫了 N 條 CSS 規則、推理為什麼不生效、加 specificity / &lt;code>!important&lt;/code> / &lt;code>display: contents&lt;/code> — 全部基於錯假設。&lt;/p>
&lt;hr>
&lt;h2 id="量-dom-的最小-query">量 DOM 的最小 query&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="c1">// ancestor chain
&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="kr">async&lt;/span> &lt;span class="p">()&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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">el&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;.target&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">let&lt;/span> &lt;span class="nx">chain&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[];&lt;/span> &lt;span class="kd">let&lt;/span> &lt;span class="nx">n&lt;/span> &lt;span class="o">=&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"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">n&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">n&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">body&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="nx">chain&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">tagName&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">.&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&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">n&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">parentElement&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="k">return&lt;/span> &lt;span class="nx">chain&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>返回值告訴你目標元素在 DOM tree 哪個位置、parent / sibling 是誰。寫 CSS 規則前 30 秒能省掉後續 30 分鐘推理。&lt;/p>
&lt;hr>
&lt;h2 id="selector-設計三維度">Selector 設計三維度&lt;/h2>
&lt;p>精準的 selector = &lt;strong>起點 + 範圍 + 過濾&lt;/strong> 三維度顯式設計、不是「能命中就好」。&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>從哪個 DOM 節點開始 query&lt;/td>
 &lt;td>document / 元件根 / 函式參數 / closest()&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>範圍&lt;/td>
 &lt;td>要找直接子節點還是子孫&lt;/td>
 &lt;td>&lt;code>&amp;gt;&lt;/code> 直接子 / &lt;code>&amp;gt; ... &amp;gt; ...&lt;/code> 多層 / 空格 子孫&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>過濾&lt;/td>
 &lt;td>要排除哪些元素 / 已處理的&lt;/td>
 &lt;td>&lt;code>:not()&lt;/code> / &lt;code>[data-processed]&lt;/code> / WeakMap 檢查&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="起點四選一依情境">起點四選一（依情境）&lt;/h2>
&lt;h3 id="起點-adocument-全文件-query">起點 A：Document 全文件 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="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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>用&lt;/strong>：原型期、單例（整頁只一個）、跨元件邊界元素。
&lt;strong>不用&lt;/strong>：production 客製、可能多實例、效能敏感（大頁面）。&lt;/p></description><content:encoded><![CDATA[<p>寫 CSS 規則之前、先讀真實 DOM tree — class name 是約定、不是結構保證。Selector 設計從最精準起步、有證據再放寬。</p>
<p>適用：寫 / 改 CSS 規則、設計 JS query selector、判斷是否該改 layout 結構。
不適用：純邏輯演算法（沒有 DOM）。</p>
<blockquote>
<p><strong>自包含聲明</strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋 DOM 量測方法、selector 三維度設計、四種起點的取捨。</p></blockquote>
<hr>
<h2 id="何時參閱本文件">何時參閱本文件</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的第一件事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即將寫 CSS 規則但只看過 class name、沒看過真實 DOM</td>
          <td>playwright 量 ancestor chain</td>
      </tr>
      <tr>
          <td>Selector 命中超出預期的元素</td>
          <td>把 selector 加上起點 + 範圍 + 過濾三維度</td>
      </tr>
      <tr>
          <td>規則寫了但不生效</td>
          <td>DevTools Computed → 看誰實際贏了</td>
      </tr>
      <tr>
          <td>Class name 含 <code>__inner</code> <code>__wrapper</code> 但不確定是直接子節點</td>
          <td>playwright 讀 parent / child 關係</td>
      </tr>
      <tr>
          <td>想用 <code>document.querySelectorAll('.target')</code></td>
          <td>先評估「起點要不要從元件根」</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼-dom-topology-要先確認">為什麼 DOM topology 要先確認</h2>
<p>CSS 行為由「規則 + DOM tree + 樣式繼承 + 框架渲染」四個變數共同決定。<strong>靜態推理只能基於假設的 DOM tree</strong> — 假設錯了、推理就錯。</p>
<p>Class name 是命名約定 — <code>pagefind-ui__drawer</code> 看起來像 <code>.pagefind-ui</code> 的 child，但實際可能是 <code>pagefind-ui__form</code> 的 child。命名告訴你「這是 drawer」、不告訴你「在哪一層」。</p>
<p>跳過 DOM 確認的代價：寫了 N 條 CSS 規則、推理為什麼不生效、加 specificity / <code>!important</code> / <code>display: contents</code> — 全部基於錯假設。</p>
<hr>
<h2 id="量-dom-的最小-query">量 DOM 的最小 query</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">// ancestor chain
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</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">el</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;.target&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">let</span> <span class="nx">chain</span> <span class="o">=</span> <span class="p">[];</span> <span class="kd">let</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">el</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="k">while</span> <span class="p">(</span><span class="nx">n</span> <span class="o">&amp;&amp;</span> <span class="nx">n</span> <span class="o">!==</span> <span class="nb">document</span><span class="p">.</span><span class="nx">body</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">chain</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">tagName</span><span class="si">}</span><span class="sb">.</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">className</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">n</span> <span class="o">=</span> <span class="nx">n</span><span class="p">.</span><span class="nx">parentElement</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="k">return</span> <span class="nx">chain</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>返回值告訴你目標元素在 DOM tree 哪個位置、parent / sibling 是誰。寫 CSS 規則前 30 秒能省掉後續 30 分鐘推理。</p>
<hr>
<h2 id="selector-設計三維度">Selector 設計三維度</h2>
<p>精準的 selector = <strong>起點 + 範圍 + 過濾</strong> 三維度顯式設計、不是「能命中就好」。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>問題</th>
          <th>答案類型</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>起點</td>
          <td>從哪個 DOM 節點開始 query</td>
          <td>document / 元件根 / 函式參數 / closest()</td>
      </tr>
      <tr>
          <td>範圍</td>
          <td>要找直接子節點還是子孫</td>
          <td><code>&gt;</code> 直接子 / <code>&gt; ... &gt; ...</code> 多層 / 空格 子孫</td>
      </tr>
      <tr>
          <td>過濾</td>
          <td>要排除哪些元素 / 已處理的</td>
          <td><code>:not()</code> / <code>[data-processed]</code> / WeakMap 檢查</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="起點四選一依情境">起點四選一（依情境）</h2>
<h3 id="起點-adocument-全文件-query">起點 A：Document 全文件 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="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></code></pre></div><p><strong>用</strong>：原型期、單例（整頁只一個）、跨元件邊界元素。
<strong>不用</strong>：production 客製、可能多實例、效能敏感（大頁面）。</p>
<h3 id="起點-b元件根變數-query">起點 B：元件根變數 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="kr">const</span> <span class="nx">root</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&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">root</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 class="c1">// 從 root 起
</span></span></span></code></pre></div><p><strong>用</strong>：production 客製、客製只該影響該元件、避免命中其他頁面同名元素。
<strong>不用</strong>：跨多元件邊界的 query。</p>
<h3 id="起點-c起點當函式參數">起點 C：起點當函式參數</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">decorate</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="k">return</span> <span class="nx">root</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">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>用</strong>：library / utility function、需要支援多實例、純函式設計。
<strong>不用</strong>：一次性腳本（多餘的抽象）。</p>
<h3 id="起點-dclosest-反向找根">起點 D：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="nx">button</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">e</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="kr">const</span> <span class="nx">card</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;.result-card&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">card</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;expanded&#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><strong>用</strong>：動態 / 多實例元件、event delegation、不知道事件源在哪一層。
<strong>不用</strong>：靜態起點已知（用 B 或 C 更直接）。</p>
<hr>
<h2 id="範圍-還是空格">範圍：<code>&gt;</code> 還是空格</h2>
<table>
  <thead>
      <tr>
          <th>寫法</th>
          <th>意思</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>.parent &gt; .child</code></td>
          <td>直接子節點</td>
          <td>安全、嚴格</td>
      </tr>
      <tr>
          <td><code>.parent .child</code></td>
          <td>任意深度子孫</td>
          <td>命中 nested 結構的同類元素</td>
      </tr>
      <tr>
          <td><code>.parent &gt; * &gt; .x</code></td>
          <td>確切兩層</td>
          <td>嚴格、結構變動時要更新</td>
      </tr>
      <tr>
          <td><code>.parent .x:not(.y)</code></td>
          <td>子孫中排除某類</td>
          <td>還是子孫範圍、:not 是過濾不是限制範圍</td>
      </tr>
  </tbody>
</table>
<p>預設 <code>&gt;</code>、有證據（多層 nested 結構都該 match）才放寬到空格。</p>
<hr>
<h2 id="過濾idempotency-標記">過濾：idempotency 標記</h2>
<p>JS 處理元素時、避免重複處理。兩種做法：</p>
<h3 id="adom-attribute-標記">A：DOM 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">decorate</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="kr">const</span> <span class="nx">targets</span> <span class="o">=</span> <span class="nx">root</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.target:not([data-decorated])&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">targets</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">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">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-decorated&#39;</span><span class="p">,</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="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>：production 預設、devtools 可見、跨 page reload 也保留（如果元素持久）。
<strong>不用</strong>：library 設計（不該污染使用者 DOM）。</p>
<h3 id="bweakmap-紀錄">B：WeakMap 紀錄</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="kr">const</span> <span class="nx">decorated</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">function</span> <span class="nx">decorate</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">3</span><span class="cl">  <span class="nx">root</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 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">4</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">decorated</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">decorated</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><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>用</strong>：library 設計、不污染 DOM、元素 GC 後紀錄自動清。
<strong>不用</strong>：跨頁面、需要 devtools debug、需要 CSS selector 過濾（CSS 看不到 WeakMap）。</p>
<hr>
<h2 id="wrong-vs-right-對照">Wrong vs Right 對照</h2>
<h3 id="範例-1寫-css-前沒看-dom">範例 1：寫 CSS 前沒看 DOM</h3>
<blockquote>
<p>任務：把 <code>pagefind-ui__drawer</code> 排到 <code>pagefind-ui__form</code> 下方</p></blockquote>
<p><strong>錯</strong>（基於 class 命名假設）：</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">pagefind-ui</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="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">pagefind-ui__form</span> <span class="p">{</span> <span class="k">grid-row</span><span class="p">:</span> <span class="mi">1</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">pagefind-ui__drawer</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></code></pre></div><p>跑出來 drawer 跑到頁尾、grid-row 完全沒生效。</p>
<p><strong>對</strong>（先量 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="kr">async</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="kr">const</span> <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">3</span><span class="cl">  <span class="kd">let</span> <span class="nx">chain</span> <span class="o">=</span> <span class="p">[];</span> <span class="kd">let</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">drawer</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">while</span> <span class="p">(</span><span class="nx">n</span> <span class="o">&amp;&amp;</span> <span class="nx">n</span> <span class="o">!==</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">chain</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">tagName</span><span class="si">}</span><span class="sb">.</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">className</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">n</span><span class="p">.</span><span class="nx">parentElement</span><span class="p">;</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="nx">chain</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">// 返回：[DIV.pagefind-ui__drawer, FORM.pagefind-ui__form, DIV.pagefind-ui]
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">// → drawer 是 form 的 child、不是 sibling
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">// → grid-row 在 .pagefind-ui 上設、無法控制 form 的 child
</span></span></span></code></pre></div><p>→ 換方向：drawer 改 absolute、form 加 margin-bottom 留 spacer。</p>
<h3 id="範例-2selector-過寬命中無關元素">範例 2：selector 過寬命中無關元素</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">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.title&#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="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;search-title&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// 命中 page header 的 .title、navbar 的 .title、結果卡的 .title — 全變色
</span></span></span></code></pre></div><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="kr">const</span> <span class="nx">root</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&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">root</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;:scope &gt; .results &gt; .result &gt; .title&#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="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;search-title&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">// 起點 = .pagefind-ui、範圍 = 確切三層、過濾 = 不需要（已精準）
</span></span></span></code></pre></div><hr>
<h2 id="自檢清單dogfooding">自檢清單（dogfooding）</h2>
<p>寫 CSS 規則或 JS query 前：</p>
<ul>
<li><input disabled="" type="checkbox"> 我有沒有量過真實 DOM tree（playwright <code>browser_evaluate</code> 或 DevTools）？</li>
<li><input disabled="" type="checkbox"> Selector 的「起點」明確嗎？是 document / 元件根 / 函式參數 / closest 哪一個？</li>
<li><input disabled="" type="checkbox"> Selector 的「範圍」明確嗎？是 <code>&gt;</code> 直接子還是空格子孫？</li>
<li><input disabled="" type="checkbox"> Selector 的「過濾」明確嗎？需要 idempotency 標記嗎？</li>
<li><input disabled="" type="checkbox"> 過寬的 selector（<code>document.querySelectorAll('*')</code>、<code>[class*=&quot;x&quot;]</code>）能不能換成更精準的？</li>
</ul>
<p>任一項打勾失敗 → 補上、再寫規則。</p>
<hr>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>對應的事後檢討（在 <code>content/report/</code>）：</p>
<ul>
<li><a href="/blog/report/dom-topology-before-css/" data-link-title="拓樸理解先行於 CSS 規則" data-link-desc="寫 CSS 之前看真實 DOM tree、不靠 class name 推測層級。本文以『drawer 在 form 內、不是 form 的 sibling』這個假設錯誤為例，展開『拓樸理解 → CSS 規則』的順序。">dom-topology-before-css</a> — 拓樸理解先行於 CSS 規則</li>
<li><a href="/blog/report/dom-selector-precision/" data-link-title="Selector 精準度：讓 query 只命中你想要的元素" data-link-desc="JS 的 DOM query 是 sanity 防線、不是優化選項。從『起點 / 範圍 / 過濾』三層收斂、避免誤命中、避免未來頁面結構變動讓 query 撈到不該撈的東西。本文是 selector 設計的完整指引。">dom-selector-precision</a> — Selector 精準度三維度</li>
<li><a href="/blog/report/pattern-document-query/" data-link-title="Pattern：Document 全文件 query" data-link-desc="`document.querySelector` 從整個頁面找元素 — 是探索期與一次性 script 的合理工具、不是 production 客製的預設。本文展開這個 pattern 的適用邊界。">pattern-document-query</a> / <a href="/blog/report/pattern-component-root/" data-link-title="Pattern：元件根變數 query" data-link-desc="把元件根 `var shell = document.querySelector(&#39;.shell&#39;)` 一次存變數、之後所有 query 從 shell 開始 — 是 production 客製的預設起點。本文展開這個 pattern 的設計細節與邊界。">pattern-component-root</a> / <a href="/blog/report/pattern-root-as-parameter/" data-link-title="Pattern：起點當函式參數" data-link-desc="把元件根當函式參數傳入 — `function setup(shell) { shell.querySelector(...) }`、外部呼叫 `forEach(setup)` 處理多實例。本文展開純函式設計與多實例支援的取捨。">pattern-root-as-parameter</a> / <a href="/blog/report/pattern-closest-lookup/" data-link-title="Pattern：closest 反向找根" data-link-desc="事件處理時用 `e.target.closest(&#39;.shell&#39;)` 從事件目標反向找元件根 — 適合動態元件、SPA 路由切換、事件委派場景。本文展開反向定位 pattern 的應用邊界。">pattern-closest-lookup</a> — 起點四選一 pattern 卡片</li>
<li><a href="/blog/report/pattern-attribute-idempotency-marker/" data-link-title="Pattern：DOM attribute idempotency 標記" data-link-desc="用 `:not([data-x])` 過濾 &#43; 處理後 `setAttribute(&#39;data-x&#39;, &#39;true&#39;)` 保證每元素只處理一次 — 是 production apply 函式的預設 idempotency 工具。本文展開命名、生命週期、跟 framework 共處的設計細節。">pattern-attribute-idempotency-marker</a> / <a href="/blog/report/pattern-weakmap-idempotency-record/" data-link-title="Pattern：WeakMap idempotency 紀錄" data-link-desc="用 `WeakMap` 紀錄已處理的元素 — 不污染 DOM、適合第三方 library、跟 framework 衝突場景。本文展開 GC 行為、debug 替代方案、跟 attribute 標記的取捨。">pattern-weakmap-idempotency-record</a> — Idempotency 兩選一</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.1.0</p>
]]></content:encoded></item><item><title>Framework Coexistence — 跟 framework-managed DOM 共處</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/framework-coexistence/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/framework-coexistence/</guid><description>&lt;p>跟 framework-managed DOM 共處：把 framework 子樹當禁區、客製 UI 注入到 boundary 外、JS 操作邊界由穩定性梯度決定、外部組件客製優先用公共介面。&lt;/p>
&lt;p>適用：跟 vendor library / framework component（pagefind、Vue widget、React component、jQuery plugin）共存的客製、注入客製 UI、覆寫 vendor 樣式。
不適用：完全自家寫的元件（沒有 framework 介入）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>自包含聲明&lt;/strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋 framework 邊界辨識、JS 操作的四級安全度、外部組件客製的四層合作。&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>客製 UI 注入到 framework 子樹後被還原&lt;/td>
 &lt;td>移到 framework 邊界外、用 CSS 控制視覺位置&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Vendor library 升級後客製樣式失效&lt;/td>
 &lt;td>改用公共介面（CSS var / API）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定 reparent / 改 attribute / 改 textContent 哪個安全&lt;/td>
 &lt;td>看下方「JS 操作四級安全度」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>客製需求看似簡單但要動 framework 內部結構&lt;/td>
 &lt;td>評估「值不值得」、把成本攤開（見 cost report）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>寫了 MutationObserver 補 framework reconcile 後元素還原&lt;/td>
 &lt;td>換思路：注入到邊界外、不需要 observer&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼-framework-managed-dom-要當禁區">為什麼 framework-managed DOM 要當禁區&lt;/h2>
&lt;p>Framework（React / Vue / vendor JS widget）對它管的 DOM 子樹有&lt;strong>所有權&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>State 變動觸發 reconciliation、子樹重建&lt;/li>
&lt;li>我們改的 attribute / textContent / 子節點被還原&lt;/li>
&lt;li>innerHTML 改動可能觸發 Vue / React 的 dev mode 警告&lt;/li>
&lt;li>Event listener 失效（節點被替換）&lt;/li>
&lt;/ul>
&lt;p>把客製 UI &lt;strong>注入到 framework 邊界外&lt;/strong>（vendor root 的 sibling、或上一層 container 的另一個 child）→ framework 不管它 → 不會被還原。&lt;/p>
&lt;hr>
&lt;h2 id="framework-邊界的識別">Framework 邊界的識別&lt;/h2>
&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>&amp;lt;div id=&amp;quot;app&amp;quot;&amp;gt;&lt;/code> / &lt;code>data-vue-component&lt;/code>&lt;/td>
 &lt;td>Vue / 自家 framework 的 root&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>&amp;lt;div data-reactroot&amp;gt;&lt;/code> / React Fiber 結構&lt;/td>
 &lt;td>React 的 root&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>.pagefind-ui&lt;/code> / &lt;code>.algolia-search&lt;/code> 等命名空間&lt;/td>
 &lt;td>Vendor library 的 root&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>子節點 attribute 含 &lt;code>__data&lt;/code> &lt;code>__key&lt;/code> 等內部標記&lt;/td>
 &lt;td>Framework 內部結構、子節點被管理&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>邊界外的 sibling / parent 通常是「自家 HTML」、安全。&lt;/p>
&lt;h3 id="範例pagefind-的邊界">範例：Pagefind 的邊界&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="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-page&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>Search&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;custom-filter&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span> ← 自家、客製 UI 放這裡
&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="p">&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="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> ← Vendor root (邊界、入內就是禁區)
&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">form&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;pagefind-ui__form&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span> ← Pagefind 管
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">input&lt;/span> &lt;span class="err">...&lt;/span>&lt;span class="p">&amp;gt;&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">&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__drawer&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span> ← Pagefind 管（重渲染時清空）
&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">div&lt;/span>&lt;span class="p">&amp;gt;&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">&amp;lt;/&lt;/span>&lt;span class="nt">form&lt;/span>&lt;span class="p">&amp;gt;&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">&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">12&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;p>&lt;code>.custom-filter&lt;/code> 跟 &lt;code>.pagefind-ui&lt;/code> 是 sibling、不在 vendor 子樹內 → 用 CSS grid / absolute 定位讓它看起來在 search 流程內、但實際 framework 不管它。&lt;/p></description><content:encoded><![CDATA[<p>跟 framework-managed DOM 共處：把 framework 子樹當禁區、客製 UI 注入到 boundary 外、JS 操作邊界由穩定性梯度決定、外部組件客製優先用公共介面。</p>
<p>適用：跟 vendor library / framework component（pagefind、Vue widget、React component、jQuery plugin）共存的客製、注入客製 UI、覆寫 vendor 樣式。
不適用：完全自家寫的元件（沒有 framework 介入）。</p>
<blockquote>
<p><strong>自包含聲明</strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋 framework 邊界辨識、JS 操作的四級安全度、外部組件客製的四層合作。</p></blockquote>
<hr>
<h2 id="何時參閱本文件">何時參閱本文件</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的第一件事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>客製 UI 注入到 framework 子樹後被還原</td>
          <td>移到 framework 邊界外、用 CSS 控制視覺位置</td>
      </tr>
      <tr>
          <td>Vendor library 升級後客製樣式失效</td>
          <td>改用公共介面（CSS var / API）</td>
      </tr>
      <tr>
          <td>不確定 reparent / 改 attribute / 改 textContent 哪個安全</td>
          <td>看下方「JS 操作四級安全度」</td>
      </tr>
      <tr>
          <td>客製需求看似簡單但要動 framework 內部結構</td>
          <td>評估「值不值得」、把成本攤開（見 cost report）</td>
      </tr>
      <tr>
          <td>寫了 MutationObserver 補 framework reconcile 後元素還原</td>
          <td>換思路：注入到邊界外、不需要 observer</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼-framework-managed-dom-要當禁區">為什麼 framework-managed DOM 要當禁區</h2>
<p>Framework（React / Vue / vendor JS widget）對它管的 DOM 子樹有<strong>所有權</strong>：</p>
<ul>
<li>State 變動觸發 reconciliation、子樹重建</li>
<li>我們改的 attribute / textContent / 子節點被還原</li>
<li>innerHTML 改動可能觸發 Vue / React 的 dev mode 警告</li>
<li>Event listener 失效（節點被替換）</li>
</ul>
<p>把客製 UI <strong>注入到 framework 邊界外</strong>（vendor root 的 sibling、或上一層 container 的另一個 child）→ framework 不管它 → 不會被還原。</p>
<hr>
<h2 id="framework-邊界的識別">Framework 邊界的識別</h2>
<h3 id="邊界的可見訊號">邊界的可見訊號</h3>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>含義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>&lt;div id=&quot;app&quot;&gt;</code> / <code>data-vue-component</code></td>
          <td>Vue / 自家 framework 的 root</td>
      </tr>
      <tr>
          <td><code>&lt;div data-reactroot&gt;</code> / React Fiber 結構</td>
          <td>React 的 root</td>
      </tr>
      <tr>
          <td><code>.pagefind-ui</code> / <code>.algolia-search</code> 等命名空間</td>
          <td>Vendor library 的 root</td>
      </tr>
      <tr>
          <td>子節點 attribute 含 <code>__data</code> <code>__key</code> 等內部標記</td>
          <td>Framework 內部結構、子節點被管理</td>
      </tr>
  </tbody>
</table>
<p>邊界外的 sibling / parent 通常是「自家 HTML」、安全。</p>
<h3 id="範例pagefind-的邊界">範例：Pagefind 的邊界</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;search-page&#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>Search<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;custom-filter&#34;</span><span class="p">&gt;</span>       ← 自家、客製 UI 放這裡
</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="p">&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="na">class</span><span class="o">=</span><span class="s">&#34;pagefind-ui&#34;</span><span class="p">&gt;</span>          ← Vendor root (邊界、入內就是禁區)
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">&lt;</span><span class="nt">form</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;pagefind-ui__form&#34;</span><span class="p">&gt;</span> ← Pagefind 管
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="p">&lt;</span><span class="nt">input</span> <span class="err">...</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 8</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__drawer&#34;</span><span class="p">&gt;</span>  ← Pagefind 管（重渲染時清空）
</span></span><span class="line"><span class="ln"> 9</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">10</span><span class="cl">    <span class="p">&lt;/</span><span class="nt">form</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">11</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">12</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div><p><code>.custom-filter</code> 跟 <code>.pagefind-ui</code> 是 sibling、不在 vendor 子樹內 → 用 CSS grid / absolute 定位讓它看起來在 search 流程內、但實際 framework 不管它。</p>
<hr>
<h2 id="js-操作的四級安全度">JS 操作的四級安全度</h2>
<p>對 framework-managed 元素的操作、按穩定性排序：</p>
<table>
  <thead>
      <tr>
          <th>操作</th>
          <th>安全度</th>
          <th>為什麼</th>
          <th>補救</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Reparent 整節點</td>
          <td>高</td>
          <td>整節點搬遷、framework 通常不會還原</td>
          <td>-</td>
      </tr>
      <tr>
          <td>改 inline style</td>
          <td>中-高</td>
          <td>Style 通常不被 reconcile（除非 framework 重設）</td>
          <td>用 CSS class 取代</td>
      </tr>
      <tr>
          <td>改 attribute</td>
          <td>中</td>
          <td>部分 framework 會 reconcile attribute</td>
          <td>用 MutationObserver 補回</td>
      </tr>
      <tr>
          <td>改 textContent</td>
          <td>中-低</td>
          <td>多數 framework 會 reconcile text</td>
          <td>改注入新節點到邊界外</td>
      </tr>
      <tr>
          <td>改 innerHTML</td>
          <td>低</td>
          <td>子節點全重建、event listener 失效</td>
          <td>不要改、用其他方法</td>
      </tr>
      <tr>
          <td>改 framework 子節點</td>
          <td>極低</td>
          <td>reconcile 還原、可能 dev warning</td>
          <td>不要動</td>
      </tr>
  </tbody>
</table>
<p>選擇規則：<strong>從最高安全度起步、不行才升級</strong>。</p>
<hr>
<h2 id="客製-ui-注入的兩種模式">客製 UI 注入的兩種模式</h2>
<h3 id="模式-1注入到-framework-邊界外推薦">模式 1：注入到 framework 邊界外（推薦）</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="kr">const</span> <span class="nx">customEl</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">createElement</span><span class="p">(</span><span class="s1">&#39;div&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">customEl</span><span class="p">.</span><span class="nx">className</span> <span class="o">=</span> <span class="s1">&#39;custom-filter&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">customEl</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="s1">&#39;Filter: All / Title / Content&#39;</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">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-page&#39;</span><span class="p">).</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">customEl</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">// 注意：appendChild 到 .search-page、不是 .pagefind-ui
</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-page</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</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="s2">&#34;h1&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="s2">&#34;form&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="s2">&#34;custom-filter&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="s2">&#34;results&#34;</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 class="nc">pagefind-ui</span> <span class="p">{</span> <span class="k">grid-area</span><span class="p">:</span> <span class="n">form</span> <span class="o">/</span> <span class="n">form</span> <span class="o">/</span> <span class="n">results</span> <span class="o">/</span> <span class="n">results</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">.</span><span class="nc">custom-filter</span> <span class="p">{</span> <span class="k">grid-area</span><span class="p">:</span> <span class="n">custom-filter</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>CSS grid 把客製 UI 排到 search 流程的某個位置、framework 不知情、不還原。</p>
<h3 id="模式-2reparent-framework-內節點次優">模式 2：reparent framework 內節點（次優）</h3>
<p>如果客製需要把 framework 內的某個元素移到別處 — reparent 整節點而不是改內部：</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">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__filters&#39;</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">target</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;.sidebar&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">target</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">filter</span><span class="p">);</span>  <span class="c1">// 整節點搬到 sidebar、不複製
</span></span></span></code></pre></div><p>整節點搬遷通常 framework 不會「還原」、因為 vDOM diff 看到 node 還在（只是 parent 變了）。</p>
<p>但有 case 例外（部分 framework 用 portal pattern、reparent 會被視為 unmount）→ 第 1 次嘗試後用 playwright 驗證行為、第 2 次失敗就停（見 requirement-protocol/failure-pivot-protocol）。</p>
<hr>
<h2 id="外部組件客製的四層合作穩定性梯度">外部組件客製的四層合作（穩定性梯度）</h2>
<p>跟外部組件合作時、選哪一層客製、決定升級時會不會壞。</p>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>範例</th>
          <th>升級穩定性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>公共介面層</td>
          <td>CLI 參數、CSS variable、option 物件</td>
          <td>最高</td>
      </tr>
      <tr>
          <td>邊界層</td>
          <td>注入 root 的 sibling、用 CSS 包邊界外</td>
          <td>高</td>
      </tr>
      <tr>
          <td>邊界 DOM 層</td>
          <td>querySelector vendor 的 root 節點</td>
          <td>中</td>
      </tr>
      <tr>
          <td>內部結構層</td>
          <td>改 vendor 子節點 attribute / 樣式</td>
          <td>最低</td>
      </tr>
  </tbody>
</table>
<p><strong>選擇順序</strong>：先看公共介面有沒有提供（讀 docs）、沒有再用邊界層、再不行才碰邊界 DOM。內部結構層幾乎不要碰 — 升級時 minor version 都會壞。</p>
<h3 id="範例pagefind-的客製優先順序">範例：Pagefind 的客製優先順序</h3>
<table>
  <thead>
      <tr>
          <th>需求</th>
          <th>優先做法</th>
          <th>次選</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>改主題色</td>
          <td>公共：<code>--pagefind-ui-primary</code> CSS var</td>
          <td>邊界 DOM：覆寫 <code>.pagefind-ui__form</code></td>
      </tr>
      <tr>
          <td>加 filter UI</td>
          <td>邊界：在 <code>.pagefind-ui</code> sibling 注入</td>
          <td>內部：塞進 <code>.pagefind-ui__form</code> 內</td>
      </tr>
      <tr>
          <td>限定 search scope</td>
          <td>公共：<code>pagefindOptions.scope: 'main'</code></td>
          <td>內部：MutationObserver 過濾結果</td>
      </tr>
      <tr>
          <td>改 result 卡片排版</td>
          <td>邊界 DOM：覆寫 <code>.pagefind-ui__result</code> CSS（接受升級時可能要重檢）</td>
          <td>-</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="wrong-vs-right-對照">Wrong vs Right 對照</h2>
<h3 id="範例-1客製-filter-ui">範例 1：客製 filter UI</h3>
<p><strong>錯</strong>（注入到 vendor 子樹內）：</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">filter</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">createElement</span><span class="p">(</span><span class="s1">&#39;div&#39;</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="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="s1">&#39;Filter: ...&#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">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__form&#39;</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="c1">// → search 觸發 → form 重渲染 → filter 消失
</span></span></span></code></pre></div><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="kr">const</span> <span class="nx">filter</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">createElement</span><span class="p">(</span><span class="s1">&#39;div&#39;</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="p">.</span><span class="nx">className</span> <span class="o">=</span> <span class="s1">&#39;custom-filter&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">filter</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="s1">&#39;Filter: ...&#39;</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">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-page&#39;</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></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-page</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="k">grid</span><span class="p">;</span> <span class="k">grid-template-areas</span><span class="p">:</span> <span class="s2">&#34;h1&#34;</span> <span class="s2">&#34;form&#34;</span> <span class="s2">&#34;filter&#34;</span> <span class="s2">&#34;results&#34;</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">pagefind-ui</span> <span class="p">{</span> <span class="k">grid-area</span><span class="p">:</span> <span class="n">form</span> <span class="o">/</span> <span class="n">form</span> <span class="o">/</span> <span class="n">results</span> <span class="o">/</span> <span class="n">results</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-filter</span> <span class="p">{</span> <span class="k">grid-area</span><span class="p">:</span> <span class="k">filter</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><h3 id="範例-2改-vendor-主題色">範例 2：改 vendor 主題色</h3>
<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">pagefind-ui__form</span> <span class="p">{</span> <span class="k">background</span><span class="p">:</span> <span class="kc">blue</span> <span class="cp">!important</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">pagefind-ui__search-input</span> <span class="p">{</span> <span class="k">color</span><span class="p">:</span> <span class="kc">white</span> <span class="cp">!important</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">pagefind-ui__button</span> <span class="p">{</span> <span class="k">background</span><span class="p">:</span> <span class="kc">darkblue</span> <span class="cp">!important</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">/* ... 8 條 important */</span></span></span></code></pre></div><p>升級後 class 改名 → 全壞。</p>
<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="nd">root</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nv">--pagefind-ui-primary</span><span class="p">:</span> <span class="mh">#2c5282</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nv">--pagefind-ui-text</span><span class="p">:</span> <span class="mh">#fff</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nv">--pagefind-ui-background</span><span class="p">:</span> <span class="mh">#1a202c</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>讀 vendor docs、用提供的 CSS var。升級安全、5 行解決。</p>
<h3 id="範例-3把-vendor-filter-移到-sidebar">範例 3：把 vendor filter 移到 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="kr">const</span> <span class="nx">original</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__filters&#39;</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">clone</span> <span class="o">=</span> <span class="nx">original</span><span class="p">.</span><span class="nx">cloneNode</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 class="nx">sidebar</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">clone</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// → 兩份、state 不同步、click 事件 listener 沒複製
</span></span></span></code></pre></div><p><strong>對</strong>（reparent 整節點）：</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">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__filters&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><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 class="c1">// 整節點搬遷、event listener 跟著、state 唯一
</span></span></span></code></pre></div><p>整節點搬遷通常安全 — vDOM 看到 node 還在、不會 reconcile。寫完先用 playwright 驗證行為（dispatch input / click 看 filter 是否還工作）。</p>
<hr>
<h2 id="自檢清單dogfooding">自檢清單（dogfooding）</h2>
<p>跟 framework / vendor library 共處時：</p>
<ul>
<li><input disabled="" type="checkbox"> 我有沒有先看 vendor docs、確認有沒有公共介面（CSS var / API）？</li>
<li><input disabled="" type="checkbox"> 客製 UI 是注入到 framework 邊界外、還是內部？</li>
<li><input disabled="" type="checkbox"> JS 操作的元素是 framework 管的子節點嗎？如果是、有沒有用「四級安全度」最高的操作？</li>
<li><input disabled="" type="checkbox"> reparent / 改 attribute 後、有沒有用 playwright 驗證 framework 沒還原？</li>
<li><input disabled="" type="checkbox"> 升級風險有攤給使用者嗎？（見 requirement-protocol/cost-and-checkpoint）</li>
</ul>
<hr>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>對應的事後檢討（在 <code>content/report/</code>）：</p>
<ul>
<li><a href="/blog/report/coexisting-with-framework-managed-dom/" data-link-title="客製 UI 留 framework 邊界外、用 CSS 控制視覺位置" data-link-desc="Svelte / React 等框架對自己管轄的 DOM 子樹有完整渲染週期 — 客製 UI 注入這個子樹會被框架重繪清掉。本文展開「邊界外 &#43; CSS 視覺定位」這條策略：為什麼 framework 會清外來節點、CSS 怎麼達到注入想要的視覺效果、什麼時候這條策略不適用。">coexisting-with-framework-managed-dom</a> — 客製 UI 留 framework 邊界外、用 CSS 控制視覺位置</li>
<li><a href="/blog/report/component-boundary-and-js-impact/" data-link-title="JS 操作 framework 元件：邊界辨識與安全規則" data-link-desc="操作 framework 管的 DOM 前先界定『動什麼、邊界在哪、state 由誰維護』。本文展開邊界辨識的決策樹、整節點 reparent 的具體 do/don&#39;t 規則、灰區操作的 fail-safe 設計。">component-boundary-and-js-impact</a> — JS 操作 framework 元件的邊界辨識</li>
<li><a href="/blog/report/external-component-customization/" data-link-title="在外部組件上加客製功能：以邊界為中心的方法選擇" data-link-desc="客製外部組件穩定的程度取決於『離組件邊界多近』。本文用 Pagefind 整合到 Hugo theme 的三個情境（索引邊界、重置邊界、specificity 邊界）展開：在邊界上客製為什麼穩、各種替代方案的不足、以及下次提早辨識的訊號。">external-component-customization</a> — 在外部組件上加客製功能：以邊界為中心</li>
<li><a href="/blog/report/external-component-collaboration-layers/" data-link-title="跟外部組件合作的層次：離介面越近、合作越穩" data-link-desc="客製外部組件的穩定性與「離組件作者保證的對外介面多遠」成反比。每往內推一層、依賴前提增加、升級風險上升、可逆性下降。本文是 #1 / #5 / #19 / #24 四篇實作的共同抽象。">external-component-collaboration-layers</a> — 跟外部組件合作的四層次</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>Playwright in the Development Loop — 開發循環的三個位置</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/playwright-in-loop/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/playwright-in-loop/</guid><description>&lt;p>Playwright 在前端開發循環的三個位置：假設驗證（寫 CSS 前）、行為驗證（規則寫完後）、互動驗證（dispatch event 後）。第 2 次同個版型 bug 出現 → 寫成測試固化。&lt;/p>
&lt;p>適用：CSS / DOM debug、layout 驗收、互動行為驗證、寫 layout regression test。
不適用：純 unit test（function input/output、無 DOM）— 那用 Vitest / Jest 即可。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>自包含聲明&lt;/strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋三個位置的具體 query 範例、layout test 模板、最低門檻 setup。&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 規則、想先確認 DOM 結構&lt;/td>
 &lt;td>位置 1：假設驗證 — 量 ancestor chain&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>規則寫完、想確認實際 layout 對&lt;/td>
 &lt;td>位置 2：行為驗證 — 量 bounding rect&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>想驗證使用者互動後的狀態（filter / search / click）&lt;/td>
 &lt;td>位置 3：互動驗證 — dispatch event&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同個 layout bug 第 2 次出現&lt;/td>
 &lt;td>寫 layout test、CI 防回歸&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定 server 怎麼起 / 怎麼接 playwright&lt;/td>
 &lt;td>看下方「最低門檻 setup」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼-playwright-是前端開發的核心驗證工具">為什麼 playwright 是前端開發的核心驗證工具&lt;/h2>
&lt;p>CSS / DOM 的真實狀態 = 規則 + DOM tree + 樣式繼承 + 框架渲染的合成結果。靜態推理只能基於假設、視覺截圖只能傳達結果不傳達原因。&lt;/p>
&lt;p>Playwright &lt;code>browser_evaluate&lt;/code> 直接執行 JS 在 live page、返回 DOM tree / computed style / bounding rect — &lt;strong>把假設變成量測值&lt;/strong>。寫一個 evaluate fn ≈ 30 行 JS，比反覆推理快得多。&lt;/p>
&lt;hr>
&lt;h2 id="位置-1假設驗證寫-css-規則前">位置 1：假設驗證（寫 CSS 規則前）&lt;/h2>
&lt;h3 id="量-ancestor-chain">量 ancestor chain&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="kr">async&lt;/span> &lt;span class="p">()&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">2&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">el&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;.target&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">let&lt;/span> &lt;span class="nx">chain&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[];&lt;/span> &lt;span class="kd">let&lt;/span> &lt;span class="nx">n&lt;/span> &lt;span class="o">=&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">4&lt;/span>&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">n&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">n&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">body&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">chain&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">tagName&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">.&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&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">n&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">parentElement&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="k">return&lt;/span> &lt;span class="nx">chain&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;h3 id="量子節點與-sibling">量子節點與 sibling&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="kr">async&lt;/span> &lt;span class="p">()&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">2&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">parent&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&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">return&lt;/span> &lt;span class="nb">Array&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">from&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">parent&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">children&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">map&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">c&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="sb">`&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">tagName&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">.&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&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;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="kr">async&lt;/span> &lt;span class="p">()&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">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">count&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">querySelectorAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.result&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">length&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">first&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;.result&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">?&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">outerHTML&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">slice&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">200&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>寫 CSS 規則前 30 秒能省掉後續 30 分鐘推理。&lt;/p>
&lt;hr>
&lt;h2 id="位置-2行為驗證規則寫完後">位置 2：行為驗證（規則寫完後）&lt;/h2>
&lt;h3 id="量-bounding-rect">量 bounding rect&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="kr">async&lt;/span> &lt;span class="p">()&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">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">form&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__form&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">getBoundingClientRect&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">scope&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;.scope&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">getBoundingClientRect&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">results&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;.results&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">getBoundingClientRect&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;/code>&lt;/pre>&lt;/div>&lt;p>返回 &lt;code>{x, y, width, height, top, right, bottom, left}&lt;/code> 的純物件、能直接 assert 順序與位置。&lt;/p>
&lt;h3 id="量-computed-style">量 computed style&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="kr">async&lt;/span> &lt;span class="p">()&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"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">el&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;.target&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="kr">const&lt;/span> &lt;span class="nx">cs&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">getComputedStyle&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"> 4&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"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">display&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">cs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">display&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">position&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">cs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">position&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">gridRow&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">cs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">gridRow&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">color&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">cs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">color&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">zIndex&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">cs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">zIndex&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;/code>&lt;/pre>&lt;/div>&lt;h3 id="量實際贏的-css-rule">量「實際贏的 CSS rule」&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="kr">async&lt;/span> &lt;span class="p">()&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">2&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">el&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;.target&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="c1">// CSSOM 沒提供標準 getMatchedCSSRules；用 computed style 加 inspect
&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">return&lt;/span> &lt;span class="nx">getComputedStyle&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">cssText&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 全部 computed properties
&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>或在 DevTools Computed panel 看 — 但 playwright 能寫成測試重跑。&lt;/p></description><content:encoded><![CDATA[<p>Playwright 在前端開發循環的三個位置：假設驗證（寫 CSS 前）、行為驗證（規則寫完後）、互動驗證（dispatch event 後）。第 2 次同個版型 bug 出現 → 寫成測試固化。</p>
<p>適用：CSS / DOM debug、layout 驗收、互動行為驗證、寫 layout regression test。
不適用：純 unit test（function input/output、無 DOM）— 那用 Vitest / Jest 即可。</p>
<blockquote>
<p><strong>自包含聲明</strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋三個位置的具體 query 範例、layout test 模板、最低門檻 setup。</p></blockquote>
<hr>
<h2 id="何時參閱本文件">何時參閱本文件</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的第一件事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即將寫 CSS 規則、想先確認 DOM 結構</td>
          <td>位置 1：假設驗證 — 量 ancestor chain</td>
      </tr>
      <tr>
          <td>規則寫完、想確認實際 layout 對</td>
          <td>位置 2：行為驗證 — 量 bounding rect</td>
      </tr>
      <tr>
          <td>想驗證使用者互動後的狀態（filter / search / click）</td>
          <td>位置 3：互動驗證 — dispatch event</td>
      </tr>
      <tr>
          <td>同個 layout bug 第 2 次出現</td>
          <td>寫 layout test、CI 防回歸</td>
      </tr>
      <tr>
          <td>不確定 server 怎麼起 / 怎麼接 playwright</td>
          <td>看下方「最低門檻 setup」</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼-playwright-是前端開發的核心驗證工具">為什麼 playwright 是前端開發的核心驗證工具</h2>
<p>CSS / DOM 的真實狀態 = 規則 + DOM tree + 樣式繼承 + 框架渲染的合成結果。靜態推理只能基於假設、視覺截圖只能傳達結果不傳達原因。</p>
<p>Playwright <code>browser_evaluate</code> 直接執行 JS 在 live page、返回 DOM tree / computed style / bounding rect — <strong>把假設變成量測值</strong>。寫一個 evaluate fn ≈ 30 行 JS，比反覆推理快得多。</p>
<hr>
<h2 id="位置-1假設驗證寫-css-規則前">位置 1：假設驗證（寫 CSS 規則前）</h2>
<h3 id="量-ancestor-chain">量 ancestor chain</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="kr">async</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="kr">const</span> <span class="nx">el</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;.target&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">let</span> <span class="nx">chain</span> <span class="o">=</span> <span class="p">[];</span> <span class="kd">let</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">el</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">while</span> <span class="p">(</span><span class="nx">n</span> <span class="o">&amp;&amp;</span> <span class="nx">n</span> <span class="o">!==</span> <span class="nb">document</span><span class="p">.</span><span class="nx">body</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">chain</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">tagName</span><span class="si">}</span><span class="sb">.</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">className</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">n</span> <span class="o">=</span> <span class="nx">n</span><span class="p">.</span><span class="nx">parentElement</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="k">return</span> <span class="nx">chain</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><h3 id="量子節點與-sibling">量子節點與 sibling</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="kr">async</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="kr">const</span> <span class="nx">parent</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&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">return</span> <span class="nb">Array</span><span class="p">.</span><span class="nx">from</span><span class="p">(</span><span class="nx">parent</span><span class="p">.</span><span class="nx">children</span><span class="p">).</span><span class="nx">map</span><span class="p">(</span><span class="nx">c</span> <span class="p">=&gt;</span> <span class="sb">`</span><span class="si">${</span><span class="nx">c</span><span class="p">.</span><span class="nx">tagName</span><span class="si">}</span><span class="sb">.</span><span class="si">${</span><span class="nx">c</span><span class="p">.</span><span class="nx">className</span><span class="si">}</span><span class="sb">`</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><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="kr">async</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="nx">count</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">).</span><span class="nx">length</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">first</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;.result&#39;</span><span class="p">)</span><span class="o">?</span><span class="p">.</span><span class="nx">outerHTML</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">200</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>寫 CSS 規則前 30 秒能省掉後續 30 分鐘推理。</p>
<hr>
<h2 id="位置-2行為驗證規則寫完後">位置 2：行為驗證（規則寫完後）</h2>
<h3 id="量-bounding-rect">量 bounding rect</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="kr">async</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="nx">form</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__form&#39;</span><span class="p">).</span><span class="nx">getBoundingClientRect</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="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.scope&#39;</span><span class="p">).</span><span class="nx">getBoundingClientRect</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">results</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;.results&#39;</span><span class="p">).</span><span class="nx">getBoundingClientRect</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>返回 <code>{x, y, width, height, top, right, bottom, left}</code> 的純物件、能直接 assert 順序與位置。</p>
<h3 id="量-computed-style">量 computed 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="kr">async</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="kr">const</span> <span class="nx">el</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;.target&#39;</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">cs</span> <span class="o">=</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nx">el</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <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">display</span><span class="o">:</span> <span class="nx">cs</span><span class="p">.</span><span class="nx">display</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">position</span><span class="o">:</span> <span class="nx">cs</span><span class="p">.</span><span class="nx">position</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">gridRow</span><span class="o">:</span> <span class="nx">cs</span><span class="p">.</span><span class="nx">gridRow</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">color</span><span class="o">:</span> <span class="nx">cs</span><span class="p">.</span><span class="nx">color</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">zIndex</span><span class="o">:</span> <span class="nx">cs</span><span class="p">.</span><span class="nx">zIndex</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></code></pre></div><h3 id="量實際贏的-css-rule">量「實際贏的 CSS rule」</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="kr">async</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="kr">const</span> <span class="nx">el</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;.target&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="c1">// CSSOM 沒提供標準 getMatchedCSSRules；用 computed style 加 inspect
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="k">return</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nx">el</span><span class="p">).</span><span class="nx">cssText</span><span class="p">;</span>  <span class="c1">// 全部 computed properties
</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>或在 DevTools Computed panel 看 — 但 playwright 能寫成測試重跑。</p>
<hr>
<h2 id="位置-3互動驗證dispatch-event-後讀-state">位置 3：互動驗證（dispatch event 後讀 state）</h2>
<h3 id="模擬-input">模擬 input</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="kr">async</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="kr">const</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;.search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">input</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="s1">&#39;pre&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">input</span><span class="p">.</span><span class="nx">dispatchEvent</span><span class="p">(</span><span class="k">new</span> <span class="nx">Event</span><span class="p">(</span><span class="s1">&#39;input&#39;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">bubbles</span><span class="o">:</span> <span class="kc">true</span> <span class="p">}));</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="kr">await</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">r</span><span class="p">,</span> <span class="mi">1000</span><span class="p">));</span>  <span class="c1">// 等 debounce / async render
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>  <span class="k">return</span> <span class="nb">Array</span><span class="p">.</span><span class="nx">from</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">.</span><span class="nx">filter</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nx">el</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">8</span><span class="cl">    <span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="nx">el</span><span class="p">.</span><span class="nx">textContent</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">50</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><h3 id="模擬-click">模擬 click</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="kr">async</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="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.scope-toggle button[data-scope=&#34;title&#34;]&#39;</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kr">await</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">r</span><span class="p">,</span> <span class="mi">500</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <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">activeScope</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;.scope-toggle [aria-pressed=&#34;true&#34;]&#39;</span><span class="p">)</span><span class="o">?</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">scope</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">visibleResults</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result:not([hidden])&#39;</span><span class="p">).</span><span class="nx">length</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><h3 id="模擬-viewport-resize透過-playwright-api不在-evaluate-內">模擬 viewport resize（透過 playwright API、不在 evaluate 內）</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="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="o">:</span> <span class="mi">375</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">667</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">result</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">evaluate</span><span class="p">(()</span> <span class="p">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">layout</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;.layout&#39;</span><span class="p">).</span><span class="nx">getBoundingClientRect</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">sidebarVisible</span><span class="o">:</span> <span class="nx">getComputedStyle</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;.sidebar&#39;</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">5</span><span class="cl"><span class="p">}));</span></span></span></code></pre></div><hr>
<h2 id="第-2-次同個-bug--寫成-layout-測試固化">第 2 次同個 bug → 寫成 layout 測試固化</h2>
<p>第 1 次 debug 完成後、bug 修好。第 2 次同個版型問題（不同 commit / viewport / 內容狀態）再出現 → <strong>debug 完後把 query 寫成 playwright 測試</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="kr">import</span> <span class="p">{</span> <span class="nx">test</span><span class="p">,</span> <span class="nx">expect</span> <span class="p">}</span> <span class="nx">from</span> <span class="s1">&#39;@playwright/test&#39;</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">test</span><span class="p">(</span><span class="s1">&#39;search scope is between form and results&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForSelector</span><span class="p">(</span><span class="s1">&#39;.result&#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="kr">const</span> <span class="nx">formRect</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__form&#39;</span><span class="p">).</span><span class="nx">boundingBox</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="kr">const</span> <span class="nx">scopeRect</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.scope-toggle&#39;</span><span class="p">).</span><span class="nx">boundingBox</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="kr">const</span> <span class="nx">resultsRect</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.results&#39;</span><span class="p">).</span><span class="nx">boundingBox</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="nx">expect</span><span class="p">(</span><span class="nx">scopeRect</span><span class="p">.</span><span class="nx">y</span><span class="p">).</span><span class="nx">toBeGreaterThan</span><span class="p">(</span><span class="nx">formRect</span><span class="p">.</span><span class="nx">y</span> <span class="o">+</span> <span class="nx">formRect</span><span class="p">.</span><span class="nx">height</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="nx">expect</span><span class="p">(</span><span class="nx">resultsRect</span><span class="p">.</span><span class="nx">y</span><span class="p">).</span><span class="nx">toBeGreaterThan</span><span class="p">(</span><span class="nx">scopeRect</span><span class="p">.</span><span class="nx">y</span> <span class="o">+</span> <span class="nx">scopeRect</span><span class="p">.</span><span class="nx">height</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></span><span class="line"><span class="ln">15</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;sidebar visible at 1400px+&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="o">:</span> <span class="mi">1400</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">800</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="kr">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.sidebar&#39;</span><span class="p">)).</span><span class="nx">toBeVisible</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="nx">test</span><span class="p">(</span><span class="s1">&#39;sidebar hidden at &lt; 1400px&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="o">:</span> <span class="mi">1399</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">800</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">  <span class="kr">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.sidebar&#39;</span><span class="p">)).</span><span class="nx">toBeHidden</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>未來 layout 改動觸發 regression、CI 立刻發現。</p>
<hr>
<h2 id="寫-layout-test-的優先順序">寫 layout test 的優先順序</h2>
<p>不要每個 layout 都寫測試 — 寫測試的 ROI 條件：</p>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>該寫測試嗎</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Bug 第 1 次出現</td>
          <td>否（修了就好）</td>
      </tr>
      <tr>
          <td>Bug 第 2 次出現</td>
          <td><strong>是</strong>（防回歸）</td>
      </tr>
      <tr>
          <td>Layout 跟 viewport 強相關（breakpoint）</td>
          <td>是（容易壞）</td>
      </tr>
      <tr>
          <td>Layout 跟 framework 重渲染相關</td>
          <td>是（升級時需要驗證）</td>
      </tr>
      <tr>
          <td>純視覺風格（顏色 / 字型）</td>
          <td>否（用視覺 review 即可）</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="最低門檻-setup">最低門檻 setup</h2>
<h3 id="server">Server</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 任何方式起本地 server</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">hugo server                                       <span class="c1"># Hugo</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">python3 -m http.server <span class="m">8000</span> --directory public    <span class="c1"># 純靜態</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">npm run dev                                        <span class="c1"># framework dev server</span></span></span></code></pre></div><h3 id="playwright-mcp給-claude-用">Playwright MCP（給 Claude 用）</h3>
<p>Claude 透過 MCP 提供的 tool：</p>
<ul>
<li><code>browser_navigate(url)</code> — 開頁</li>
<li><code>browser_evaluate(fn)</code> — 執行 JS 拿結果</li>
<li><code>browser_take_screenshot()</code> — 截圖</li>
<li><code>browser_snapshot()</code> — accessibility tree</li>
<li><code>browser_click(selector)</code> / <code>browser_type(selector, text)</code> — 互動</li>
</ul>
<h3 id="playwright-測試給-ci-用">Playwright 測試（給 CI 用）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">npm i -D @playwright/test
</span></span><span class="line"><span class="ln">2</span><span class="cl">npx playwright install
</span></span><span class="line"><span class="ln">3</span><span class="cl">npx playwright test</span></span></code></pre></div><p><code>playwright.config.ts</code> 設 baseURL 指向 <code>http://localhost:1313</code>（Hugo 預設）或自訂 port。</p>
<hr>
<h2 id="wrong-vs-right-對照">Wrong vs Right 對照</h2>
<h3 id="範例-1css-不生效">範例 1：CSS 不生效</h3>
<p><strong>錯</strong>：靜態推理 + 截圖溝通 4 次失敗。</p>
<p><strong>對</strong>：第 2 次失敗 → 切 playwright：</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. 確認 ancestor chain
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</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">el</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;.target&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">let</span> <span class="nx">chain</span> <span class="o">=</span> <span class="p">[];</span> <span class="kd">let</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">el</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="k">while</span> <span class="p">(</span><span class="nx">n</span><span class="p">)</span> <span class="p">{</span> <span class="nx">chain</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">tagName</span><span class="si">}</span><span class="sb">.</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">className</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">n</span><span class="p">.</span><span class="nx">parentElement</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">return</span> <span class="nx">chain</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">// → 看到目標元素是 form 的 child、不是 .pagefind-ui 的直接 child
</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">// 2. 確認 computed style 誰贏
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="nx">getComputedStyle</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;.target&#39;</span><span class="p">)).</span><span class="nx">color</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">// → &#34;rgb(0,0,255)&#34; — vendor 的藍色贏了
</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">// 3. 換方向：用 @layer 把 vendor 包起來
</span></span></span></code></pre></div><h3 id="範例-2layout-第-2-次出現一樣的-bug">範例 2：Layout 第 2 次出現一樣的 bug</h3>
<p><strong>錯</strong>：手動在不同 viewport 下視覺驗證、commit、過幾週又壞、又手動驗證。</p>
<p><strong>對</strong>：第 2 次出現後寫成測試：</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;layout golden path: form → scope → results&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</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">for</span> <span class="p">(</span><span class="kr">const</span> <span class="nx">width</span> <span class="k">of</span> <span class="p">[</span><span class="mi">375</span><span class="p">,</span> <span class="mi">768</span><span class="p">,</span> <span class="mi">1024</span><span class="p">,</span> <span class="mi">1400</span><span class="p">,</span> <span class="mi">1920</span><span class="p">])</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">800</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="kr">const</span> <span class="nx">form</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__form&#39;</span><span class="p">).</span><span class="nx">boundingBox</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="kr">const</span> <span class="nx">scope</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.scope-toggle&#39;</span><span class="p">).</span><span class="nx">boundingBox</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">expect</span><span class="p">(</span><span class="nx">scope</span><span class="p">.</span><span class="nx">y</span><span class="p">,</span> <span class="sb">`at width=</span><span class="si">${</span><span class="nx">width</span><span class="si">}</span><span class="sb">`</span><span class="p">).</span><span class="nx">toBeGreaterThanOrEqual</span><span class="p">(</span><span class="nx">form</span><span class="p">.</span><span class="nx">y</span> <span class="o">+</span> <span class="nx">form</span><span class="p">.</span><span class="nx">height</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>未來改 CSS、CI 直接告訴你哪個 viewport 壞了。</p>
<hr>
<h2 id="red-green-順序先看到-red-才相信-green">RED-GREEN 順序：先看到 RED 才相信 GREEN</h2>
<p>寫完 playwright test 後、必須先在「buggy code」跑出 RED 才能相信「fixed code」的 GREEN。詳見 <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>。</p>
<p>修 bug 的順序：</p>
<ol>
<li><strong>先寫測試 + 跑 → RED</strong>（在 buggy code 上 fail、證明測試會 catch + bug 真的存在）</li>
<li><strong>修 code</strong></li>
<li><strong>跑測試 → GREEN</strong>（證明修對了 + 測試會抓回歸）</li>
</ol>
<p>跳過 step 1 的 retrospective 補救（修完才補測試）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Stash 修復、checkout 修前 commit</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">git stash <span class="o">&amp;&amp;</span> git checkout &lt;pre-fix-commit&gt;
</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"># Cherry-pick 測試 commit、build、跑</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">git cherry-pick &lt;test-commit&gt;
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">make site <span class="o">&amp;&amp;</span> npm <span class="nb">test</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># 預期：RED</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">git checkout main <span class="o">&amp;&amp;</span> git stash pop
</span></span><span class="line"><span class="ln">11</span><span class="cl">npm <span class="nb">test</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># 預期：GREEN</span></span></span></code></pre></div><p>兩個訊號都看到 + 順序對、測試才被驗證。</p>
<hr>
<h2 id="自檢清單dogfooding">自檢清單（dogfooding）</h2>
<p>debug / 驗證 layout 時：</p>
<ul>
<li><input disabled="" type="checkbox"> 寫 CSS 規則前、有沒有用 playwright 量過 ancestor chain？</li>
<li><input disabled="" type="checkbox"> 規則寫完後、有沒有用 playwright 量過 bounding rect / computed style 確認？</li>
<li><input disabled="" type="checkbox"> 互動行為（filter / click）有沒有用 playwright 模擬 + 量化驗證？</li>
<li><input disabled="" type="checkbox"> 同個 layout bug 第 2 次出現時、有沒有寫成測試？</li>
<li><input disabled="" type="checkbox"> 推理失敗 ≥ 2 次時、有沒有主動切換到 playwright（不等到第 5 次）？</li>
</ul>
<hr>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>對應的事後檢討（在 <code>content/report/</code>）：</p>
<ul>
<li><a href="/blog/report/playwright-early-in-loop/" data-link-title="在開發循環裡早一點用 playwright 看真實結果" data-link-desc="靜態 CSS 推理跟視覺截圖溝通有極限 — 當行為與預期不符 ≥ 2 次，stop 推理、改用 playwright browser_evaluate 直接讀 live DOM。本文說明工具引入時機。">playwright-early-in-loop</a> — 在開發循環裡早一點用 playwright 看真實結果</li>
<li><a href="/blog/report/layout-tests-with-playwright/" data-link-title="用前端測試把排版問題自動化" data-link-desc="排版問題傳統靠人眼檢查、容易遺漏邊界 case。當一個版型被 debug 兩次以上、就值得寫成 playwright 測試把規範固定下來。本文展開測試替代手動檢查的時機。">layout-tests-with-playwright</a> — 用前端測試把排版問題自動化</li>
<li><a href="/blog/report/verification-method-timing/" data-link-title="驗證方法的選擇時機" data-link-desc="靜態 CSS 推理 ≥ 2 次失敗就主動提『啟個 server、用 playwright 看 live DOM 比較快』、不要繼續猜。本文展開驗證工具的引入時機。">verification-method-timing</a> — 驗證方法的選擇時機</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.1.0</p>
]]></content:encoded></item><item><title>Reactive Performance — Reactive 效能盤點與優化</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/reactive-performance/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/reactive-performance/</guid><description>&lt;p>前端 reactive 效能的盤點與優化：MutationObserver 三維度（root / options / debounce）、polling → observer、iteration / regex / reflow / lazy load 四個成本面。&lt;/p>
&lt;p>適用：使用者反映卡頓、CPU 100%、scroll lag、resize jank、首次互動延遲。
不適用：純後端效能、純伺服器渲染（SSR 的成本另一套）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>自包含聲明&lt;/strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋四個效能風險面向、observer 設計準則、量測方法。&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>使用者打字時搜尋頁卡頓&lt;/td>
 &lt;td>量 input listener / observer 觸發頻率&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Scroll 時掉幀&lt;/td>
 &lt;td>量 scroll listener 觸發頻率 + reflow 成本&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Resize 視窗時 layout 跳動&lt;/td>
 &lt;td>量 ResizeObserver 觸發 + 重新計算成本&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CPU 100%、即使頁面靜止&lt;/td>
 &lt;td>找 setInterval / setTimeout polling、換 observer&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>結果規模大（&amp;gt; 500 筆）時慢&lt;/td>
 &lt;td>量 iteration cost、看是否每筆都跑 regex&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>首次互動延遲（搜尋頁 200ms+ 才能輸入）&lt;/td>
 &lt;td>量 critical path、看 lazy chunk 是否要 preload&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>即將寫 &lt;code>observer.observe(document.body, { subtree: true })&lt;/code>&lt;/td>
 &lt;td>停 — 範圍過寬、補上限制&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼-reactive-效能要主動盤點">為什麼 reactive 效能要主動盤點&lt;/h2>
&lt;p>Reactive 系統的成本不是線性 — 一個觸發頻率失控的 listener 會放大整個系統的負擔：&lt;/p>
&lt;ul>
&lt;li>一個 observer 觸發 → callback 執行 → DOM 變動 → 再觸發 observer → 無限迴圈&lt;/li>
&lt;li>一個 input listener 沒 debounce → 每個鍵盤事件跑一次重 query → CPU 飆高&lt;/li>
&lt;li>一個 setInterval polling 50ms → 永遠不停、即使頁面背景&lt;/li>
&lt;/ul>
&lt;p>主動盤點 = 寫之前先估觸發頻率、寫之後用 &lt;code>console.count&lt;/code> 驗證。事後 debug 比事前設計貴 10 倍。&lt;/p>
&lt;hr>
&lt;h2 id="風險面向-1listener-觸發頻率">風險面向 1：Listener 觸發頻率&lt;/h2>
&lt;h3 id="mutationobserver-三維度">MutationObserver 三維度&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>Root&lt;/td>
 &lt;td>最窄（具體 element）&lt;/td>
 &lt;td>&lt;code>document.body&lt;/code> / &lt;code>document.documentElement&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Options&lt;/td>
 &lt;td>&lt;code>{ childList: true }&lt;/code>&lt;/td>
 &lt;td>&lt;code>{ subtree: true, attributes: true, characterData: true }&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Debounce&lt;/td>
 &lt;td>0ms 或微 microtask&lt;/td>
 &lt;td>沒寫 debounce、callback 執行 &amp;gt; 5ms&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&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">// 監聽整個 page 任何變動
&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">cb&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>&lt;/span>&lt;span class="line">&lt;span class="ln">3&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>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &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">5&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &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>&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">// 一次 react state 變動 → 100+ 個 callback
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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="kr">const&lt;/span> &lt;span class="nx">root&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__results-area&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">let&lt;/span> &lt;span class="nx">timer&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">new&lt;/span> &lt;span class="nx">MutationObserver&lt;/span>&lt;span class="p">(()&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">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">clearTimeout&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">timer&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">timer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">setTimeout&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">callback&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">100&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c1">// debounce 100ms
&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 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>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1">// 只監聽 results 直接子節點變動、debounce 100ms
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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="kd">let&lt;/span> &lt;span class="nx">count&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&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">new&lt;/span> &lt;span class="nx">MutationObserver&lt;/span>&lt;span class="p">(()&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">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">count&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">console&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">log&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;mutation&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">count&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="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="c1">// 預期：使用者打字 1 秒、觸發 10 次以下
&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">// 觀察：100+ 次 → 範圍過寬、加 debounce 或縮 root
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>或用 &lt;code>console.count('decorate')&lt;/code> 計數、看每秒觸發幾次。&lt;/p></description><content:encoded><![CDATA[<p>前端 reactive 效能的盤點與優化：MutationObserver 三維度（root / options / debounce）、polling → observer、iteration / regex / reflow / lazy load 四個成本面。</p>
<p>適用：使用者反映卡頓、CPU 100%、scroll lag、resize jank、首次互動延遲。
不適用：純後端效能、純伺服器渲染（SSR 的成本另一套）。</p>
<blockquote>
<p><strong>自包含聲明</strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋四個效能風險面向、observer 設計準則、量測方法。</p></blockquote>
<hr>
<h2 id="何時參閱本文件">何時參閱本文件</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的第一件事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>使用者打字時搜尋頁卡頓</td>
          <td>量 input listener / observer 觸發頻率</td>
      </tr>
      <tr>
          <td>Scroll 時掉幀</td>
          <td>量 scroll listener 觸發頻率 + reflow 成本</td>
      </tr>
      <tr>
          <td>Resize 視窗時 layout 跳動</td>
          <td>量 ResizeObserver 觸發 + 重新計算成本</td>
      </tr>
      <tr>
          <td>CPU 100%、即使頁面靜止</td>
          <td>找 setInterval / setTimeout polling、換 observer</td>
      </tr>
      <tr>
          <td>結果規模大（&gt; 500 筆）時慢</td>
          <td>量 iteration cost、看是否每筆都跑 regex</td>
      </tr>
      <tr>
          <td>首次互動延遲（搜尋頁 200ms+ 才能輸入）</td>
          <td>量 critical path、看 lazy chunk 是否要 preload</td>
      </tr>
      <tr>
          <td>即將寫 <code>observer.observe(document.body, { subtree: true })</code></td>
          <td>停 — 範圍過寬、補上限制</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼-reactive-效能要主動盤點">為什麼 reactive 效能要主動盤點</h2>
<p>Reactive 系統的成本不是線性 — 一個觸發頻率失控的 listener 會放大整個系統的負擔：</p>
<ul>
<li>一個 observer 觸發 → callback 執行 → DOM 變動 → 再觸發 observer → 無限迴圈</li>
<li>一個 input listener 沒 debounce → 每個鍵盤事件跑一次重 query → CPU 飆高</li>
<li>一個 setInterval polling 50ms → 永遠不停、即使頁面背景</li>
</ul>
<p>主動盤點 = 寫之前先估觸發頻率、寫之後用 <code>console.count</code> 驗證。事後 debug 比事前設計貴 10 倍。</p>
<hr>
<h2 id="風險面向-1listener-觸發頻率">風險面向 1：Listener 觸發頻率</h2>
<h3 id="mutationobserver-三維度">MutationObserver 三維度</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>預設</th>
          <th>過寬訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Root</td>
          <td>最窄（具體 element）</td>
          <td><code>document.body</code> / <code>document.documentElement</code></td>
      </tr>
      <tr>
          <td>Options</td>
          <td><code>{ childList: true }</code></td>
          <td><code>{ subtree: true, attributes: true, characterData: true }</code></td>
      </tr>
      <tr>
          <td>Debounce</td>
          <td>0ms 或微 microtask</td>
          <td>沒寫 debounce、callback 執行 &gt; 5ms</td>
      </tr>
  </tbody>
</table>
<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">// 監聽整個 page 任何變動
</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">cb</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></span><span class="line"><span class="ln">3</span><span class="cl">  <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">4</span><span class="cl">  <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">5</span><span class="cl">  <span class="nx">attributes</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nx">characterData</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">// 一次 react state 變動 → 100+ 個 callback
</span></span></span></code></pre></div><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="kr">const</span> <span class="nx">root</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__results-area&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">let</span> <span class="nx">timer</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</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">5</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">callback</span><span class="p">,</span> <span class="mi">100</span><span class="p">);</span>  <span class="c1">// debounce 100ms
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></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><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">// 只監聽 results 直接子節點變動、debounce 100ms
</span></span></span></code></pre></div><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">let</span> <span class="nx">count</span> <span class="o">=</span> <span class="mi">0</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="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">count</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">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;mutation&#39;</span><span class="p">,</span> <span class="nx">count</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><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="c1">// 預期：使用者打字 1 秒、觸發 10 次以下
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">// 觀察：100+ 次 → 範圍過寬、加 debounce 或縮 root
</span></span></span></code></pre></div><p>或用 <code>console.count('decorate')</code> 計數、看每秒觸發幾次。</p>
<hr>
<h2 id="風險面向-2polling-換-observer">風險面向 2：Polling 換 Observer</h2>
<h3 id="反例setinterval-polling">反例：setInterval polling</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="kr">const</span> <span class="nx">timer</span> <span class="o">=</span> <span class="nx">setInterval</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="kr">const</span> <span class="nx">el</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;.target&#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="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="nx">decorate</span><span class="p">(</span><span class="nx">el</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">clearInterval</span><span class="p">(</span><span class="nx">timer</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 class="mi">50</span><span class="p">);</span></span></span></code></pre></div><p>問題：CPU 50% busy waiting、即使元素永遠不出現、interval 永遠跑。</p>
<h3 id="對例mutationobserver--fast-path">對例：MutationObserver + 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="kd">function</span> <span class="nx">waitForElement</span><span class="p">(</span><span class="nx">selector</span><span class="p">,</span> <span class="nx">root</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">body</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="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">resolve</span> <span class="p">=&gt;</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">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"> 4</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">existing</span><span class="p">)</span> <span class="k">return</span> <span class="nx">resolve</span><span class="p">(</span><span class="nx">existing</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="kr">const</span> <span class="nx">obs</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="kr">const</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"> 8</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"> 9</span><span class="cl">        <span class="nx">obs</span><span class="p">.</span><span class="nx">disconnect</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">resolve</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="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">obs</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">14</span><span class="cl">  <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>Fast-path 先檢查（如果已經在 DOM 立即返回）、否則 observer 等元素出現。0 latency、0 idle CPU、元素出現立刻觸發。</p>
<hr>
<h2 id="風險面向-3iteration--regex-成本">風險面向 3：Iteration / Regex 成本</h2>
<h3 id="反例每筆結果跑重-regex">反例：每筆結果跑重 regex</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="kr">const</span> <span class="nx">results</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">pagefind</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">query</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">filtered</span> <span class="o">=</span> <span class="nx">results</span><span class="p">.</span><span class="nx">results</span><span class="p">.</span><span class="nx">filter</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="o">/</span><span class="nx">complex</span><span class="o">|</span><span class="nx">regex</span><span class="o">|</span><span class="nx">here</span><span class="o">/</span><span class="nx">i</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">r</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="c1">// 500 筆 × regex test = 500 次 regex 編譯與執行
</span></span></span></code></pre></div><h3 id="對例regex-compile-一次用-cached-version">對例：regex compile 一次、用 cached version</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="kr">const</span> <span class="nx">re</span> <span class="o">=</span> <span class="sr">/complex|regex|here/i</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">filtered</span> <span class="o">=</span> <span class="nx">results</span><span class="p">.</span><span class="nx">results</span><span class="p">.</span><span class="nx">filter</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="nx">re</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">r</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="c1">// regex 只編譯一次、test 每次便宜
</span></span></span></code></pre></div><h3 id="量-iteration-成本">量 iteration 成本</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">console</span><span class="p">.</span><span class="nx">time</span><span class="p">(</span><span class="s1">&#39;filter&#39;</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">filtered</span> <span class="o">=</span> <span class="nx">results</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="nx">console</span><span class="p">.</span><span class="nx">timeEnd</span><span class="p">(</span><span class="s1">&#39;filter&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// 觀察：&gt; 16ms → 影響 60fps、要優化
</span></span></span></code></pre></div><h3 id="大資料量的常用優化">大資料量的常用優化</h3>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>優化</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>每筆都跑 regex</td>
          <td>regex 編譯一次、test 重用</td>
      </tr>
      <tr>
          <td>每筆 query DOM</td>
          <td>DOM query 一次、緩存結果</td>
      </tr>
      <tr>
          <td>排序 N²</td>
          <td>用 <code>Array.sort()</code> (N log N)</td>
      </tr>
      <tr>
          <td>全量過濾後分頁</td>
          <td>分頁邊界提前 break、不跑完全部</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="風險面向-4layout-reflow-成本">風險面向 4：Layout Reflow 成本</h2>
<p>Reflow（重新計算 layout） &gt; Repaint（重繪） &gt; Composite（合成）— 三者成本遞減。</p>
<h3 id="reflow-觸發訊號">Reflow 觸發訊號</h3>
<table>
  <thead>
      <tr>
          <th>操作</th>
          <th>成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>改 width / height / top / margin</td>
          <td>Reflow（layout 變動）</td>
      </tr>
      <tr>
          <td>改 color / background</td>
          <td>Repaint（不影響 layout）</td>
      </tr>
      <tr>
          <td>改 transform / opacity</td>
          <td>Composite（GPU、最便宜）</td>
      </tr>
      <tr>
          <td>讀 <code>getBoundingClientRect()</code></td>
          <td>強制 sync reflow（如果 pending 變動）</td>
      </tr>
  </tbody>
</table>
<h3 id="反例read-write-read-write-觸發-layout-thrashing">反例：read-write-read-write 觸發 layout thrashing</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">elements</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">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">w</span> <span class="o">=</span> <span class="nx">el</span><span class="p">.</span><span class="nx">offsetWidth</span><span class="p">;</span>       <span class="c1">// read
</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">style</span><span class="p">.</span><span class="nx">width</span> <span class="o">=</span> <span class="sb">`</span><span class="si">${</span><span class="nx">w</span> <span class="o">*</span> <span class="mi">2</span><span class="si">}</span><span class="sb">px`</span><span class="p">;</span>  <span class="c1">// write
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="kr">const</span> <span class="nx">h</span> <span class="o">=</span> <span class="nx">el</span><span class="p">.</span><span class="nx">offsetHeight</span><span class="p">;</span>      <span class="c1">// read（強制 reflow）
</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">style</span><span class="p">.</span><span class="nx">height</span> <span class="o">=</span> <span class="sb">`</span><span class="si">${</span><span class="nx">h</span> <span class="o">*</span> <span class="mi">2</span><span class="si">}</span><span class="sb">px`</span><span class="p">;</span> <span class="c1">// write
</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="c1">// 每次 read 觸發一次 reflow、N 個元素 = N 次 reflow
</span></span></span></code></pre></div><h3 id="對例批量-read批量-write">對例：批量 read、批量 write</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="kr">const</span> <span class="nx">sizes</span> <span class="o">=</span> <span class="nx">elements</span><span class="p">.</span><span class="nx">map</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">2</span><span class="cl">  <span class="nx">el</span><span class="p">,</span> <span class="nx">w</span><span class="o">:</span> <span class="nx">el</span><span class="p">.</span><span class="nx">offsetWidth</span><span class="p">,</span> <span class="nx">h</span><span class="o">:</span> <span class="nx">el</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="p">}));</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">sizes</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(({</span> <span class="nx">el</span><span class="p">,</span> <span class="nx">w</span><span class="p">,</span> <span class="nx">h</span> <span class="p">})</span> <span class="p">=&gt;</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">style</span><span class="p">.</span><span class="nx">width</span> <span class="o">=</span> <span class="sb">`</span><span class="si">${</span><span class="nx">w</span> <span class="o">*</span> <span class="mi">2</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="nx">el</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">height</span> <span class="o">=</span> <span class="sb">`</span><span class="si">${</span><span class="nx">h</span> <span class="o">*</span> <span class="mi">2</span><span class="si">}</span><span class="sb">px`</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">// 1 次 reflow、性能提升 N 倍
</span></span></span></code></pre></div><h3 id="量-reflow-成本">量 reflow 成本</h3>
<p>Chrome DevTools Performance panel → 找 &ldquo;Layout&rdquo; 紫色塊。&gt; 16ms 要優化。</p>
<hr>
<h2 id="風險面向-5資源載入時序">風險面向 5：資源載入時序</h2>
<h3 id="critical-path-vs-lazy-chunk">Critical path vs lazy chunk</h3>
<table>
  <thead>
      <tr>
          <th>資源</th>
          <th>該不該 lazy</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>首屏需要的 CSS / JS</td>
          <td>否（critical path、preload）</td>
      </tr>
      <tr>
          <td>搜尋頁的 search index</td>
          <td>是（使用者進搜尋頁前不需要）</td>
      </tr>
      <tr>
          <td>Footer 圖片</td>
          <td>是（lazy load on scroll）</td>
      </tr>
      <tr>
          <td>跟首屏互動相關的 JS</td>
          <td>否（input listener 要立刻 ready）</td>
      </tr>
  </tbody>
</table>
<h3 id="範例搜尋頁的-lazy-chunk">範例：搜尋頁的 lazy chunk</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;!-- 搜尋頁進來時、preload 第一個 chunk --&gt;</span>
</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;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/_pagefind/pagefind-entry.json&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;fetch&#34;</span> <span class="na">crossorigin</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">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/_pagefind/pagefind.js&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;script&#34;</span><span class="p">&gt;</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="p">&lt;</span><span class="nt">script</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;module&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="kr">import</span><span class="p">(</span><span class="s1">&#39;/_pagefind/pagefind.js&#39;</span><span class="p">).</span><span class="nx">then</span><span class="p">(</span><span class="nx">p</span> <span class="p">=&gt;</span> <span class="nx">p</span><span class="p">.</span><span class="nx">init</span><span class="p">());</span>
</span></span><span class="line"><span class="ln">7</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>不 preload 的代價：使用者進搜尋頁 → 點 input → 等 200-500ms 才能搜尋。</p>
<h3 id="量-critical-path">量 critical path</h3>
<p>Chrome DevTools Network panel → 看每個資源的 timing。Slow 3G throttle 模擬真實使用者環境。</p>
<hr>
<h2 id="盤點-reactive-listener-的協議">盤點 reactive listener 的協議</h2>
<p>對複雜頁面（搜尋頁、dashboard）做一次性盤點：</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. 列出所有 observer / listener
</span></span></span><span class="line"><span class="ln"> 2</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></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">mutationObservers</span><span class="o">:</span> <span class="nb">window</span><span class="p">.</span><span class="nx">observers</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">resizeObservers</span><span class="o">:</span> <span class="nb">window</span><span class="p">.</span><span class="nx">resizeObservers</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nx">inputListeners</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="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. 加 console.count 在每個 callback
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">decorateCount</span> <span class="o">=</span> <span class="p">(()</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="kd">let</span> <span class="nx">c</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="k">return</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="nx">console</span><span class="p">.</span><span class="nx">count</span><span class="p">(</span><span class="sb">`decorate </span><span class="si">${</span><span class="o">++</span><span class="nx">c</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span> <span class="p">};</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">// 3. 操作頁面 1 分鐘、看 console
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">// 4. 任何 callback 執行 &gt; 100 次/分鐘 → 評估是否需要 debounce / 縮範圍
</span></span></span></code></pre></div><p>定期盤點（每加新 observer 後）= 主動發現觸發頻率失控、不等使用者抱怨。</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">addEventListener</span><span class="p">(</span><span class="s1">&#39;input&#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="c1">// 每個鍵盤事件都重 query 整個 results、重排版
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="kr">const</span> <span class="nx">results</span> <span class="o">=</span> <span class="nx">expensiveQuery</span><span class="p">(</span><span class="nx">input</span><span class="p">.</span><span class="nx">value</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">renderResults</span><span class="p">(</span><span class="nx">results</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><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">let</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="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="p">()</span> <span class="p">=&gt;</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="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="kr">const</span> <span class="nx">results</span> <span class="o">=</span> <span class="nx">expensiveQuery</span><span class="p">(</span><span class="nx">input</span><span class="p">.</span><span class="nx">value</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">renderResults</span><span class="p">(</span><span class="nx">results</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">},</span> <span class="mi">200</span><span class="p">);</span>  <span class="c1">// debounce 200ms
</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><h3 id="範例-2等元素出現">範例 2：等元素出現</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="kr">const</span> <span class="nx">timer</span> <span class="o">=</span> <span class="nx">setInterval</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">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 class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">decorate</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">clearInterval</span><span class="p">(</span><span class="nx">timer</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 class="mi">100</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-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">mutations</span><span class="p">,</span> <span class="nx">obs</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">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 class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">obs</span><span class="p">.</span><span class="nx">disconnect</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">decorate</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 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><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">// 注意：subtree 只在「等元素出現」場景可接受、決完後 disconnect
</span></span></span></code></pre></div><hr>
<h2 id="自檢清單dogfooding">自檢清單（dogfooding）</h2>
<p>寫 reactive code 或 perf debug 時：</p>
<ul>
<li><input disabled="" type="checkbox"> MutationObserver root 是不是最窄能達成目標的 element？</li>
<li><input disabled="" type="checkbox"> options 是不是只開必要的（<code>childList</code> 預設、<code>subtree</code> 要有理由、<code>attributes</code> 不是預設）？</li>
<li><input disabled="" type="checkbox"> 重 callback 有沒有 debounce / throttle？</li>
<li><input disabled="" type="checkbox"> setInterval / setTimeout polling 能不能換成 MutationObserver？</li>
<li><input disabled="" type="checkbox"> iteration / regex 在大資料量下測過嗎？&gt; 16ms 要優化</li>
<li><input disabled="" type="checkbox"> 改 layout 屬性有沒有 batch read-write、避免 layout thrashing？</li>
<li><input disabled="" type="checkbox"> Lazy chunk 是 critical path 還是真的 lazy？</li>
</ul>
<hr>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>對應的事後檢討（在 <code>content/report/</code>）：</p>
<ul>
<li><a href="/blog/report/mutation-observer-scope/" data-link-title="MutationObserver 範圍與觸發頻率：監聽最少必要的變動" data-link-desc="MutationObserver 是非同步監聽工具、跟同步 selector 是不同議題。範圍寬會頻繁觸發、option 勾多會在不關心的變動上跑邏輯、apply 自己改 DOM 會觸發無限循環。本文是 observer 設計的完整指引。">mutation-observer-scope</a> — MutationObserver 範圍與觸發頻率</li>
<li><a href="/blog/report/mutationobserver-over-polling/" data-link-title="setTimeout 輪詢換 MutationObserver" data-link-desc="等元素出現的場景、用 MutationObserver 監聽 DOM 變化、看到目標就 disconnect — 沒延遲、CPU 不被輪詢吃。本文展開兩種等待機制的差異。">mutationobserver-over-polling</a> — setTimeout 輪詢換 MutationObserver</li>
<li><a href="/blog/report/reactive-listener-frequency-management/" data-link-title="Reactive 監聽器的效能 audit：跨 listener 類型盤點觸發頻率" data-link-desc="MutationObserver / ResizeObserver / event listener 各自的觸發頻率怎麼盤點。本文是效能 audit 視角 — 找問題用、跟 #29 (observer 設計指引) 互補不重複。">reactive-listener-frequency-management</a> — Reactive 監聽器的效能 audit</li>
<li><a href="/blog/report/runtime-iteration-and-regex-cost/" data-link-title="Runtime 計算成本：每筆迭代與正則" data-link-desc="Scope filter 對每筆結果跑 regex — 結果數量大時成為 frame budget 的主要消耗。本文盤點此類「每筆迭代 &#43; per-item 計算」的風險點與評估方法。">runtime-iteration-and-regex-cost</a> — Runtime 計算成本：每筆迭代與正則</li>
<li><a href="/blog/report/layout-reflow-measurement/" data-link-title="Layout reflow / repaint 的可量化評估" data-link-desc="Filter slot 切換、CSS 變數寫入、絕對定位重算 — 哪些操作觸發 reflow 而非僅 repaint、用什麼工具量、評估值落在哪個區間值得優化。">layout-reflow-measurement</a> — Layout reflow / repaint 的可量化評估</li>
<li><a href="/blog/report/lazy-loading-and-critical-path/" data-link-title="資源載入時序：lazy chunk 與 critical path" data-link-desc="Pagefind 的 index 採 chunked lazy load — 首次互動延遲與 critical path 之間的取捨怎麼盤點。預載 entry chunk 的時機與不預載的代價。">lazy-loading-and-critical-path</a> — 資源載入時序：lazy chunk 與 critical path</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.1.0</p>
]]></content:encoded></item></channel></rss>