核心原則

單一函式做 ≥ 3 件無關的事就拆。 每個函式只負責一個職責、有明確的 input / output、可以獨立 debug 與測試。Init function 變成「組合各職責 function 的 orchestrator」。


為什麼拆函式

商業邏輯

單一函式做多件事的成本:

規模維護痛點
一函式 50 行做 1 件事低 — 容易讀、職責清楚
一函式 100 行做 3 件事中 — 邏輯交織、debug 要分辨哪段
一函式 200 行做 5+ 件事高 — 沒人想動、改一處可能影響別處

拆函式的成本是「多寫幾個函式名與簽名」、收益是「每個函式範圍小、debug 容易、可單獨重用」。

拆的依據是「職責」、不是行數

拆法結果
按行數機械拆切出沒邏輯意義的片段、更亂
按職責拆每個函式名能描述「做什麼」
按 input / output 拆函式變得 testable、可組合

按職責拆的判斷:能不能用一個動詞片語描述函式做什麼?做不到 → 多個職責、該拆。


這次任務的拆分機會

觀察

setupScopeFilter() 現況做 5 件事:

 1function setupScopeFilter() {
 2  // 1. 找元素
 3  var scopeEl = document.querySelector('.search-scope');
 4  var input   = document.querySelector('.pagefind-ui__search-input');
 5  // ...
 6
 7  // 2. 量測 scope 高度寫回 CSS 變數
 8  function syncScopeHeight() { ... }
 9  syncScopeHeight();
10  new ResizeObserver(syncScopeHeight).observe(scopeEl);
11
12  // 3. 把 filter-panel 搬到 sidebar (position function)
13  function place() { ... }
14
15  // 4. 註冊 scope filter listener + apply
16  function apply() { ... }
17  scopeEl.addEventListener('change', apply);
18  // ...
19
20  // 5. Reorder filter blocks
21  function reorderFilters() { ... }
22  reorderFilters();
23}

5 個職責塞在一個函式:找元素、量高度、搬 slot、scope filter、reorder filter。

判讀

按職責拆成獨立函式:

 1function findSearchElements(shell) {
 2  return {
 3    shell:  shell,
 4    ui:     shell.querySelector('.pagefind-ui'),
 5    input:  shell.querySelector('.pagefind-ui__search-input'),
 6    drawer: shell.querySelector('.pagefind-ui__drawer'),
 7    filter: shell.querySelector('.pagefind-ui__filter-panel'),
 8    scope:  shell.querySelector('.search-scope'),
 9  };
10}
11
12function syncScopeHeight(scopeEl) {
13  function update() {
14    var h = scopeEl.offsetHeight || 56;
15    document.body.style.setProperty('--search-scope-h', h + 'px');
16  }
17  update();
18  new ResizeObserver(update).observe(scopeEl);
19}
20
21function setupFilterSlotSwap(filter, drawer, slot, breakpoint) {
22  var mql = window.matchMedia('(min-width: ' + breakpoint + 'px)');
23  function place() {
24    if (mql.matches) slot.appendChild(filter);
25    else drawer.insertBefore(filter, drawer.firstChild);
26  }
27  place();
28  mql.addEventListener('change', place);
29}
30
31function reorderFilters(filterPanel, desiredOrder) {
32  var blocks = filterPanel.querySelectorAll('.pagefind-ui__filter-block');
33  var byKey = {};
34  blocks.forEach(function (b) {
35    var key = b.querySelector('.pagefind-ui__filter-name').textContent.trim().toLowerCase();
36    byKey[key] = b;
37  });
38  desiredOrder.forEach(function (k) {
39    if (byKey[k]) filterPanel.appendChild(byKey[k]);
40  });
41}
42
43function setupScopeFilter(scopeEl, input, ui) {
44  function getScope() { ... }
45  function apply() { ... }
46  function schedule() { ... }
47  scopeEl.addEventListener('change', schedule);
48  input.addEventListener('input', schedule);
49  new MutationObserver(schedule).observe(ui, { childList: true, subtree: true });
50}

init() 變成 orchestrator:

 1function init() {
 2  var shell = document.querySelector('.search-shell');
 3  if (!shell) return;
 4
 5  waitForElement(shell, '.pagefind-ui__drawer', function () {
 6    var els = findSearchElements(shell);
 7    syncScopeHeight(els.scope);
 8    setupFilterSlotSwap(els.filter, els.drawer, document.querySelector('.search-filter-slot'), 1400);
 9    reorderFilters(els.filter, ['type', 'tag']);
10    setupScopeFilter(els.scope, els.input, els.ui);
11  });
12}
13init();

每個拆出的函式:

  • 名字描述做什麼(動詞 + 名詞)
  • 接受需要的元素當參數(不依賴全局)
  • 不知道其他函式的存在(解耦)

內在屬性比較:四種函式拆分粒度

粒度維護成本Debug 範圍可重用性
一個 mega init function高 — 200+ 行交織整個函式都要看低 — 跟特定 setup 綁
按行數機械拆(每 30 行一份)中 — 切出無意義片段
按職責拆低 — 每函式單一職責函式內部、範圍小
按職責拆 + class 包裝範圍小最高 — 多實例

優先按職責拆 — 函式名表達 intent、debug 範圍小、單獨可測。


拆函式的具體技巧

1. 函式名是動詞片語

1syncScopeHeight()           // 動詞 + 對象
2setupFilterSlotSwap()       // 動詞 + 對象
3reorderFilters()            // 動詞 + 對象
4findSearchElements()        // 動詞 + 對象

不要:

1filter()        // 動詞模糊(filter 是動詞還是名詞?)
2handle()        // 太抽象
3init()          // 只有 orchestrator 用、不要散在各處

2. 參數是該函式需要的、不傳一個 mega object

1// 好 — 函式知道它需要什麼
2function syncScopeHeight(scopeEl) { ... }
3
4// 較差 — 函式拿到一堆無關的東西、不清楚依賴
5function syncScopeHeight(allElements) {
6  var scope = allElements.scope;
7  ...
8}

明確參數 = 明確依賴 = 容易測試。

3. 副作用集中在一處

1function syncScopeHeight(scopeEl) {
2  function update() {
3    document.body.style.setProperty('--search-scope-h', scopeEl.offsetHeight + 'px');
4  }
5  update();
6  new ResizeObserver(update).observe(scopeEl);
7}

副作用(DOM 變動、event listener、observer)都在這個函式內。沒散到別處。

4. 不依賴外部變數

1// 好 — 純函式、依賴只在參數
2function reorderFilters(filterPanel, desiredOrder) { ... }
3
4// 較差 — 依賴外部全局變數
5var desiredOrder = ['type', 'tag'];
6function reorderFilters(filterPanel) {
7  // 用了 desiredOrder
8}

純函式 = 無隱式依賴 = 重用方便、測試方便。


設計取捨:大型 init function 的拆分策略

四種做法、各自機會成本不同。這個專案選 A(按職責拆 + 純函式)當預設、其他做法在特定情境合理。

A:按職責拆 + 純函式 + init 當 orchestrator(這個專案的預設)

  • 機制:每職責一個函式(動詞 + 對象命名)、依賴透過參數傳入、init 組合各函式
  • 選 A 的理由:debug 範圍小(職責 = 函式 = grep 範圍)、單獨可測、可重用
  • 適合:> 50 行的 init function、預期長期維護
  • 代價:多寫幾個函式名與簽名、檔案 LOC 略增

B:按行數機械拆(每 30 行一份)

  • 機制:固定 LOC 拆檔、不考慮職責邊界
  • 跟 A 的取捨:B 拆完後切片無邏輯意義、A 切片各自完整;B 更亂、debug 反而更難
  • B 是反模式:「行數」不是有意義的拆分判準 — 拆完後切片無邏輯意義、debug 反而更難

C:保持 mega init function

  • 機制:所有 setup 邏輯塞在一個 init 內
  • 跟 A 的取捨:C 一個函式看完所有 setup、A 散在多函式;但 C 在 200+ 行時改一處要小心整體
  • C 才合理的情境:< 50 行的 init、職責本來就單一

D:按職責拆 + class 包裝多實例

  • 機制:把 setup 包成 class、new SearchShell(rootEl) 建立實例
  • 跟 A 的取捨:D 多實例支援更乾淨(每實例自己的 state)、A 用純函式 + 起點當參數也能達成
  • D 比 A 好的情境:元件有複雜的內部 state、預期會被多次實例化(library 設計)

判讀徵兆

訊號Refactor 動作
一個函式 100+ 行列出做的事、按職責拆
函式名抽象(init / handle / process改名動詞 + 對象、表達 intent
函式內讀外部全局變數把依賴改為參數、純函式化
Debug 時要 grep 整個函式找哪段邏輯拆完後職責 = 函式 = grep 範圍縮小
同一段邏輯複製到別處拆成獨立函式、兩處引用

核心原則:函式是「做一件事」的單位。一個函式越多職責、debug 與重用越難。拆 = 投資、回報是未來的維護成本下降。