前言
Firefox 137 以後內建了原生分頁群組(Tab Groups)功能,可以將多個分頁歸組並手動折疊。折疊後,滑鼠 hover 群組標籤會彈出垂直預覽面板,點選即可切換分頁——這個設計本意是「不展開就能切換」,但實際上點選後 Firefox 仍會自動將群組展開,必須再手動點一次才能折疊回去,造成操作上的不便。
本文說明如何自製擴充套件,讓 Firefox 在切換到群組內分頁後自動折疊該群組,並搭配現有的 Auto Collapse Tab Groups 套件形成完整方案。
環境需求
- Firefox 149+(建議;
tabGroups.update()最低需求為 Firefox 139) - 文字編輯器(Cursor、VS Code 等均可)
- 基本的 JavaScript 知識
Firefox 原生行為說明
在開始開發前,先理解 Firefox 分頁群組的現有行為:
可以做到的(原生):
- 點群組標籤手動折疊 / 展開
- hover 群組標籤顯示垂直預覽面板,點選切換分頁
- 搭配 Auto Collapse Tab Groups 套件,切換到群組外的分頁時自動折疊其他群組
無法做到的(原生缺口):
- 從 hover 面板點選分頁後,自動將該群組折疊回去
關鍵技術原因: Firefox 的事件觸發順序如下:
- 使用者點擊 hover 預覽面板中的某個分頁
tabs.onActivated觸發(此時群組仍是collapsed = true)- Firefox 內部才將群組展開(
collapsed變為false)
這導致在 onActivated 時提前偵測 collapsed 狀態會誤判,必須等待 Firefox 完成展開後再執行折疊。
被排除的方案
| 方案 | 排除原因 |
|---|---|
| Firefox 原生設定 | 無此設定,這不是內建功能 |
| 切換到垂直側欄分頁 | 使用者需求是水平分頁列,非側欄模式 |
| 只用 Auto Collapse Tab Groups | 只處理切換到群組外時折疊其他群組,不處理切換回同群組後的折疊 |
在 onActivated 中直接判斷 collapsed 狀態 | 事件觸發時群組尚未展開,會誤判為「已折疊」而跳過 |
| Firefox 138 以下的 API | tabGroups.update() 於 Firefox 139 才加入 |
擴充套件開發
檔案結構
auto-collapse-on-select/
├── manifest.json
├── background.js
├── popup.html
├── popup.js
└── icons/
└── icon48.png
manifest.json
{
"manifest_version": 2,
"name": "Auto Collapse Tab Group After Select",
"version": "1.2.0",
"description": "切換到群組內的分頁後,自動將該群組折疊,保持分頁列整潔。",
"permissions": [
"tabs",
"tabGroups",
"storage"
],
"background": {
"scripts": ["background.js"],
"persistent": true
},
"browser_action": {
"default_icon": {
"48": "icons/icon48.png"
},
"default_title": "Auto Collapse Tab Group After Select",
"default_popup": "popup.html"
},
"icons": {
"48": "icons/icon48.png"
}
}
注意事項:
tabGroupspermission 是操作分頁群組的必要權限storagepermission 用於儲存使用者設定(開關狀態、延遲時間)- 三個 permission 缺一不可,漏掉任一個會導致對應功能靜默失敗或拋出例外
background.js(核心邏輯)
let enabled = true;
let collapseDelay = 300; // 預設延遲 ms
browser.storage.local.get(["enabled", "collapseDelay"]).then((result) => {
if (typeof result.enabled !== "undefined") enabled = result.enabled;
if (typeof result.collapseDelay !== "undefined") collapseDelay = result.collapseDelay;
console.log("[AutoCollapse] 初始化完成,enabled =", enabled, "delay =", collapseDelay);
}).catch((e) => {
console.error("[AutoCollapse] storage 讀取失敗:", e);
});
browser.runtime.onMessage.addListener((message) => {
if (message.type === "setEnabled") {
enabled = message.value;
browser.storage.local.set({ enabled }).catch(() => {});
}
if (message.type === "setDelay") {
collapseDelay = message.value;
browser.storage.local.set({ collapseDelay }).catch(() => {});
}
if (message.type === "getSettings") {
return Promise.resolve({ enabled, collapseDelay });
}
});
browser.tabs.onActivated.addListener(async ({ tabId, windowId }) => {
if (!enabled) return;
try {
const tab = await browser.tabs.get(tabId);
const gid = tab.groupId;
// groupId 為 -1 表示不屬於任何群組
if (gid === undefined || gid === -1) return;
// 關鍵:不在這裡判斷 collapsed 狀態
// 因為 hover 點選時 onActivated 觸發的當下,Firefox 尚未展開群組
// 若在此判斷 collapsed === true 會誤判為「已折疊」而跳過
await delay(collapseDelay);
// 確認仍是 active(避免快速切換時重複觸發)
const [cur] = await browser.tabs.query({ active: true, windowId });
if (!cur || cur.id !== tabId) return;
// 延遲後再查狀態,若群組已是折疊就不重複操作
const group = await browser.tabGroups.get(gid);
if (group.collapsed) return;
await browser.tabGroups.update(gid, { collapsed: true });
console.log("[AutoCollapse] 折疊完成 groupId=", gid);
} catch (e) {
console.error("[AutoCollapse] 例外:", String(e));
}
});
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
設計重點說明:
onActivated 觸發時不做 collapsed 狀態的前置判斷,直接進入延遲等待。等待結束後才查詢群組狀態,此時 Firefox 已完成展開動作,查詢結果才是真實狀態。延遲時間預設 300 ms,可由使用者自行調整。
popup.html
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<style>
* { box-sizing: border-box; }
body {
font-family: system-ui, sans-serif;
width: 240px;
padding: 14px 16px;
margin: 0;
background: #1c1b22;
color: #fbfbfe;
}
h3 { margin: 0 0 12px 0; font-size: 13px; color: #cfcfda; font-weight: 600; }
.row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
.label { font-size: 13px; flex: 1; }
.sublabel { font-size: 11px; color: #9f9fad; margin-top: 1px; }
.switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; inset: 0; background: #52525e; border-radius: 20px; cursor: pointer; transition: 0.2s; }
.slider::before { content: ""; position: absolute; width: 14px; height: 14px; left: 3px; top: 3px; background: white; border-radius: 50%; transition: 0.2s; }
input:checked + .slider { background: #9059ff; }
input:checked + .slider::before { transform: translateX(16px); }
.delay-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
.delay-val { font-size: 12px; color: #9059ff; font-weight: 600; min-width: 48px; text-align: right; }
input[type=range] { width: 100%; accent-color: #9059ff; cursor: pointer; height: 4px; }
.delay-ticks { display: flex; justify-content: space-between; font-size: 10px; color: #6b6b7b; margin-top: 3px; padding: 0 1px; }
.divider { border: none; border-top: 1px solid #3a3944; margin: 12px 0; }
.status { font-size: 11px; color: #9f9fad; text-align: center; margin-top: 10px; }
.status.on { color: #9059ff; }
</style>
</head>
<body>
<h3>選分頁後自動折疊群組</h3>
<div class="row">
<div class="label">啟用自動折疊</div>
<label class="switch">
<input type="checkbox" id="toggle">
<span class="slider"></span>
</label>
</div>
<hr class="divider">
<div class="delay-section">
<div class="delay-header">
<div class="label">折疊延遲</div>
<div class="delay-val" id="delay-val">300 ms</div>
</div>
<input type="range" id="delay-slider" min="100" max="1000" step="50" value="300">
<div class="delay-ticks">
<span>100</span><span>300</span><span>500</span><span>750</span><span>1000</span>
</div>
<div class="sublabel" style="margin-top:6px;">數值越小越快折疊;建議 200–400 ms</div>
</div>
<div class="status" id="status">讀取中…</div>
<script src="popup.js"></script>
</body>
</html>
popup.js
const toggle = document.getElementById("toggle");
const status = document.getElementById("status");
const slider = document.getElementById("delay-slider");
const delayVal = document.getElementById("delay-val");
function updateStatus(enabled) {
status.textContent = enabled ? "✓ 已啟用 — 切換分頁後自動折疊" : "已停用";
status.className = enabled ? "status on" : "status";
}
browser.runtime.sendMessage({ type: "getSettings" }).then(({ enabled, collapseDelay }) => {
toggle.checked = enabled;
updateStatus(enabled);
slider.value = collapseDelay;
delayVal.textContent = collapseDelay + " ms";
});
toggle.addEventListener("change", () => {
const enabled = toggle.checked;
browser.runtime.sendMessage({ type: "setEnabled", value: enabled });
updateStatus(enabled);
});
slider.addEventListener("input", () => {
delayVal.textContent = slider.value + " ms";
});
slider.addEventListener("change", () => {
browser.runtime.sendMessage({ type: "setDelay", value: parseInt(slider.value) });
});
遇到的問題與解決方式
問題一:載入時出現「does not contain a valid manifest」
原因: 在 about:debugging 載入時選擇了 .zip 壓縮檔,而非解壓縮後的 manifest.json 檔案。
解決: 先解壓縮 zip,進入資料夾,選擇 manifest.json 檔案本身載入。
問題二:browser.storage is undefined
原因: manifest.json 的 permissions 陣列缺少 "storage",導致 browser.storage API 不可用,background.js 在初始化時就拋出例外,後續的事件監聽器根本沒有掛載。
解決: 在 permissions 加入 "storage"。
問題三:從 hover 面板點選後不會折疊
原因: 這是最關鍵的 bug。原本的邏輯在 onActivated 觸發後立即查詢群組的 collapsed 狀態,因為 Firefox 此時還沒展開群組,查到的值是 true,程式誤判為「群組已折疊,不需動作」而跳過。
解決: 移除 onActivated 觸發時的前置 collapsed 狀態判斷,改為直接進入延遲等待,等 Firefox 完成展開後再查詢狀態並執行折疊。
// 錯誤做法:在 onActivated 觸發時就判斷 collapsed
const group = await browser.tabGroups.get(gid);
if (group.collapsed) return; // hover 點選時這裡會誤判為 true
// 正確做法:先等待,讓 Firefox 完成展開後再判斷
await delay(collapseDelay);
const group = await browser.tabGroups.get(gid);
if (group.collapsed) return; // 此時查到的才是真實狀態
暫時性安裝(開發測試用)
- 開啟
about:debugging - 點左側「此 Firefox」
- 點「載入暫時性附加元件」
- 選擇資料夾內的
manifest.json
注意: 暫時性安裝在 Firefox 重啟後會消失,修改程式碼後點擴充套件旁的「重新整理」按鈕即可套用,不需重新打包。
正式安裝與上架
打包成 .xpi
Firefox 的擴充套件格式是 .xpi,需從資料夾內部打包,確保 zip 根目錄直接是 manifest.json:
cd auto-collapse-on-select
zip -r ../auto-collapse-on-select.xpi *
上架到 Firefox Add-ons (AMO)
- 到 addons.mozilla.org/developers 登入或建立開發者帳號
- 點「Submit a New Add-on」
- 選擇發佈方式:
- On this site:公開上架,需通過 Mozilla 程式碼審查(數天至數週)
- On your own:自行散佈,仍需提交讓 Mozilla 自動簽署,取回已簽署的
.xpi後才能讓他人安裝
- 上傳
.xpi,填寫名稱、描述、截圖等資訊 - 提交審查
Mozilla 簽署說明: 未經簽署的擴充套件只能在 Firefox Developer Edition 或 Nightly 中搭配 xpinstall.signatures.required = false 安裝,一般 Firefox 無法使用。
上架前建議補充
- 替換
icon48.png為正式設計的圖示(建議同時提供icon96.png) - 在
manifest.json補上author與homepage_url欄位 - 準備 1–2 張截圖供 AMO 頁面展示
- 撰寫英文版說明文字(AMO 主要以英文為主)
完整方案組合
搭配使用以下兩個套件,可達到最完整的分頁群組整潔體驗:
| 套件 | 功能 |
|---|---|
| Auto Collapse Tab Groups | 切換到群組外的分頁時,自動折疊其他群組 |
| 本文自製套件 | 切換到群組內的分頁後,自動折疊該群組自身 |
兩者互補,合在一起就能讓所有群組在任何情況下保持折疊狀態。
結論
Firefox 原生的 hover 預覽面板雖然設計上是為了「不展開就能切換」,但實際上點選後仍會觸發展開,這個行為是 Firefox 內部的設計缺陷,目前沒有原生設定可以改變。
透過監聽 tabs.onActivated 並在適當延遲後呼叫 tabGroups.update() 強制折疊,可以繞過這個限制。關鍵在於延遲的時機:必須等 Firefox 完成展開動作後再執行折疊,而非在 onActivated 觸發時就判斷狀態。
這個方案需要 Firefox 139 以上版本,並且在 Firefox 重啟後暫時安裝會消失,若要永久使用需透過 AMO 正式簽署安裝。
不過不管怎麼用,群組分頁不能用快速鍵展開還是不好用,即便我把摺疊時間修改到 10S 也一樣,設計上本身就有點問題,最後還是只能當雞肋
