核心原則

JS 的 DOM query 從具體開始、發現不夠用再放寬。 Selector 涵蓋「最少必要範圍」、避免誤命中其他元素、避免未來頁面結構變動讓 query 撈到不該撈的東西。精準度有三個收斂維度:起點(從哪開始找)、範圍(找多深)、過濾(哪些不要)— 三者一起設計才完整。


為什麼精準度是 default

商業邏輯

DOM selector 的範圍越寬、被誤命中的可能性越高。寬泛 selector 像「網撈」 — 當下頁面只有一個目標元素時看不出問題、未來頁面結構變動(加第二個同類元件、加 demo 區塊、加 widget)就壞。

精準度的成本是「寫 selector 時多想一點」、收益是「行為可預測、不會被未來變動打破」。這不是優化、是 sanity 防線

寬泛 selector 的失敗模式

失敗模式表現根因
跨元件誤命中該動的動了、不該動的也動了沒指定 ancestor scope
同名 class 誤命中demo 區塊 / 文檔截圖也被處理沒過濾「處於展示用途」的元素
未初始化元素被處理元件還沒 mount 完就被操作沒過濾「狀態未就緒」的元素
已處理元素重複處理apply 被 observer 觸發又處理一次沒標記「已處理」

四種失敗都來自「query 範圍 > 真實需要的範圍」。從具體開始就避免。


三層收斂維度

Selector 精準度不是單一參數、是三個維度的組合。每個維度都該設計、不能只想其中一個。

維度 1:起點(從哪個 root 開始找)

核心定義:query 的起點決定「最大可能範圍」。從 document 起 = 全頁面;從元件根起 = 子樹內。

1// 寬:全頁面搜尋
2document.querySelector('.pagefind-ui__result');
3
4// 收斂:從元件根開始
5var shell = document.querySelector('.search-shell');
6shell.querySelector('.pagefind-ui__result');

從元件根開始等於把 selector 的作用範圍收斂到「我管的子樹」 — 即使未來頁面其他地方出現同名元素、跟我無關。

起點選擇的決策

起點適用情境
document確定全頁只有一個目標、且未來不會增加同類
元件根(變數存好)一般情境(推薦預設)
函式參數傳入根同頁面有多個元件實例、各自獨立 setup
事件 closest 反向找根動態多實例、用事件驅動

多元件 setup pattern

1function setupSearchShell(shell) {
2  var ui     = shell.querySelector('.pagefind-ui');
3  var input  = shell.querySelector('.pagefind-ui__search-input');
4  var drawer = shell.querySelector('.pagefind-ui__drawer');
5  // ... 其他 setup
6}
7
8document.querySelectorAll('.search-shell').forEach(setupSearchShell);

頁面有 N 個 shell、自動 setup N 次、各自獨立。當前只一個也適用、未來加更多無痛 — 這是「起點當參數」帶來的擴展性。

例外處理:當目標元素不在元件子樹內(例如同層的 sibling),保留 document.querySelector 但加註解說明:

1// slot 是 main 的子節點、跟 shell 同層、不能從 shell 找
2var slot = document.querySelector('.search-filter-slot');

註解讓未來維護者知道這是「明知故為」的例外、不是疏忽。

維度 2:範圍(找多深)

核心定義:起點確定後、要找直接子、特定層、還是任意深度。

querySelector 預設找任意深度 — 大部分情況沒問題、但結構穩定時可以更精準:

1// 預設:任意深度
2shell.querySelector('.pagefind-ui');
3
4// 限縮:只找直接子
5shell.querySelector(':scope > .pagefind-ui');
6
7// 限縮:只找特定層
8shell.querySelector(':scope > div > .pagefind-ui');

:scope 在 querySelector 內表示 query 的起始元素 — 配合 > 就能精準匹配「直接子」。

範圍選擇的決策

範圍適用情境
任意深度(預設)結構可能變動、目標可能搬位置
直接子 :scope > X結構穩定、避免深層誤命中
特定路徑 :scope > A > B結構非常穩定、想要結構變動立即察覺

選太寬未來誤命中、選太窄未來結構微調就壞 — 預設選任意深度、結構穩定的關鍵 query 才用 :scope >

維度 3:過濾(哪些元素不要)

核心定義:起點 + 範圍確定後、可能還是命中過多 — 用 attribute filter 與否定 selector 排除不要的。

1// 寬:所有 result
2shell.querySelectorAll('.pagefind-ui__result');
3
4// 過濾:只取已 rank 過的(排除初始化中的)
5shell.querySelectorAll('.pagefind-ui__result[data-pagefind-rank]');
6
7// 過濾:排除已處理過的
8shell.querySelectorAll('.pagefind-ui__result:not([data-scoped])');

過濾技巧

技巧用法
Attribute filter[data-state="ready"] 只取狀態就緒的
:not() 排除:not([data-scoped]) 排除已處理
Attribute exists[data-pagefind-rank] 只取有特定屬性的
處理後標記處理完 el.setAttribute('data-scoped', 'true') 避免重複處理

「處理後標記」是 idempotency 工具:apply 函式可能被多次呼叫(observer 觸發、event 觸發),標記 + :not() 過濾確保每個元素只處理一次。


三維度的組合範例

完整的精準 selector 設計:

 1var shell = document.querySelector('.search-shell');           // 維度 1:起點
 2if (!shell) return;
 3
 4var results = shell.querySelectorAll(                          // 維度 2:任意深度
 5  '.pagefind-ui__result[data-pagefind-rank]:not([data-scoped])'  // 維度 3:過濾
 6);
 7
 8results.forEach(function (el) {
 9  // ... 處理
10  el.setAttribute('data-scoped', 'true');                      // 處理後標記
11});

每個維度都有意識地選擇 — 不是把所有預設值疊一起。


內在屬性比較:四種 selector 設計

設計誤命中風險未來結構變動的容忍度多元件支援
document.querySelector('.x')低 — 任何同名出現就壞否(只取第一個)
shell.querySelector('.x')中 — shell 內變動才影響部分
shell.querySelector(':scope > .x')最低低 — 結構微調就壞部分
起點當參數 + 過濾 + 標記最低高 — 顯式聲明所有假設完整

推薦:起點當參數 + 過濾。:scope > 只在「結構保證穩定」的關鍵 query 用。


進階技巧

1. 把元件根存成變數一次

1var shell = document.querySelector('.search-shell');
2if (!shell) return;
3// 之後所有 query 都從 shell 開始

避免每次 query 都重新從 document 找元件根 — 一是效能(小)、二是 query 範圍仍維持在 shell 內。

2. 用 closest 反向找根

1function getShell(el) {
2  return el.closest('.search-shell');
3}
4
5document.addEventListener('click', function (e) {
6  var shell = getShell(e.target);
7  if (!shell) return;
8  // 在這個 shell 內處理
9});

事件委派 + closest 適合「多元件實例 + 動態事件處理」 — 各 shell 不需要各自綁 listener、共用一個 listener 用 closest 區分。

3. 起點不存在時提早 return

1var shell = document.querySelector('.search-shell');
2if (!shell) return;

頁面可能沒有 shell(不是搜尋頁),所有後續 query 都會失敗。提早 return 比後續一連串 null check 乾淨。

4. WeakMap 替代 attribute 標記

當不想污染 DOM attribute 時、用 WeakMap 紀錄已處理的元素:

1var processed = new WeakMap();
2
3shell.querySelectorAll('.pagefind-ui__result').forEach(function (el) {
4  if (processed.has(el)) return;
5  // ... 處理
6  processed.set(el, true);
7});

WeakMap 在元素 GC 時自動清理、不留下 DOM 痕跡。適合短生命週期的 idempotency。


設計取捨:起點選擇

Selector 的「起點」有四種做法、各自機會成本不同。這個專案選 B(元件根存變數)當預設、其他做法在特定情境也合理。每張卡片獨立展開該做法的設計細節。

A:document.querySelector 全文件搜

  • 機制:每處 query 都從 document 開始、靠 class name 唯一性命中目標
  • 適合:原型階段、demo 程式碼、確定全頁只有一個目標且未來不會變
  • 代價:未來頁面結構變動(加同類 widget、加 demo 區塊)就壞、且失敗模式是安靜地操作錯元素、不報錯
  • 選 A 的時機:「快速看會不會動」的探索期

B:元件根存變數、之後從變數 query(這個專案的預設)

  • 機制var shell = document.querySelector('.search-shell') 一次、之後所有 query 用 shell.querySelector(...)
  • 選 B 的理由:當前頁面只有一個 shell、未來可能加(站內搜尋 widget、相關搜尋)— 用變數隔離成本低、提早預防
  • 適合:一般客製情境、預期未來結構可能擴展
  • 代價:多一個變數、多一次 query;函式內邏輯變得依賴外部變數

C:函式接受元件根當參數

  • 機制function setup(shell) { shell.querySelector(...) }、外部呼叫 document.querySelectorAll('.shell').forEach(setup)
  • 跟 B 的取捨:B 假設只有一個 shell、C 直接支援多 shell;C 的設計成本前期較高(每函式多一個參數)、但多實例支援是免費的
  • C 比 B 好的情境:頁面同時有多個 shell(例如多語切換頁面)、或計劃中要重用組件到不同頁面

D:事件 + closest 反向找根

  • 機制:監聽全域事件、事件處理時 e.target.closest('.shell') 反向找元件根
  • 跟 B/C 的取捨:B/C 是「初始化時綁定」、D 是「事件發生時動態判斷」— D 適合元件動態出現 / 消失(SPA 路由切換、AJAX 注入)
  • D 比 C 好的情境:元件實例在 runtime 動態增減、用 mutation observer 補打成本反而更高
  • 代價:事件委派的調試比直接綁定難(不知道事件實際從哪傳上來)

設計取捨:範圍深度

querySelector 預設找任意深度、可以收緊到直接子。三種做法:

A:任意深度(這個專案的預設)

  • 機制shell.querySelector('.target') — 子樹任何深度都接受
  • 選 A 的理由:結構可能因 framework 升級微調、容忍微調換取維護彈性
  • 代價:深層結構意外多出同名元素時可能誤命中

B:直接子 :scope > X

  • 機制shell.querySelector(':scope > .target') — 只找直接子
  • 跟 A 的取捨:A 容忍結構微調、B 強制結構穩定 — B 帶來「結構變動立即報錯」的早期偵測
  • B 比 A 好的情境:自家完全控制的結構、想用 selector 失敗當回歸測試訊號

C:特定路徑 :scope > A > B

  • 機制:強制一條精確路徑
  • 代價:結構任何微調都壞、維護成本高
  • C 才合理的情境:寫整合測試的結構斷言、不是 production query

設計取捨:過濾與 idempotency

apply 函式可能被多次觸發(observer / event / 初始化)、過濾保證每元素只處理一次。三種做法:

A:DOM attribute 標記(這個專案的預設)

  • 機制:not([data-scoped]) 過濾 + 處理後 el.setAttribute('data-scoped', 'true')
  • 選 A 的理由:標記跟著 DOM 元素走、元素被移除時自動清理;標記在 devtools 可見、debug 直接
  • 代價:DOM 上多了一個自家用的 attribute(命名衝突風險小)

B:WeakMap 紀錄

  • 機制var processed = new WeakMap(); processed.set(el, true)
  • 跟 A 的取捨:B 不污染 DOM、適合「不想留 attribute 痕跡」的場景;A 在 devtools 可見、debug 較直接
  • B 比 A 好的情境:寫成第三方函式庫、不想對使用者 DOM 加屬性

C:依賴外部呼叫者保證只呼叫一次

  • 機制:apply 內不防護、依賴 init 時只綁一次 listener
  • 成本特別高的原因:observer 觸發 / 事件觸發 / 初始化任一處多呼叫、就產生重複處理 bug;錯誤難以追蹤
  • C 才合理的情境:apply 本身是 idempotent 的(例如 set class 設成已是的值、無副作用)— 此時不需過濾

判讀徵兆

訊號Selector 精準度問題修正動作
多處 document.querySelector 同類元素起點太寬把元件根存變數、之後 query 從變數開始
同頁加第二個元件實例後行為錯亂起點 hardcode改「起點當參數」pattern
Selector 命中了不該命中的元素範圍 / 過濾不足加 ancestor scope、或加 attribute filter
Apply 被多次呼叫產生重複處理沒 idempotency 防線:not([data-flag]) + 處理後標記
結構微調後 selector 失效:scope > 用得太死換成任意深度(預設)
事件處理時不知是哪個元件實例沒反向找根機制closest

核心原則:Selector 精準度不是極致最佳化、是 sanity 防線。三維度(起點 / 範圍 / 過濾)一起設計、每個維度都顯式選擇 — 比從寬泛開始一路追 bug 容易得多。

寬 selector(querySelectorAll('.title'))是「便利位置」、窄 selector 是「對齊位置」 — 這個反相關的更高層原則見 #67 寫作便利度跟意圖對齊反相關