一篇文章教你顺利入门和开发 chrome 扩展程序
前言
关于 chrome extension 的开发经验总结或说明文档等资料很多,很多人在写,然而,我也是一员。但是,也许这篇文章,可能给你一些不一样的感受。
这里介绍的是 80% 你要开发扩展会碰到的问题
前面部分大多数是一些基础介绍,和别人的资料大同小异,但是用的是通俗的语言或者我自己理解来描述的,不是拷贝官方的描述,不然的话,你干脆看官方文档就好啦,干嘛还来我这里折腾对吧,也许这些通俗的描述,更方便你理解(当然不排除也会有官方的话语)
后面部分多为一些我在项目中总结的方法,这部分就是在别人的资料可能看不到的地方了,当然,这些方法也许不通用,因为毕竟是基于我项目里的,但是尽量总结一套方法出来。
废话不多说,咱们开始吧...
目录
WHAT
谷歌扩展(chrome extension),在认识之前,首先要明确一个观念,这种扩展程序,实际上不是一个 exe、app 之类的程序,下载了本地打开运行安装,本质上,它就是一个网页,写的用的都是前端的语言,高档点说是一个程序,通俗来讲, 就是运行在浏览器上的一个网站,网页。
我这种说法也许不对,不准确,不专业。但是起码,能把小白开发扩展的心态,调整好点,实际上是一个不难的东西,就是在写页面而已。要知道,心态不好,后面就坚持不下去了。
最基本组成
这里讲的是开发一个扩展(插件)最常用最基本的所需的东西,并不像官方说的那种分类。
- manifest.json
- background script
- content script
- popup
严格上来讲主要是 background script 、 content script 和 popup,毕竟他们都是贯穿在 manifest 里的,把 manifest 写出来,只是为了凸显一下它的重要性
(一)manifest.json
一个插件,必须都含有这个一个文件——manifest.json,位于根目录。顾名思义,这是一个扩展的组成清单,在这个清单里能大约看到该插件的一个 “规则”。
罗列和简单介绍一下一些常用的配置项,说之前,先看一个大致的文件,首先感官感受一下先
{
// 必须
"manifest_version": 2,
"name": "插件名称a",
"version": "1.1.2",
// 推荐
"default_locale": "en",
"description": "插件的描述",
"icons": {
"16": "img/icon.png", // 扩展程序页面上的图标
"32": "img/icon.png", // Windows计算机通常需要此大小。提供此选项可防止尺寸失真缩小48x48选项。
"48": "img/icon.png", // 显示在扩展程序管理页面上
"128": "img/icon.png" // 在安装和Chrome Webstore中显示
},
// 可选
"background": {
"page": "background/background.html",
"scripts": ["background.js"],
// 推荐
"persistent": false
},
"browser_action": {
"default_icon": "img/icon.png",
// 特定于工具栏的图标,至少建议使用16x16和32x32尺寸,应为方形,
// 不然会变形
"default_title": "悬浮在工具栏插件图标上时的tooltip内容",
"default_popup": "hello.html" // 不允许内联JavaScript。
},
"content_scripts": [ {
"js": [ "inject.js" ],
"matches": [ "http://*/*", "https://*/*" ],
"run_at": "document_start"
} ],
"permissions": [
"contextMenus",
"tabs",
"http://*/*",
"https://*/*"
],
"web_accessible_resources": [ "dist/*", "dist/**/*" ]
}
复制代码
上面有我写的一些注释,用于帮助大家更好的去理解。
那接下来开始说一下其中的配置项
icons
extension 程序的图标,可以有一个或多个。
48x48 的图标用在 extensions 的管理界面 (chrome://extensions);
128x128 的图标用在安装 extension 程序的时候;
16x16 的图标当作 extension 的页面图标,也可以显示在信息栏上。
图标一般为 PNG 格式, 因为最好的透明度的支持,不过 WebKit 支持任何格式,包括 BMP,GIF,ICO 等
注意: 以上写的图标不是固定的。随浏览器的环境的改变而变。如:安装时弹出的对话框变小。
browser_action 与 page_action
这两个配置项都是用来处理扩展在浏览器工具栏上的表现行为。 前者扩展可以适用于任何页面。后者扩展只能作用于某一页面,当打开该页面时触发该 Google Chrome 扩展,关闭页面则 Google Chrome 扩展也随之消失。
通俗的举个例子,一些扩展任何页面可用,就都会显示在工具栏上为可用状态,一些扩展只适用于某些页面,如大家很熟悉的 vue tools 调试器,在检测到页面用的是 vue 时,就会在工具栏显示出来并可用(非灰色)
default_popup
在用户点击扩展程序图标时,都可以设置弹出一个 popup 页面。而这个页面中自然是可以有运行的 js 脚本的(比如就叫 popup.js)。它会在每次点击插件图标——popup 页面弹出时,重新载入。
这个小小的设置,也就是上面我把它分为在基本组成里的popup 了
permissions
在 background 里使用一些 chrome api,需要授权才能使用,例如要使用 chrome.tabs.xxx 的 api,就要在 permissions 引入 “tabs”
web_accessible_resources
允许扩展外的页面访问的扩展内指定的资源。通俗来讲就是,扩展是一个文件夹 A 的,别人的网站是一个文件夹 B,B 要看 A 的东西,需要获得权限,而写在这个属性下的文件,就是授予了别人访问的权限。
(二)background script
background 可以理解为插件运行在浏览器中的一个后台网站 / 脚本,注意它是与当前浏览页面无关的。
实际上这部分内容的配置情况也会写在 manifest 里,对应的是background
配置项。单独拿出来讲,是彰显它的分量很重,也是一个插件常用的配置。从其中几个配置项项去了解一下什么是 background script
page
可以理解为这个后台网站的主页,在这个主页中,有引用的脚本,其中一般都会有一个专门来管理插件各种交互以及监听浏览器行为的脚本,一般都起名为 background.js。这个主页,不一定要求有。
scripts
这里的脚本其实跟写在 page 里 html 引入的脚本目的一样,个人的理解是,page 的 html 在没有的情况下,那么脚本就需要通过这个属性引入了;
如果在存在 page 的情况下,一般在这里引入的脚本是专门为插件服务的脚本,而那些第三方脚本如 jquery 还是在 page 里引用比较好,或许这是一个众人的 “潜规则” 吧
persistent
所谓的后台脚本,在 chrome 扩展中又分为两类,分别运行于后台页面(background page)和事件页面(event page)中。两者区别在于,
前者(后台页面)持续运行,生存周期和浏览器相同,即从打开浏览器到关闭浏览器期间,后台脚本一直在运行,一直占据着内存等系统资源,persistent 设为 true;
而后者(事件页面)只在需要活动时活动,在完全不活动的状态持续几秒后,chrome 将会终止其运行,从而释放其占据的系统资源,而在再次有事件需要后台脚本来处理时,重新载入它,persistent 设为 false。
保持后台脚本持久活动的唯一场合是扩展使用 chrome.webRequest API 来阻止或修改网络请求。webRequest API 与非持久性后台页面不兼容。
(三) content script
这部分脚本,简单来说是插入到网页中的脚本。它具有独立而富有包容性。
所谓独立,指它的工作空间,命名空间,域等是独立的,不会说跟插入到的页面的某些函数和变量发生冲突;
所谓包容性,指插件把自己的一些脚本(content script)插入到符合条件的页面里,作为页面的脚本,因此与插入的页面共享 dom 的,即用 dom 操作是针对插入的网页的,在这些脚本里使用的 window 对象跟插入页面的 window 是一样的。主要用在消息传递上(使用 postMessage 和 onmessage)
实际上这部分内容的配置情况也会写在 manifest 里,对应的是content_scripts
配置项。单独拿出来讲,是彰显它的分量很重,也是一个插件常用的配置。从其中几个配置项项去了解一下什么是 content script
js
要插入到页面里的脚本。例子很常见,例如在一个别人的网页上,你要打开你做的扩展,对别人的网页做一些处理或者获取一些数据等,那怎么跟别人的页面建立起联系呢?就是通过把 js 里的这些脚本嵌入都别人的网页里。
matches
必需。匹配规则组成的数组,用来匹配页面 url 的,符合条件的页面将会插入 js 的脚本。当然,有可以匹配的自然会有不匹配的——exclude_matches。匹配规则:
developer.chrome.com/extensions/…
上面的官方描述已经很清晰啦,我就不多说了。
run_at
js 配置项里的脚本何时插入到页面里呢,这个配置项来控制插入时机。有三个选择项:
- document_start
- document_end
- document_idle(默认)
document_start
style 样式加载好,dom 渲染完成和脚本执行前
document_end
dom 渲染完成后,即 DOMContentLoaded 后马上执行
document_idle
在 DOMContentLoaded 和 window load 之间,具体是什么时刻,要视页面的复杂程度和加载时间,并针对页面加载速度进行了优化。
popup
其实这部分,早就讲过了,就是在 manifest 里的browser_action
与page_action
配置项里设置的
基础的通信机制
上面讲述了基本的组成部分,那么这几部分,他们要进行交流合作,把他们组织起来,才能成就一个漂亮的扩展。那么这种交流,分为以下几种说明:
- content script 与 background 的通信
- popup 与 background 的通信
- popup 与 content script 的通信
- 插件 iframe 网站与插入网页的通信
最后一点,是额外说的,但是却是很重要的。毕竟很多扩展,也是以 iframe 的形式呈现的。
(一)content script 与 background 的通信
content-script 向 background 发送消息
在 content-script 端
使用
chrome.runtime.sendMessege(
message,
function(response) {…}
)
复制代码
就能向 background 发送消息了,第一个参数 message 为发送的消息(基础数据类型),回调函数里的第一个参数为 background 接收消息后返回的消息(如有)
在 background 端
使用
chrome.runtime.onMessege.addListener(
function(request, sender, sendResponse) {…}
)
复制代码
进行监听发来的消息,request
表示发来的消息,sendResponse
是一个函数,用于对发来的消息进行回应,如 sendResponse('我已收到你的消息:'+JSON.stringify(request));
这里需要注意的是,默认情况下sendResponse
函数的执行是同步的,如果在这个监听消息的处理函数的同步执行流程里没有发现sendResponse
,则默认返回undefined
,假设我们是要经过一个异步处理之后才调用sendResponse
,已经为时已晚了。因此,我们可能需要异步执行sendResponse
,这时我们在这个监听函数里的添加return true
就能实现了。
还有,由于 background 监听所有页面上的 content script 上发来的消息,如果多个页面同时发送同种消息,background 的 onMessage 只会处理最先收到的那个,其他的不了了之了。
background 向 content-script 发送消息
我们发现,一个插件里只有一个 background 环境,而 content-script 有多个(一个页面一个),那么 background 怎么向特定的 content-script 发送消息?
在 background 端
首先我们需要知道要向哪个 content scripts 发送消息,一般一个页面一份 content scripts,而一个页面对应一个浏览器 tab,每个 tab 都有自己的 tabId,因此首先要获取要发送消息的 tab 对应的 tabId。
/**
* 获取当前选项卡id
* @param callback - 获取到id后要执行的回调函数
*/
function getCurrentTabId(callback) {
chrome.tabs.query({active: true, currentWindow: true}, function (tabs) {
if (callback) {
callback(tabs.length ? tabs[0].id: null);
}
});
}
复制代码
当知道了 tabId 后,就使用该 api 进行发送消息
chrome.tabs.sendMessage(tabId, message, function(response) {...});
复制代码
其中 message 为发送的消息,回调函数的 response 为 content scripts 接收到消息后的回传消息
在 content scripts 端
同样是使用
chrome.runtime.onMessege.addListener(function(request, sender, sendResponse) {…})
复制代码
进行来自 background 发来消息的监听并回传
(二)popup 与 background 的通信
一般地,popup 与 background 的交流,常见于 popup 要获取 background 里的某些 “东西”,当然我们可以使用上述的chrome.runtime.sendMessage
和chrome.runtime.onMessage
的方式进行 popup 向 background 的交流,但是其实有更方便快捷的方式:
var bg = chrome.extension.getBackgroundPage();
bg.someMethod(); //someMethod()是background中的一个方法
复制代码
(三)popup 与 content script 的通信
这里的通信,实际上跟 background 与 content script 的方式是一样的
(四)插件 iframe 网站与插入网页的通信
其实这两个的通信,算不上是 chrome extension 开发里的知识,它就是一个基础的 js 知识——ifame 与父窗体的通信。
同域的情况下,可以通过 DOM 操作达到通信的目的,如获取 dom 元素,获取值赋值之类的。
在父窗体里,用window.contentWindow
获取到 iframe 的 window 对象
在 iframe 里,用window.parent
获取到父窗体的 window 对象
而在跨域下,上述的方法是行不通的,网上也有各种方法解决,但是在插件这块里,最方便的就是使用 js 的message机制
了。
我这里说的message机制
,就是使用 window 对象的postMessage()
和onmessage
。
一般插件展现都是在别人的网站上,因此没办法直接在别人的网站上添加postMessage
和onmessage
的代码。这时候,重任就落在了插件的 content script 身上了(之前说了他们共用 DOM)。由于 content script 是自己编写的,所以可以 “为所欲为” 了
iframe 向父窗体发送消息
在 iframe 端
假设 iframe 类名为 extension-iframe,这里设置类名而不是 id 名的初衷是,我们不能保证设置的名称原本的网站会不会已经存在,设置类名能共存。发送消息使用window.parent.postMessage(message, '*');
其中message
为发送的消息
在父窗体端
由于一个页面,可能有来自页面本身的 postMessage 来的消息,也有可能来自该页面其他 chrome extension 发送来的消息,因此用 onmessage 来监听,要做好区分来源,这里使用以下方法
window.addEventListener('message', function (event, a, b) {
// 如果没消息就退出
if (!event.data) {
return;
}
var iframes = document.getElementsByClassName('extension-iframe');
var extensionIframe = null; // 存插件iframe节点对象
var correctSource = false; // 是否来源正确
// 找出真正的插件生成的iframe
for (var i = 0; i < iframes.length; i++) {
if (iframes[i].contentWindow && (event.source === iframes[i].contentWindow)) {
correctSource = true;
extensionIframe = iframes[i];
break;
}
}
// 如果来源不是来自插件的,就退出
if (!correctSource) {
return;
}
}, false);
复制代码
这里也不能百分百区分好是不是来自自己 extension 的消息,或许真的那么倒霉刚好有一个跟自己 extension 同类名的 iframe 也发了一个消息过来。因此还可以加多一层保障,在 iframe 发送消息的内容上做手脚,例如加个 from,然后在这边判断一下等。当然,这样也不能百分百确定,只能说保障更上一层楼了。
如果大家有好的点子,请务必告诉鄙人!受教受教!
父窗体向 iframe 发送消息
在父窗体端
使用 extensionIframe.contentWindow.postMessage(message, '*');
其中extensionIframe
为插件的 iframe 节点对象,message
为发送的消息,例如
{from: 'content-script', other: xxx}
复制代码
在 iframe 端
使用
window.addEventListener('message', function (event, a, b) {
let result = event.data;
if (result && (result.from === 'content-script') && (event.source === window.parent)) {...}
});
复制代码
在这里,在发送消息里增加了个 from 属性,进而进一步判断是不是来自父窗体自己插件的 content script
插件内容发送 ajax 请求,我的一套 “土办法”
我们知道,在进行 ajax 请求,是有可能遇到跨域的。例如我的项目就是在任何一个页面插入 iframe 网站,然后有些操作就需要发请求了,这样必然存在跨域问题。
然而,如果开发插件还要开发者想办法解决跨域问题,那 chrome extension 就太逊了,而且,跨不跨域,还不是浏览器自己的主意,是浏览器本身的安全策略。
所以,chrome extension 为了保证自己的优越性,允许在自己的程序里面,实现跨域请求,那完全的 chrome extension 程序,无非就是在 background 里了。
因此,插件要实现一些 ajax 请求,都得通通搬到 background 里实现。这个事情,本身不是什么重大发现。接下来要说的是,我利用这个特性,按照某个规则,实现一套方便的请求流程。
这里以一个 ifame 网站嵌入到别人页面的这类形式的 chrome extension 为例子。
在插件生成的 iframe 网站里
首先在这个插件网站中,有一些按钮操作本身是要触发某些 ajax 请求的,但是由于上述原因,不能直接在插件网站里发请求,而是先向父窗口发送消息,利用postMessage
。例如
window.parent.postMessage({
from: 'extension-iframe',
type: 'loadTable',
data: {
pageIndex: 1,
pageSize: 10,
sortProp: '',
sortOrder: 0
}
}, '*');
复制代码
by the way,这里用window.parent.postMessage
是为了解决 iframe 跨域通信问题,当然如果是确保同域的情况下,其实可以直接用 DOM 操作告诉父窗口一些消息。
言归正传,在 postMessage 第一个参数对象里
属性名 | 描述 |
---|---|
from | 标记这条消息来自哪里 |
type | 操作的名称,如发送该 message 的操作目的是为了加载表格 |
data | 发送请求的 data |
在插件的 content script 里
监听发来的消息,这里①标注的代码为前面说过的区分来源,这里重点放在②部分的代码
window.addEventListener('message', function (event, a, b) {
var responseData = event.data;
if (!event.data) {
return;
}
// 来自插件内嵌网站的消息
if (responseData.from === 'extension-iframe') {
// ① 判断是否自己插件的iframe
var iframes = document.getElementsByClassName('extension-iframe');
var extensionIframe = null;
var correctSource = false;
for (var i = 0; i < iframes.length; i++) {
if (iframes[i].contentWindow && (event.source === iframes[i].contentWindow)) {
correctSource = true;
extensionIframe = iframes[i];
break;
}
}
if (!correctSource) {
return;
}
// ② 加载表格、提交信息等请求操作
// 该数组为iframe传来各个操作的名称,对应发来的消息的type属性
var operators = ['loadTable', 'submit', 'getNonMarkedCount', 'getUrl'];
// 如果跟操作匹配上了,就转发给background
if (operators.indexOf(responseData.type) !== -1) {
chrome.runtime.sendMessage({
type: responseData.type,
data: responseData.data
},function (response) {
// 返回请求后的数据给iframe网站
extensionIframe.contentWindow.postMessage({
from: 'extension-content-script',
type: responseData.type,
response: response
}, '*');
});
}
}
}, false);
复制代码
在插件的 background script 里
监听刚转发过来的消息
// 这是所有请求组成对象
var httpService = {
loadTable: function (config) {
return eodHttp.get('/brandimageservice/perspective/mark', config);
},
submit: function (config) {
return eodHttp.post('/brandimageservice/perspective/mark', config);
},
...
};
// 监听刚转发过来的消息
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
// 该数组为iframe传来各个操作的名称,对应发来的消息的type属性
var operators = ['loadTable', 'submit', 'getNonMarkedCount', 'getUrl'];
if (operators.indexOf(request.type) !== -1) {
// 这里的type刚好与请求的属性名一致
httpService[request.type](request.data).then(res => {
// 把请求的结果回传给content script
sendResponse(res);
}).catch(e => {
// 这里做了请求拦截,如果不是canceled的请求报错,则把报错信息也回传给content script
(e.status !== -1) && (sendResponse(e.data));
});
}
// 此处return true是为了把sendResponse作为异步处理。
return true;
});
复制代码
再返回到插件生成的 iframe 网站里
这次绕回到这个 ifame 里,最终的请求的数据还是会流回这里。
window.addEventListener('message', function (event, a, b) {
let result = event.data;
if (result && (result.from === 'extension-content-script') && (event.source === window.parent)) {
// 以下为请求返回内容
let res = result.response;
// 加载表格数据
if (result.type === 'loadTable') {...}
}
});
复制代码
这样,最终在 iframe 里获取到的请求数据还是跟之前我们平常开发调接口的情况是一样的。
总结
整个流程是 iframe -> content script -> background -> content script -> iframe
以 background 为中分线,前半截为发送请求,后半截为获取请求数据。这里巧妙的用法就是 “type” 这个字段由始至终都一直存在,都代表一样的意思。这样的写法的好处是,所有请求操作都可以共用这么一个流程,改一下 type 区分一下操作即可。
检测 chrome extension 是否已经安装
有一些 chrome extension,可能不单单是通过点击浏览器工具栏上的插件图标来激活插件,也有一些需求是通过点击网站上某个按钮来激活插件(如自家的系统),那么这时候第一步需要的是,检测浏览器是否安装了要求的 chrome extension,如果没有,进行提示等等。
在国内资料中进行搜索,往往会看到很多条教用navigator
对象来查找安装的插件,可能是我太弱鸡了,我发现并不能用来检测到自己添加的 chrome extension。于是我只能另寻他法了,如果有大神知道如何用navigator
对象来判断,麻烦指导一下。
思路
如果安装了某个插件,那么该插件的 content script 就会插入到页面上(没有 content script 的除外,但是一般没有 content script 的插件往往也没有以上这样的需求),因此判断是否安装了该插件,就变为判断 content script 是否插入到页面上。
方法一
在 content script 里写这么一个逻辑:往插入页面生成一个 html 元素标记,如<div><div>
。然后在插入页面获取这个元素,如果获取到了,就证明 content script 存在了(不存在也就没有这个元素了),就证明已经安装了。
缺点: 创建的这个元素,一定要够 “特别”,越能确保其独一无二越能证明是来自插件的。什么意思?假设刚好页面也有一个类名跟创建的一样的,那就要做进一步区分这到底是不是来自该插件的了。
方法二
通过 message 机制,在合适的时机里,页面用postMessage
发送消息给 window,content script 监听 window 消息,判断如果是要求检查是否安装的消息,则再用postMessage
告知,只要收到这个消息,就证明已经安装了。
缺点:由于发送消息和接受消息再到发送消息,这个过程是异步的。所以要处理好何时发送检测的时机问题。
安装的一些注意事项
安装手段一般是有两种的,一种在谷歌商店上进行在线安装,一种是下载安装包离线安装。在线安装没什么好说的,那么说一下离线安装。
离线安装的关键是,你提供的下载包是什么?
开发者在开发扩展的时候,往往是直接安装在本地上的扩展所在文件夹。在 chrome://extensions 上开启开发者模式,点击 “加载已解压的扩展程序”。这时候会发现第一次打开浏览器的时候会老是提示你这个扩展不安全之类的。当然我们提供给用户下载的安装包肯定不能是这个了。
一开始我傻不拉几的直接压缩自己的开发的扩展所在文件夹,然后发到服务器上给用户下载,结果呢,用户下载了,然后把压缩包拖动到 chrome://extension 里,发现 chrome 不允许安装,说什么基于安全什么的。也就是我这个扩展可能不安全不给我装。
后来才知道,不应该提供这种压缩包,而是在自己发布扩展的开发者信息中心里,把已发布的扩展下载下来提供给用户用才可以。
也许... 只有我那么傻吧
最后
最后的最后,我说一下小细节的注意项吧,稍微不留神,可能就这样傻傻地写下了 bug 了...
扩展之间很容易相互影响
怎么理解?在通信部分我讲过,在传递消息的时候,在消息里,我有用 type 字段来标明传递的内容类型。在开发完扩展的时候,发现有些同事的电脑可以正常使用有些却不行,后来调试代码发现,在 postMessage 函数里的 type 参数给别的扩展改造过了,受到了影响。
为什么会这样呢?原因是扩展都是通过嵌入自己的脚本到别人的网页里,因此在一个网页里的代码,特别是传递消息机制里,更容易受到牵连。
这个问题说明了什么?
- 要安装权威可信的扩展
- 开发一个值得让人信赖的扩展,开发一个尽量考虑全面的扩展,不要给用户添麻烦
代码调试
对于 content script 的调试,平常我们打开 F12 选择到 source 选项的时候,一般都会显示在 "page" 下,其实可以看到,还有个 content script 的选择,里边的就是各个扩展的内容脚本了。
对于 backgroud script 的调试,就在去到 chrome://extensions 页下,找到对应的扩展,然后点击背景视图,就可以看到 backgroud script 进行调试了,而且,还能在控制台调用 chrome api 呢。以及,请求也可以在这里看到。
信息传递的异步性
在扩展中,会用到很多消息传递,如上述的 postMessage 和 chrome.runtime.sendMessage 等之类的,大家一定要有一个观念,他们的交流并不是同步,不是说我发了一个消息过去,就马上收到然后做接下来的处理。
所以我们写逻辑的时候一定要注意,这种异步性,会对你的逻辑处理产生什么效果。特别是也要考虑到 content script 的插入时机是否对这些通信产生一定影响,如 content script 都没有准备好,就发了一些消息,然后就没有声响了。
关于 chrome extensions 的基本介绍和开发思路就介绍到这里,后续会有一篇文章专门来阐述一下,我项目中遇到的需求,遇到的问题以及对应的解决方案。
感兴趣的可以关注一下,感觉文章写得对你有帮助的话,请点下赞。
转载请标注出处谢谢,写文章不易。