Chrome扩展开发之二——Chrome扩展中脚本的运行机制和通信方式

目录:

如果你对GmailAssist感兴趣,可以在chrome商店中搜索“Gmail助手”,或点击这里直接访问商店来安装试用;
如果你对GmailAssist的源码感兴趣,可以在我的GitHub上查看它的源码。


零、首先来一波注意事项:

chrome不允许扩展中的HTML页面内直接内嵌js脚本,像这样:

<script type="text/javascript"> document.write("Hello World!"); </script>

而要求所有的脚本都作为外部src来引入,像这样:

<script src="hello.js" charset="utf-8"></script>

再提一点:chrome扩展中涉及的js脚本一定是要运行在网页上下文中的,也就是说任何一个脚本要跑起来,它必须是属于某一个网页的。

进入正题。有了基础的网页编程知识后,我们知道我们要做的扩展中,全指着javascript(也就是js)来实现各种程序功能,而HTML和CSS主要是负责显示。那么在Chrome扩展中运行的脚本有哪些呢?我的理解是大致有这么四类:background、popup页面内的js、content script、injected js。下面先分别介绍一下这四类脚本,从几个角度对比一下它们,然后再谈谈它们之间的联系和通信。这里的介绍更多地是侧重理解,而不是具体的书写格式,至于后者,最好的参考就是google官方的文档

一.Chrome扩展中有哪些类型的脚本

1.injected

  • 生存周期

  这种脚本,和原网页自带的脚本,就完全是一路货了。有多种方式来在扩展程序中向正在浏览的页面注入这样的脚本,我只说一种最常用也是最被推荐的:先把脚本保存在js文件里(比如GmailAssist中的tableInited.js),然后在匹配当前页面的content script中(如GmailAssist中的content.js)用类似下面这样的代码来把tableInited.js注入浏览中的页面:

var s = document.createElement('script');
s.src = chrome.extension.getURL('tableInited.js');
s.onload = function() {
this.parentNode.removeChild(this);
};
(document.head || document.documentElement).appendChild(s);

这里要注意一点:你要注入的inject.js需要在manifest中的web_accessible_resources字段里进行声明。否则,扩展程序在加载到浏览器中时,将会报错,如图(图中是某个.css文件没有在manifest中声明导致的报错,和这里说的错误原因是类似的):
Chrome扩展开发之二——Chrome扩展中脚本的运行机制和通信方式
那么web_accessible_resources字段是啥呢?说白了就是你的扩展中的文件,有哪些是要允许从网页可访问的,就需要挨个在这里面声明。像这样:

"web_accessible_resources" : [
"oauth2/oauth2.html",
"js/tableInited.js",
"css/style.css",
"js/table_sort_script.js",
"images/sort.gif",
]

否则会报上面的错。
显然,这类脚本在每次页面刷新时是会被重新载入的。

  • 可用API范围

只有网页通用的API是可用的,而chrome为扩展提供的API(chrome.*),这种完全注入到用户浏览的页面中的脚本都不能访问。

  • 作用范围/运行环境

完全和网页原有的脚本文件一样,我称它为“不属于扩展程序的脚本”。
可以访问网页原有js的变量空间。

  • 何时使用

  我的建议是,仅当你需要获取被浏览页面中原有js中的变量时,才把你的脚本inject到用户浏览的页面中,然后通过接下来例子里这种方式,把它传到content script中。当然了,有一些单纯地操纵DOM元素而不需要它们再返回什么数据的脚本,也可以直接inject到页面里。

  • 例子

获取ik的值。我在GmailAssist构建初期,尝试过一个gmail的非官方的库,当时为了获取邮箱用户的唯一标识(即ik,它是在gmail原有js的变量空间中的全局变量即GLOBALS中的),就不得不通过向页面中注入injected script来获取到GLOBALS。获取到之后要传给扩展程序的其他部分,则要通过event listener来完成。部分代码如下:

injected script中:

if(email_data) {
  window.postMessage({"usrik": JSON.stringify(userik) }, '*');//userik就从GLOBALS中取得
}

content script中:

window.addEventListener("message", function(event) {
if(event.data.usrik) {
    usrik = event.data.usrik
    console.log(usrik);
  }
}, false);

2.content script

  • 生存周期

  和injected script相似,它也是被注入到用户当前浏览的页面中的。但区别在于,它不是真正完全融入网页上下文的,而是运行在一个单独的被隔离的环境中。它的生存周期也就是跟浏览的网页一样,最迟到网页加载完全完成时,content script就开始跑了,直到用户当前浏览的网页被关闭。每次刷新时将重新载入。

  • 可用API范围

网页通用的API,跨域xhr请求,以及chrome为扩展程序提供的API中的一部分,具体有:(开头都是chrome.)
  extension(getURL、inIncognitoContext、lastError、onRequest、sendRequest)
  i18n
  runtime(connect、getManifest、getURL、id、onConnect、onMessage、sendMessage)
  storage

  • 作用范围/运行环境

它是注入用户浏览的网页中的,但又不像injected script那样彻底,而是单独运行在一个隔离环境里。
它可以访问一部分chrome给扩展程序提供的API,但也只有一部分。
它不运行在网页的真正上下文中,因而只能访问和操纵页面DOM,但访问不到页面里js的变量空间(当然也访问不到页面里js定义的函数们)。
它不可以访问background和popup页面中的脚本(我称后面这两类为“完全属于扩展程序的脚本”,接下来介绍)的变量和函数,但可以通过和background的通信来和扩展程序的其他部分实现数据交流。(这句和上句,这两种不可以,都是好理解的,只要你记住content script是运行在专门为它们准备的隔离环境里的即可。)因此我称content script为“半属于扩展程序的脚本”。
需要注意到,manifest中声明的形式,content_scripts字段的值是一个数组。也就是说一个扩展程序可以向一个页面中插入多个content script,而每个content script可以有多个 JavaScript 和 CSS 文件。那么有个问题,同一个页面内注入的多个content script可能来自同一个扩展程序,也可能来自不同的扩展程序,那么这些content script可以互相访问对方的变量空间吗?都不可以。

  • 何时使用

需要操纵页面DOM时,需要与具体页面匹配时,需要接受injected js传出来的数据时,以及每次刷新网页都需要重新载入的脚本,就可以作为content script来写。

  • 例子

向gmail服务器发xhr请求数据、操纵gmail页面的DOM,把返回的数据显示出来。

3.popup

  • 生存周期

在用户点击扩展程序图标时(无论是page action还是browser action),都可以设置弹出一个popup页面。而这个页面中自然是可以有运行的脚本的(比如就叫popup.js)。它会在每次popup页面弹出时重新载入。

  • 可用API范围

这类脚本和下一类(background),我都称为“完全属于扩展程序的脚本”。它们不仅可以访问普通网页API、可以发起跨域xhr请求,而且可以访问chrome为扩展程序专门提供的API(即chrome.*)中的全部。

  • 作用范围/运行环境

“完全属于扩展程序的脚本”之间是可以互相访问的,但popup页面中的脚本,会在每次用户呼出popup页面时重新载入。

  • 何时使用

仅针对popup页面内起作用的、比较小的(这样每次重新载入不会太久)脚本,用这一类来实现,比较合适。当然,这种脚本可以向外部请求数据,也可以访问本地存储API(chrome.storage),那么是可以通过这类脚本来写的。

  • 例子

授权按钮(加载很快,而且只在每次用户点击图标时加载即可,获取的token通过localStorage保存在本地,功能完成后即可把该脚本占据的资源腾出来)。
来个反例

最初我因为对这几类脚本的认识还比较模糊,所以图方便把获取附件列表的功能写在了popup页面中,并且没有调用本地存储来在本地cache一份。这就导致每次用户想操作附件时,都得点图标然后等一会才能继续,这样的体验是很差劲的,所以我就把获取附件列表的逻辑挪到了content script中。当然,现在在了解了本地存储API后,我依然认为不应该在popup页面中完成诸如获取附件列表这样的功能,原因有二:1.popup页面每次重新加载,即使数据从本地加载比较快,页面元素的加载时间总是不可避免的;2.popup页面的位置和大小限制,从用户角度来看,用它来实现和用户交互的界面,比较不舒服。

4.跑在后台(background)页面中的脚本

  • 生存周期

  这类脚本是运行在浏览器后台的,注意它是与当前浏览页面无关的。
  所谓的后台脚本,在chrome扩展中又分为两类,分别运行于后台页面(background page)和事件页面(event page)中。两者区别在于,前者(后台页面)持续运行,生存周期和浏览器相同,即从打开浏览器到关闭浏览器期间,后台脚本一直在运行,一直占据着内存等系统资源;而后者(事件页面)只在需要活动时活动,在完全不活动的状态持续几秒后,chrome将会终止其运行,从而释放其占据的系统资源,而在再次有事件需要后台脚本来处理时,重新载入它。这两类咋区分呢?通过你在manifest中的声明:

"background": {
"scripts": ["background.js"],
"persistent": false
},

  正如上一节说过的,这里persistent的值默认是true,此时这个js就是运行在后台页面的(持续的);若这个值为false,那就是事件页面(非持续的)了。

  • 可用API范围

和popup那种一样。

  • 作用范围/运行环境

  作为“完全属于扩展程序的脚本”,它可以和其他的“完全属于扩展程序的脚本”之间通信;也是运行在浏览器的环境内的,而与当前浏览的页面无关。

  • 何时使用

  需要持续运行在后台的,肯定就选这种了,而且要把persistent置为true。需要在后台处理些事件啊之类的,包括要用到content script无法访问的扩展程序专用API们时,也应该用这种,不过只要你不是需要它必须持续运行的,就把它设置成事件页面,从而提高性能。

  • 例子

  下载(因为1.能调用chrome给扩展程序提供的下载API的,只有“完全属于扩展程序的脚本”。2.“完全属于扩展程序的脚本”中,只有background这种可以持续运行在后台,或者被别的事件从后台激活,而另一类——popup页面中的脚本则是每次弹出popup页面时都被重新加载一次的,也只有用户点了扩展图标时才会弹出popup页面,若用popup无疑是增加了用户的操作复杂度)。

二、这几类脚本之间的通信

  我们现在知道,chrome扩展中的通信可以有这么几类:content script和“完全属于扩展程序的脚本”之间的通信、injected script和content script之间的通信、“完全属于扩展程序的脚本”们互相之间的通信、扩展程序的脚本和外部服务器之间的通信。

  先提一个概念,“异步”。异步和同步相对,同步就可以理解为“串行”,即完成一件事,才能做下一件;而异步就是,这件事的命令发出去就可以做其他事了,等这件事完成,可以通过“回调函数(callback)”来返回执行结果。曾经看到过一个很形象的例子,异步就相当于你去永和豆浆吃早饭,你把点的东西在柜台告诉服务员,然后你领个号就可以去做你自己的事了,等你的饭做好了,服务员会叫你的号(回调函数,callback),然后你去拿饭就可以吃了。类似的情景里,同步是啥呢,就是你点了餐,然后就只能在柜台那等你的餐做好,然后把饭给你你再走(相当于给你一个返回值,return)。

  chrome扩展内部的信息交换都是异步的。当然xhr也可以设置成异步的,也可以设置成同步的。

1. content script和“完全属于扩展程序的脚本”之间的通信

  两者可以互相发送消息,接收端的处理方式是一样的:(由于content script可以调用chrome.runtime中和信息传递相关的几个API,而“完全属于扩展程序的脚本”可以访问到全部chrome.*的API,自然可以调用chrome.runtime.onMessage通信API)

chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
console.log(sender.tab ?
"from a content script:" + sender.tab.url :
"from the extension");
if (request.greeting == "hello")
sendResponse({farewell: "goodbye"});
});

  发送端有所区别。(当然两种方向的发送,都是可以选择有没有回复)

  从content script向“完全属于扩展程序的脚本”发消息:

chrome.runtime.sendMessage({greeting: "hello"}, function(response) {
console.log(response.farewell);
});

  从“完全属于扩展程序的脚本”向content script发消息:

chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
chrome.tabs.sendMessage(tabs[0].id, {greeting: "hello"}, function(response) {
console.log(response.farewell);
});
});//和上面的区别是,这种消息发送的方向,需要你指明你信息要发到哪个页面(用户浏览的页面)中,也就是哪个tab中(chrome中每个页面都在一个tab,即标签中打开嘛)的content script;但显然上面那种就不需要,而且在接收端也不需要。

  这部分的实例代码,很容易理解,我也都是直接摘抄的chrome的官方文档,更多更具体的关于这些通信API信息,可以访问这里这里得到。

2. injected script和content script之间的通信:

  通过window.addEventListener和 window.postMessage来实现,代码可以参考上面获取user_ik的那个。
  这种通信方式的原理是基于content script和页面内的脚本(injected script自然也属于页面内的脚本)之间唯一共享的东西就是页面的DOM元素,window就是页面元素,因而可以被用来传递消息。

3.“完全属于扩展程序的脚本”们之间通信

  首先,他们之间函数是可以直接互相访问的;其次,它们可以互相访问对方的DOM元素;因而他们之间事实上不存在什么复杂的通信,大家都是一家人。

4. 扩展程序和外部服务器的通信

这里要补充几个概念:

1)域(domain)

Chrome扩展开发之二——Chrome扩展中脚本的运行机制和通信方式
这个表格来自我上一节中提到的电子书《Chrome扩展及应用开发》。其中列出了普通的网站在进行XHR时,必须遵守的规则。但在Chrome扩展中,可以通过在manifest的permissions属性中声明需要跨域的权限,来实现跨域xhr。

2) XHR(XMLHttpRequest)

  这里简单理解为页面或站点之间请求资源所要遵循的格式即可。需要知道,请求有两种结果,一种成功,将在返回信息中附带值为200的状态码;而另一种失败,将根据不同的失败原因在返回信息中附上不同的状态码和具体的失败原因描述(比如最常见的404,页面不存在)。

  你向外部服务器请求数据就要通过发起xhr(当然这要求你先把你要请求数据的站点声明在manifest的permissions字段里,这样chrome才允许你跨域,否则根据默认不允许跨域的原则,你只能访问你的扩展程序中的资源——html、js、图片之类的都是资源。说到这,也就可以更好理解前面提到过的manifest中的web_accessible_resource字段的含义了吧)。
  发起xhr的格式比较固定,篇幅原因,具体可以参考GmailAssist的源码。或者直接百度一下都有一大把。

最后补充几点,

1. 与扩展程序相关的信息的传送形式都是JSON字符串(可以简单理解为就是把JSON对象加上引号变成字符串),但你并不需要额外弄个JSON的库到你的程序中,chrome自动集成了这些东西;
2. 除了与外部的xhr交流,内部的这些信息传送不需要你手动转成字符串或者手动从字符串转成对象(JSON.stringify或JSON.parse),这两种过程是自动的,你可以直接认为你传送的就是JSON对象,而不是字符串;在和外部进行XHR交流时,你需要把发出去的信息手动stringify一下,把收到的信息parse一下,才可以正常使用;
3. 从安全性的角度出发,不要让你的程序接收到外来的信息时,用eval等方法来处理它们,这可能导致恶意脚本的执行,而应该使用JSON.parse这种不会引起脚本执行的方法来处理收到的信息(这一点如果不好理解,可以不去深究,只要记住,用xhr请求来的信息,统统用JSON.parse来解析即可);
4. 可以认为还有一种通信方式,在除了injected script外的剩下几种脚本之间,都可以通过本地保存的数据进行通信(即通过localStorage或者chrome.storage,这二者的概念,下一节中我会介绍)。

三、GmailAssist中安排哪些逻辑写在哪种脚本中

  清楚了上面这些基础知识,结合着对Gmail API的认识,我把GmailAssist要实现的每个功能,按照“它应该被写在哪一类脚本中”,细分成几个具体的小部分(并不是原子操作,这些小部分是可以再细分的,但这是后话):

  • 1. 授权(用了一个现成的库)。

  1# 用户点击授权或取消授权按钮,发起授权请求或删除本地保存的OAuth token;(popup)

  • 2. 获取列表并显示。

  1# 向服务器发请求(用message.list)(content script)
  2# 在gmail的页面的DOM中加入一块页面(直接自己布置在一个div中,或者弄个iframe都可以,我这里采用了前者)(content script)
  3# 收到服务器返回的数据并显示在2#中生成的区域内(content script)
  4# 把列表缓存到本地(用chrome.storage,后面的篇章中会介绍)(content script)

  • 3. 单独下载列表中的某个附件

  1# 向服务器发请求(content script)
  2# 收到服务器返回的附件下载地址后,把它传递给background(content script)
  3# 弹出下载对话框,让用户选择保存位置和设置保存的文件名(background)——由上面介绍的background和content script的特点,就不难理解为什么需要把下载地址传递给background了。

  • 4. 向草稿中插入附件(都是get、post方法调用gmail API,都在content script中完成即可)

  1# 获取草稿
  2# 获取附件内容
  3# 拼接出新草稿(虽然不是调用gmail API,但显然也应该直接在content script中完成这部分工作)
  4# 上传新草稿

  • 5. 批量操作、列表过滤等等针对获取到本地的数据的操作,包括操纵页面DOM的操作,也可以一并都放在content script中来实现。
  • 6. i18n(即国际化/多语言)相关的内容,其实已经基本不涉及js脚本了,我会放在i18n那一节里说,具体请看顶端的目录。

不难发现,GmailAssist的主要功能都是放在content script中实现的。
当你在开发你的扩展程序时,如何决定你的逻辑用哪类js来实现,可以参考我上面的分类介绍。

四、总结

chrome扩展中的脚本运行机制和通信方式,就基本是这样。上面的介绍中我没有放多少代码,因为实际开发时,最靠谱的参考一定是官方的文档。上面这些只是记录我的理解,也希望帮助新手更好地理解chrome扩展的architecture。下一节将介绍Chrome扩展中的数据存储和下载。

上一篇:域名注册域名解析域名绑定 dns服务器解析 域名记录的添加 记录类型含义@ www 访问域名请求过程


下一篇:Microsoft Azure Web Sites应用与实践【1】—— 打造你的第一个Microsoft Azure Website