0%

用Vue实现一个谷歌浏览器搜索扩展

Rummage

前言

在平时的工作机中,一般安装了两个浏览器,一个最新版的 Chrome 专用于前端开发,一个 360极速浏览器 用于日常事务处理。但由于某些原因,工作机不再允许安装 360浏览器 ,但是 Chrome浏览器 中缺少了一个非常便捷的功能:工具栏和右键中的自定义引擎搜索,缺少了这个功能非常难受,感觉搜索的效率直线下降。

Chrome 扩展市场中倒是有一些类似功能的插件,但是用起来总是不如意。恰逢疫情突然变重,过年没办法回老家,窝在武汉手写一个浏览器扩展 Rummage 来实现这些功能。

最近刚刚谷歌扩展市场审核通过,所以可以直接安装:Rummage,访问不了的话可以下载 crx 拖到浏览器中:https://magee.lanzous.com/iVZ89moyubg 密码:2021,总结下开发的过程。

产品设计

分析下需求,核心需求主要是三个:

  1. 可自定义配置搜索引擎
  2. 在工具栏中可以输入内容并选择引擎后搜索
  3. 页面中选中文本后,右键菜单中选择引擎搜索指定文本

锦上添花的需求如下:

  1. 自定义配置搜索引擎时,可多配置一些功能:新页面打开,无痕模式打开,默认搜索引擎可以被动态修改
  2. 显示搜索引擎的 Favicon,便于识别
  3. 工具栏中能保存并回显搜索记录
  4. 导入导出配置
  5. 实现国际化

调研

需求分析完了,但是现在有一个问题,我从来没开发过浏览器扩展,两眼一抹黑。搜了很多资料并看了官方文档后总结如下:

扩展文档

如果英文水平不错,可以直接官网文档:https://developer.chrome.com/docs/extensions/mv3/

另外,有大佬之前详细的总结过的一篇博客——【干货】Chrome 插件(扩展)开发全攻略

UI 框架

大致学会了扩展的开发流程,核心就是 manifest.json,通过这个配置文件指向需要的文件,另外还有很多专用的 API 用于和浏览器交互。

所以在开发中,只要能在打包时生成对应文件,就可以开发和打包分开,也就可以引入体积较大的 UI框架 来敏捷开发。但是另一个问题来了,我对 Webpack 玩的不是很溜,自己配置一个太费功夫。

功夫不负有心人,多番搜索发现了 Vue 的一个小众 UI框架 —— Quasar,这个框架不仅样式精美,它的 CLI 中自带一个浏览器扩展开发模式 Quasar Bex,可以自动生成一个与 src 层级并列的 src-bex 文件夹,不管是 manifest.json 还是其他文件都已经配置妥当,只需要按照自己的需要来开发就可以了。

开发

首先给插件起个名叫“翻查”,英文名 “Rummage”,其次扩展还需要个图标,在 Iconfont 上搜了一个挺好看的图标。开发过程中其实只需要三个部分:

  1. 点击扩展图标后的 Popup 弹出页(需先配置 manifest.json 中的 browser_action.default_popup
  2. 内置页面:右键选扩展图标后点击选项,新打开的扩展配置和说明页面(需先配置 manifest.json 中的 options_page
  3. 页面中点击右键后的菜单配置(需先配置 manifest.json 中的 backgroundpermissions

Quasarbex 模式已经配置好,只需要将路由与 manifest.json 文件匹配即可。

下面是 vue-router 的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const routes = [
{
path: '/',
component: () => import('layouts/MainLayout.vue'),
children: [
{ path: '', component: () => import('pages/Index.vue') },
{ path: 'options', component: () => import('pages/Options.vue') },
{ path: 'about', component: () => import('pages/about.vue') },
],
},
{
path: '/popup',
component: () => import('pages/Popup.vue'),
children: [],
},
{
path: '*',
component: () => import('pages/Error404.vue'),
},
];

export default routes;

对应的 manifest.json 配置:

1
2
3
4
5
6
7
8
9
{
// ...
"options_page": "www/index.html#/options",
"browser_action": {
"default_title": "__MSG_ext_title__",
"default_popup": "www/index.html#/popup"
}
// ...
}

浏览器端保存 Favicon

前两种页面的开发就是很普通的 Vue 页面的开发方式,唯一多费了脑筋的地方是保存搜索引擎的 favicon 上。

最后的实现步骤是如下方式:

  1. 先请求给定的链接,再用浏览器自带的 DOMParser API 来解析 DOM
  2. 判断页面的 <head /> 中是否有 rel 属性为 "shortcut icon""icon"<link/> 标签,假如有则保存 faviconurl
  3. 如果没有则将 URL 设为协议:域名/favicon
  4. 统一规范化 URL
  5. 用 Image 对象请求 URL 后,使用 Canvas 加载图片元素。
  6. 将图片导出成为 Base64 格式。

具体的代码写的有点乱,如下所示:

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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
/**
* @description: 获取指定url的favicon链接
* @param {String} url
* @return {String} url
*/
const getFaviconUrl = async (url) => {
if (!isValidHttpOrHttpsUrl(url)) {
return null;
}

try {
url = new URL(url).origin;
let href = await getHref(url);
let pathFormated = formatHref(href, url);
return pathFormated;
} catch (_) {
return null;
}
};

/**
* @description: 校验url
* @param {string} string
* @return {*}
*/
const isValidHttpOrHttpsUrl = (string) => {
let url;
try {
url = new URL(string);
} catch (_) {
return false;
}

return url.protocol === 'http:' || url.protocol === 'https:';
};

/**
* @description: 从 DOM 的 head 中 获取 link 指向的url
* @param {String} url
* @return {String} url
*/
const getHref = async (url) => {
try {
let res = await fetch(url);
let resText = await res.text();
let resHtml = new DOMParser().parseFromString(resText, 'text/html');
let linkHtml =
resHtml.querySelector('link[rel="icon"]') ??
resHtml.querySelector('link[rel="shortcut icon"]');
let href = linkHtml.getAttribute('href');
return href;
} catch (_) {
return null;
}
};

/**
* @description: 规范化 favicon 的 URL 链接
* @param {String} rawHref
* @param {String} url
* @return {String} 返回 favicon 的完整链接
*/
const formatHref = (rawHref, url) => {
try {
let urlObj = new URL(url);
if (rawHref == null) {
return `${urlObj.origin}/favicon.ico`;
}
// start with http or https
if (rawHref.startsWith('http')) {
return rawHref;
}
// start with //
if (rawHref.startsWith('//')) {
return `${urlObj.protocol}${rawHref}`;
}
// start with /
if (rawHref.startsWith('/')) {
return `${urlObj.origin}${rawHref}`;
}

// default, root path + /favicon.ico
} catch (_) {
return null;
}
};

/**
* @description: 将 IMG元素 转为 base64
* @param {IMGElement} imgElement
* @param {Number} width 宽
* @param {Number} height 高
* @return {String} base64
*/
const getBase64Image = (imgElement, width, height) => {
try {
//width、height调用时传入具体像素值,控制大小 ,不传则默认图像大小
let canvas = document.createElement('canvas');
canvas.width = width
? width
: imgElement.width <= 20
? imgElement.width
: 20;
canvas.height = height
? height
: imgElement.height <= 20
? imgElement.height
: 20;

let ctx = canvas.getContext('2d');
ctx.drawImage(imgElement, 0, 0, canvas.width, canvas.height);
let dataURL = canvas.toDataURL('image/gif', 0.8);
return dataURL;
} catch (_) {
return null;
}
};

/**
* @description: 将 图片URL 转为 base64
* @param {String} imgUrl
* @return {String} base64
*/
const getBase64FromFaviconUrl = async (imgUrl) => {
if (imgUrl == null) {
return null;
}
try {
let imgElement = new Image();
imgElement.crossOrigin = '';
imgElement.src = imgUrl;
let imgPromise = new Promise((resolve, reject) => {
if (imgUrl) {
imgElement.onload = function () {
resolve(getBase64Image(imgElement));
};
imgElement.onerror = function () {
reject();
};
}
});
return await imgPromise;
} catch (_) {
return null;
}
};

/**
* @description: 传入链接,返回对应网站的base64
* @param {String} url
* @return {String} base64
*/
const getBase64FromUrl = async (url) => {
// debugger;
try {
let faviconUrl = await getFaviconUrl(url);
let faviconBase64 = await getBase64FromFaviconUrl(faviconUrl);
return faviconBase64;
} catch (_) {
return null;
}
};

background.js 的模块化

最后一种 background.js 的开发主要是为了能够配置右键菜单。都是对照着谷歌官方文档开发就可以,但是有一个地方比较特殊——模块化。

如果想 importexport 一些公用方法,普通的 import 'XXXX' from 'XXXX.js';的模式是不生效的,得用如下的特殊方法:

模块 js 导出:

1
2
3
4
5
6
7
8
// func.js
const funcA = () => {
console.log('funcA函数执行');
};
const funcB = () => {
console.log('funcB函数执行');
};
export { funcA, funcB };

background.js 导入:

1
2
3
4
5
6
7
8
// background.js
(async () => {
const funcURL = chrome.runtime.getURL('js/func.js');
const funcMain = await import(funcURL);

funcMain.funcA(); // output: funcA函数执行
funcMain.funcB(); // output: funcB函数执行
})();

存储

对于扩展来说,只是单纯的展示页面,可以用普通页面存储用到的 cookieslocalStorage。但是如果需要与浏览器交互,比如给右键配置菜单,就需要用扩展专用的负责存储的 APIchrome.storage.localchrome.storage.sync

存储 chrome.storage.local chrome.storage.sync window.localStorage
总最大限制 可无限大 100KB 5MB
单条最大限制 可无限大 8KB 5MB
修改频率限制 可无限大 8KB 1800 次/小时
存储格式 可直接存储对象 可直接存储对象 只可存储字符串
数据同步方式 手动导入导出 自动跨设备同步 手动导入导出
事件 支持 支持 其他页面修改才会触发
可用位置 可用于 content 和 background 可用于 content 和 background 只能用于插件自身页面

写了一些工具方法,把的异步存储操作的回调变为了 async:

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
const setStorageLocal = async (items) => {
let result = await new Promise((resolve) => {
chrome.storage.local.set(items, () => {
// 通知保存完成。
console.log('保存成功', items);
resolve(items);
});
});
return result;
};

const getStorageLocal = async (keys) => {
return await new Promise((resolve) => {
chrome.storage.local.get(keys, (items) => {
console.log('获取成功', items);
resolve(items);
});
});
};

const removeStorageLocal = async (keys) => {
return await new Promise((resolve) => {
chrome.storage.local.remove(keys, () => {
console.log('删除成功', keys);
resolve(keys);
});
});
};

const clearStorageLocal = async () => {
return await new Promise((resolve) => {
chrome.storage.local.clear(() => {
console.log('清空成功');
resolve(true);
});
});
};

发布

Quasar cli 中自带的 bex 打包模式,将插件打包到 dist 中,有 Chrome 版,FireFox 版和未压缩版。

dist

发布到 Google 应用市场需要做一些准备工作,主要是需要以下准备内容:

  1. 如果没有谷歌 Web 的开发者账户,需要准备可支付 5 美元的信用卡来注册开发者。如果开发移动端 APP,注册谷歌 Play 开发者需要 25 美元,相比下页面端还是便宜。
  2. 扩展图标 128x128 像素,png 格式。
  3. 插件名字,根据你扩展的 i18n 支持的数量准备不同语言下的名字。
  4. 插件介绍,与上条类似。
  5. 屏幕截图,1280x800640x400 JPEG24PNG(无 alpha 透明层),每种语言不得多于 5 张,不得少于一张。
  6. 全球通用的屏幕截图:不区分语言,条件与上一条相同。
  7. 小型宣传图块,不区分语言,440x280JPEG24PNG(无 alpha 透明层)。
  8. 大型宣传图块,不区分语言,920x680JPEG24PNG(无 alpha 透明层)。
  9. 顶部宣传图块,不区分语言,1400x560JPEG24PNG(无 alpha 透明层)。
  10. manifest.json 中需要启用的权限,每种权限都需要说明用途。

screen

全都填完后就可以提交审核,大概需要一周的时间。发布到 EDGE 或者 FireFox 也是类似的步骤。


前端记事本,不定期更新,欢迎关注!


👆 全文结束,棒槌时间到 👇