简介

这是一个 Chrome 插件,用于在关闭最后一个Tab时自动打开一个新标签页而不是关闭浏览器,行为表现模拟 世界之窗浏览器

本人也算是世界之窗浏览器的老粉丝了,大约十五年前接触到这款浏览器之后就因为它简洁、轻巧、速度快、功能全面且人性化而一直使用。但是由于其被 360 公司收购之后逐渐停止更新,而且个人常用操作系统逐渐全面更换为 Linux,不得已只能强忍不适,将常用浏览器切换为 Chrome,多年过去也已经彻底习惯了。直到前些天看到 V2EX 上的这篇帖子:《浏览器关闭最后一个标签页后的行为》,又使我回想起了那些年有世界之窗相伴的日子,回忆起切换到 Chrome 后关闭最后一个标签页后浏览器会直接关闭而不是保留一个新标签页这一行为的不适与别扭——当时的我没有能力,只是个普通的计算机爱好者,而现在作为伪全栈开发有了折腾的能力,趁此机会就尝试编写了本插件,试图找回熟悉的感觉~

效果

demo

实现细节

Chrome插件开发学习

主要参考资料如下:

开发过程

查阅API文档后,一开始的想法是,通过监听 chrome.tabs.onRemoved 事件,当判断刚刚关掉的是最后一个 tab,就通过 chrome.tabs.create 创建一个新标签页,经过尝试发现事情并没有那么简单:因为在关闭最后一个标签页时, chrome.tabs.onRemoved 事件要么根本来不及回调浏览器进程就已经退出,要么虽然事件触发,但是创建标签页不成功。之后又是各种尝试,包括使用 content_script 向每个页面注入 window.onbeforeunload 的监听,在页面关闭前向后台服务脚本发送消息执行判断逻辑,进而在需要的时候创建新标签页,结果虽然在一定程度上达到了预期效果,但是个别网站却会失效,具体原因不明😑。最终是参考了 Don't Close Window With Last Tab 这个插件的方式,通过预先在最左侧创建一个固定的空白标签页来阻止浏览器自动退出,最终才实现了上面演示的效果。

创建工程目录&manifest.json

清单文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"name": "NewTabBeforeClose",
"version": "1.0",
"author": "DebuggerX",
"description": "当关闭最后一个Tab时打开一个新标签页而不是关闭浏览器",
"permissions": [
"tabs"
],
"manifest_version": 3,
"background": {
"service_worker": "background.js"
}
}

参考 Migrating to Manifest V3Migrating from background pages to service workers,新版插件需要将清单版本设为 "manifest_version": 3,并且后台服务脚本需要设为 "service_worker": "background.js"。因为要调用api对标签页进行增删改查操作,所以还需要加入 tabs 权限的声明。

简单实现初版功能

编辑 background.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 为chrome.tabs的onRemoved、onCreated和onUpdated事件设置监听
[chrome.tabs.onRemoved, chrome.tabs.onCreated, chrome.tabs.onUpdated].forEach((evt) => {
evt.addListener(() => {
// 加入延时函数是因为调试过程中发现,当标签页变化的事件触发回调时,如果立即进行 chrome.tabs.query,拿到的结果可能不是最新的
setTimeout(async () => {
// 使用该api可以查询出当前浏览器的所有标签页的信息数组,然后统计普通网页标签页的数量和以“chrome://”开头的系统标签页的数量
const tabs = await chrome.tabs.query({});
let normalTabCount = 0;
let chromeTabCount = 0;
tabs.forEach(t => {
if (t.url.startsWith('chrome://')) {
chromeTabCount++;
} else {
normalTabCount++;
}
});

if (normalTabCount === 1 && chromeTabCount === 0) {
// 如果没有系统标签页,并且只有一个普通标签页,那么就在最左侧创建一个固定的新标签页
chrome.tabs.create({ pinned: true, index: 0, active: false });
} else if (normalTabCount === 0 && chromeTabCount === 1 && tabs[0].pinned) {
// 如果只有一个系统标签页,而且该标签页是固定状态,那么认为就是上面一种情况创建了固定新标签页,然后用户关闭了最后一个普通标签页
// 此时将这个固定的新标签页更新,取消其固定状态,这样实现了关闭最后一个标签页时打开新标签页的效果
chrome.tabs.update(tabs[0].id, {
pinned: false,
});
} else if (tabs[0].pinned && (normalTabCount > 1 || chromeTabCount > 1 || (normalTabCount === 0 && tabs[1].url.startsWith('chrome://')))) {
// 在不需要的时候移除用于防止浏览器关闭的固定标签页,比如用户打开了第二个普通页面,或者虽然没有普通页面,但其他页面都是系统标签页的时候
chrome.tabs.remove(tabs[0].id);
}
}, 100);
});
});

这样就已经实现了最核心的需求,接下来再进行一些优化和特殊情况的处理。

加入防抖机制

上面的代码虽然能够实现效果,但是在创建和删除固定标签页的时候会出现标签页闪现,或者连续创建多个便签页然后又消失的问题。
这是因为同时监听了tab的增删改三个操作,新打开页面的时候可能会同时触发多个事件,就导致了监听函数在短时间内多次执行,所以加个防抖即可解决这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const handleTabsChange = async () => { ... }

let timer = null;
function debounce(fn, delay) {
return (...args) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}

[chrome.tabs.onRemoved, chrome.tabs.onCreated, chrome.tabs.onUpdated].forEach((evt) => {
evt.addListener(debounce(handleTabsChange, 100));
});

和常规的防抖函数相比,这里我把用于判断延迟函数执行的 timer 变量放到了全局而不是闭包函数内部,是因为常规的防抖函数是限制当前那一行方法的抖动执行,而我这里是需要在任何情况下对 handleTabsChange 这个函数本身的执行进行限制。打个比方,利用常规的防抖函数对页面上的一个按钮进行处理,快速点击这个按钮三次,实际只会执行一次。但是如果是有三个按钮分别绑定了防抖后的函数,快速点击这三个按钮9次,实际会执行3次;而用我这个防抖函数,相同的情况下还是只会执行一次点击事件的回调。

处理多窗口时的行为

上面的代码在只有一个浏览器窗口的时候已经可以正常工作了,但是如果打开了多个浏览器窗口,那么新开的窗口还是无法实现保留最后一个标签页的效果,所以还需要对上面的代码进行改造,对获取到的每个标签页进行 windowId 的判断。
而为了处理拖动标签页而触发的新建/合并窗口行为,还需要加入对 chrome.windowsonCreatedonRemoved 的监听。
还有在拖动行为进行的过程中,虽然窗口事件已经触发,但是此时尝试获取标签页信息时会抛出 Tabs cannot be queried right now (user may be dragging a tab). 的错误,所以还需要加入事件处理的延迟重试机制。

最终的代码修改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
class TabCount {
normalTabCount = 0;
chromeTabCount = 0;
tabs = [];
}

let justDragged = false;

const handleTabsChange = async () => {
let tabs;
try {
tabs = await chrome.tabs.query({});
} catch (error) {
if (error.message.includes('dragging')) {
debounce(handleTabsChange, 200)();
justDragged = true;
}
}

if (tabs) {
if (justDragged) {
justDragged = false;
return debounce(handleTabsChange, 200)();
}
} else return;
justDragged = false;

const tabsCount = [];
tabs.forEach(t => {
if (!tabsCount[t.windowId]) {
tabsCount[t.windowId] = new TabCount();
}
if (t.url.startsWith('chrome://')) {
tabsCount[t.windowId].chromeTabCount++;
} else {
tabsCount[t.windowId].normalTabCount++;
}
tabsCount[t.windowId].tabs[t.index] = t;
});

tabsCount.forEach(tabCount => {
if (tabCount.normalTabCount === 1 && tabCount.chromeTabCount === 0) {
chrome.tabs.create({
pinned: true,
index: 0,
active: false,
windowId: tabCount.tabs[0].windowId,
});
} else if (tabCount.normalTabCount === 0 && tabCount.chromeTabCount === 1 && tabCount.tabs[0].pinned) {
chrome.tabs.update(tabCount.tabs[0].id, {
pinned: false,
});
} else if (tabCount.tabs[0].pinned
&& (tabCount.normalTabCount > 1
|| tabCount.chromeTabCount > 1
|| (tabCount.normalTabCount === 0 && tabCount.tabs[1].url.startsWith('chrome://'))
)
) {
chrome.tabs.remove(tabCount.tabs[0].id);
}
});
}

let timer = null;

function debounce(fn, delay) {
return (...args) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}

[
chrome.tabs.onRemoved,
chrome.tabs.onCreated,
chrome.tabs.onUpdated,
chrome.windows.onCreated,
chrome.windows.onRemoved,
].forEach((evt) => {
evt.addListener(debounce(handleTabsChange, 200));
});

如此,想要实现的效果就全部实现了。

更新(2022年8月29日)

更新新版本 Chrome(104) 以后,tab 发生变化时,空白标签页的创建和删除变得异常缓慢,经常要等十几秒才有反应。一开始以为是新版浏览器调整了 API 的行为,搜索无果后,调试发现是上面代码中有一处极其浪费性能的垃圾代码……
举例来说就是声明了 const tabsCount = [],然后用 tabsCount[t.windowId] = new TabCount() 的方式向这个数组中添加元素,最后 tabsCount.forEach 遍历使用。在之前版本的 Chrome 中,windowId 的值普遍较小,基本都是十位数以内,所以这样使用没有太大问题;但是更新新版以后, windowId 的生成方式可能发生了变化,其值变得很大,此时 tabsCount 就会变成一个包含大量 Empty 元素的超长的数组,对其遍历会变得非常耗时。所以对相关代码进行如下优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const tabsCount = {};
for await (const t of tabs) {
if (!(t.windowId in tabsCount)) {
const w = await chrome.windows.get(t.windowId);
if (w.type === 'normal') {
tabsCount[t.windowId] = new TabCount();
} else {
tabsCount[t.windowId] = null;
}
}

if (tabsCount[t.windowId] === null) continue;

if (t.url.startsWith('chrome://')) {
tabsCount[t.windowId].chromeTabCount++;
} else {
tabsCount[t.windowId].normalTabCount++;
}
tabsCount[t.windowId].tabs[t.index] = t;
}

Object.values(tabsCount).filter(e => e !== null).forEach( ... );

插件&源码地址

GitHub: https://github.com/debuggerx01/new_tab_before_close
WebStore: NewTabBeforeClose