浏览器家园·资讯

展开

【Chrome扩展开发】定制HTTP请求响应头域

编辑:浏览器知识

本文首发于《程序员》杂志2017年第9、10、11期,下面的版本又经过进一步的修订。

关于

本文共15k字,阅读需15分钟。

导读

搜索是程序员的灵魂,为了提升搜索的效率,以便更快的查询信息,我试着同时搜索4个网站,分别是百度、Google、维基、Bing。一个可行的做法就是网页中嵌入4个iframe,通过js拼接前面4个搜索引擎的Search URL并依次在iframe中加载。这个构思丝毫没有问题,简单粗暴。然而就是这么简单的功能,也无法实现。由于Google网站在HTML的response header中添加了X-Frame-Options字段以防止网页被Frame(这项设置常被用来防止Click Cheats),因此我无法将Google Search加入到iframe中来。那么,我会放弃Google吗?

Nginx反向代理Google

显然不会,既然问题出在X-Frame-Options上,我去掉就行了。对于请求或响应头域定制,nginx是个不错的选择,其第三方的ngx_headers_more模块就特别擅长这种处理。由于nginx无法动态加载第三方模块,我动态编译了nginx以便加入ngx_headers_more模块。至此,第一步完成,以下是nginx的部分配置。

location / {
  more_clear_headers 'X-Frame-Options';
}

为了让www.google.com正常访问,我需要使用另外一个域名比如louis.google.com。通过nginx,让louis.google.com转发到www.google.com,转发的同时去掉响应头域中的X-Frame-Options字段。于是nginx配置看起来像这样:

server {
  listen 80;
  server_name louis.google.com;
  location / {
    proxy_pass https://www.google.com/;
    more_clear_headers 'X-Frame-Options';
  }
}

以上的配置有什么问题吗?且不说http直接转https的问题,即使能转发,实际上由于Google的安全策略限制,我们也访问不了Google首页!

最终我使用了一个Nginx Google代理模块ngx_http_google_filter_module),nginx配置如下:

server {
    listen 80;
    server_name louis.google.com;
    resolver 192.168.1.1; # 需要设置为当前路由的网关
    location / {
        google on;
        google_robots_allow on;
        more_clear_headers 'X-Frame-Options';
    }
}

以上,通过实现一个Google网站的反向代理,代理的同时去掉了响应头域中的X-Frame-Options字段。至此,nginx方案完结。

nginx方案有一个明显的缺陷是,配置中resolver对应的网关IP192.168.1.1是随着路由器的改变而改变的,家里和公司就是两个不同的网关(更别说去星巴克了办公了),因此经常需要手动去修改网关然后重启nginx。

IHeader缘起

nginx方案的这个缺陷多少有些麻烦,恰好Chrome Extension可以定制headers,为了解决这个问题,我便尝试开发Chrome Extension。(使用Chrome以来,我下载试用过无数的Chrome Extension。每每看到一款优秀的Extension,都要激动好久,总有一种相见恨晚的感觉。Extension以其强大的定制能力,神奇的运行机制征服了无数的开发者,我也不例外。然而无论多少次的学习和模仿,最终的目的还是为了使用,故开发一款定制请求的Extension势在必行。)由于Chrome浏览器与网页的天然联系,使用Chrome Extension的方式去掉响应头域字段,比其它方案要更加简单高效。

要知道,Chrome Extension提供的API中有chrome.webRequest.onHeadersReceived。它能够添加对响应头的监听并同步修改响应头域,去掉X-Frame-Options似乎是小case。

于是新建项目,取名IHeader。目录结构如下:

目录结构

其中,_locales是国际化配置,目前IHeader支持中文和英文两种语言。

res是资源目录,index.html是extension的首页,options.html是选项页面。

manifest.json是extension的声明配置(总入口),在这里配置extension的名称、版本号、图标、快捷键、资源路径以及权限等。

manifest.json贴出来如下:

{
  "name": "IHeader", // 扩展名称
  "version": "1.1.0", // 扩展版本号
  "icons": { // 上传到chrome webstore需要32px、64px、128px边长的方形图标
    "128": "res/images/lightning_green128.png",
    "32": "res/images/lightning_green.png",
    "64": "res/images/lightning_green64.png"
  },
  "page_action": { // 扩展的一种类型,说明这是页面级的扩展
    "default_title": "IHeader", // 默认名称
    "default_icon": "res/images/lightning_default.png", // 默认图标
    "default_popup": "res/index.html" // 点击时弹出的页面路径
  },
  "background": { // 扩展在后台运行的脚本
    "persistent": true, // 由于后台脚本需要持续运行,需要设置为true,反之扩展不活动时可能被浏览器关闭
    "scripts": ["res/js/message.js", "res/js/background.js"] // 指定运行的脚本,实际上Chrome会启用一个匿名的html去引用这些js脚本。等同于"pages":["background.html"]这种方式(注意这两种互斥,同时设置时,后一种有效)
  },
  "commands": { // 指定快捷键
    "toggle_status": { // 快捷命令的名称
      "suggested_key": { // 快捷命令的热键
        "default": "Alt+H",
        "windows": "Alt+H",
        "mac": "Alt+H",
        "chromeos": "Alt+H",
        "linux": "Alt+H"
      },
      "description": "Toggle IHeader" // 描述
    }
  },
  "content_scripts": [ // 随着每个页面加载的内容脚本,通过它可以访问到页面的DOM
    {
      "all_frames": false, // frame中不加载
      "matches": ["\u003Call_urls>"], // 匹配所有URL
      "js": ["res/js/message.js", "res/js/content.js"] // 内容脚本的路径
    }
  ],
  "default_locale": "en", // 默认语言
  "description": "__MSG_description__", // 扩展描述
  "manifest_version": 2, // Chrome 18及更高版本中,应该指定为2,低于v18版本的Chrome浏览器可以指定为1或不指定
  "minimum_chrome_version": "26.0", // 最低支持到v26版本,主要受制于chrome.runtime api
  "options_page": "res/options.html", // 选项页面的路径
  "permissions": [ "tabs" , "webRequest", "webRequestBlocking", "http://*/*", "https://*/*", "contextMenus", "notifications"] // 扩展需要的权限
}

Chrome Extension简介

开始开发之前,我们先来刷一波基础知识。

Chrome官方明确规定了插件、扩展和应用的区别:

不注意区分的话,我们讲到Chrome插件,往往指的就是以上三者之一。为了避免引起误解,本篇将严格区分概念,避免使用插件这种含糊的说法。

如何安装扩展

开发扩展,首先得从安装开始,从Chrome 21起,Chrome浏览器就增加了对扩展安装的限制,默认只允许从 Chrome Web Store (Chrome 网上应用店)安装扩展和应用,这意味着用户一般只能安装Chrome Web Store内的扩展和应用。

如果你拖动一个crx安装文件到Chrome浏览器的任何一个普通网页,将会出现如下提示。

点击继续按钮,则会在浏览器左上角弹出如下警告。

如果你恰好在Github上发现一个不错的Chrome扩展程序,而Chrome Web Store中没有。是不是就没有办法安装呢?当然不是的,Chrome浏览器还有三种其它的方式可以加载扩展程序。

​ 2)点击添加扩展程序

​ 3)添加好的扩展如下所示。

说到安装,自然有人会问,安装了某款扩展后,怎么查看该扩展的源码呢?Mac系统的用户请记住这个目录~/Library/Application Support/Google/Chrome/Default/Extensions/(windows的扩展目录暂无)。

扩展打包和更新

另外,中间的打包扩展程序按钮用于将本地开发的扩展程序打包成crx包,首次打包还会生成秘钥文件(如IHeader.pem),如下所示。

打包扩展程序

打包好的扩展程序,可以发送给其他人安装,或发布到Chrome Web Store(开发者注册费用为5$)。

右边的立即更新扩展程序按钮则用于更新扩展。

扩展的基本组成

通常一个Chrome扩展包含如下资源或目录:

为了方便管理,个人倾向于将HTML、JS、CSS,ICON等资源分类统一到同一个目录。

扩展的分类

从使用场景上看,Chrome扩展可分为以下三类:

1)Browser Action,浏览器扩展,可通过manifest.json中的browser_action属性设置,如下所示。

"browser_action": {
  "default_title": "Qrcode",
  "default_icon": "images/icon.png",
  "default_popup": "index.html" // 可选的
},

以上是URL生成二维码的Browser Action扩展,运行如下所示:

该类扩展特点:全局扩展,icon长期占据浏览器右上角工具栏,每个页面均可用。

2)Page Action,页面级扩展,可通过manifest.json中的page_action属性设置,如下所示。

"page_action": {
  "default_title": "IHeader",
  "default_icon": "res/images/lightning_default.png",
  "default_popup": "res/index.html" // 可选的
},

以上是本篇将要讲解的Page Action的扩展——IHeader,它被指定为所有页面可见,其icon状态切换如下所示。

该类扩展特点:不同页面可以拥有不同的状态和不同的icon,icon在指定的页面可见,可见时位于浏览器右上角工具栏。

由上可见,Browser Action与Page Action功能上非常相似,配置上各自的内部属性也完全一致,它们不仅可以配置点击时弹出的页面,同时还可以绑定点击事件,如下所示。

// 以下事件绑定一般在background.js中运行
// Browser Action
chrome.browserAction.onClicked.addListener(function(tab) {
  console.log(tab.id, tab.url);
  chrome.tabs.executeScript(tab.id, {file: 'content.js'});
});
// Page Action
chrome.pageAction.onClicked.addListener(function(tab) {
  console.log(tab.id, tab.url);
});

 

如果非要说两者的差别,开发中能够感受到的就是:前者不需要维护icon状态,后者需要针对每个启用的页面管理不同的icon状态。

3)Omnibox,全能工具条,可通过manifest.json中的omnibox属性设置,如下所示。

"omnibox": {
  "keyword": "mdn-" //URL地址栏输入关键字"mdn-"+空格后,就会触发Omnibox
},

 

以上是MDN网站快捷查询的Omnibox扩展,运行如下所示:

很明显,你可以对地址栏的各种输入做定制,Chrome的URL地址栏只所以强大,omnibox可谓功不可没。

该类扩展特点:运行在URL地址栏,无弹出界面,用户在输入时,扩展就可以显示建议或者自动完成一些工作。

以上三类决定了扩展如何在浏览器中运行。除此之外,每个扩展程序还可以任意搭载如下页面或脚本。

 

 

 

 

 

总之,对于Chrome扩展而言,Browser Action、Page Action 或 Omnibox之间是互斥的,其它情况下它并不限制你需要添加哪些页面或脚本,只要你愿意,就可以随意组合。

扩展如何运行调试

只要你会写js,就可以开发Chrome扩展程序了。涉及到开发,调试是不可避免的,Chrome扩展的调试也非常简单。我们都知道Chrome浏览器的 chrome://extensions/页面可以查看所有的Chrome扩展,不仅如此,该页面下的加载已解压的扩展程序按钮,便可以直接加载本地开发的扩展程序,如下所示。

注意:需要勾选开发者模式才会出现加载已解压的扩展程序按钮。

成功加载后的扩展跟正常安装的扩展程序,没有什么不同,接下来,我们就可以使用web技术进行调试了。

 

Chrome Extension API

Chrome陆续向开发者开放了大量的API。使用这些API,我们可以监听或代理网络请求,存储数据,管理标签页和Cookie,绑定快捷键、设置右键菜单,添加通知和闹钟,获取CPU、电池、内存、显示器的信息等等(还有很多没有列举出来)。具体请阅读Chrome API官方文档。请注意,使用相应的API,往往需要申请对应的权限,如IHeader申请的权限如下所示。

"permissions": [ "tabs" , "webRequest", "webRequestBlocking", "http://*/*", "https://*/*", "contextMenus", "notifications"]

以上,IHeader依次申请了标签页、请求、请求断点、http网站,https网站,右键菜单,桌面通知的权限。

WebRequest API

Chrome Extension API中,能够修改请求的,只有chrome.webRequest了。webRequest能够为请求的不同阶段添加事件监听器,这些事件监听器,可以收集请求的详细信息,甚至修改或取消请求。

事件监听器只在特定阶段触发,它们的触发顺序如下所示。(图片来自MDN

事件监听器的含义如下所示。

以上,凡是能够修改请求的事件监听器,都能够指定其extraInfoSpec参数数组中包含”blocking”字符串(意味着能阻塞请求并修改),反之则不行。

另外请注意,Chrome对于请求头和响应头的展示有着明确的规定,即控制台中只展示发送出去或刚接收到的字段。因此编辑后的请求字段,控制台的network栏能够正常展示;而编辑后的响应字段由于不属于刚接收到的字段,所以从控制台上就会看不到编辑的痕迹,如同没修改过一样,实际上编辑仍然有效。

事件监听器含义虽不同,但语法却一致。接下来我们就以onHeadersReceived为例,进行深入分析。

如何绑定header监听

还记得我们的目标吗?想要去掉Google网站HTML响应头的X-Frame-Options字段。请看如下代码:

// 监听的回调
var callback = function(details) {
  var headers = details.responseHeaders;
  for (var i = 0; i < headers.length; ++i) {
    // 移除X-Frame-Options字段
    if (headers[i].name === 'X-Frame-Options') {
      headers.splice(i, 1);
      break;
    }
  }
  // 返回修改后的headers列表
  return { responseHeaders: headers };
};
// 监听哪些内容
var filter = {
  urls: ["<all_urls>"]
};
// 额外的信息规范,可选的
var extraInfoSpec = ["blocking", "responseHeaders"];
/* 监听response headers接收事件*/
chrome.webRequest.onHeadersReceived.addListener(callback, filter, extraInfoSpec);

chrome.webRequest.onHeadersReceived.addListener表示添加一个接收响应头的监听。以上代码中的关键参数或属性,下面逐一讲解。

既然有了添加监听的方法,自然,还会有移除监听的方法。

chrome.webRequest.onHeadersReceived.removeListener(listener);

除此之外,为了避免重复监听,还可以判断监听是否已经存在。

var bool = chrome.webRequest.onHeadersReceived.hasListener(listener);

为了保证更好的理清以上属性、方法或参数的逻辑关系,请看如下脑图:

扩展状态管理

监听器的状态管理

知道了如何绑定监听器,仅仅是第一步。监听器需要在合适的时机绑定,也需要在合适的时机解绑。为了不影响Chrome的访问速度,我们只在需要的标签页创建新的监听器,因此监听器需要依赖filter来区分不同的tabId,考虑到用户可能只需要监听一部分请求类型,types的区分也是不可避免的。又由于一个Tab里不同的时间段可能会加载不同的页面,一个监听器在不同的页面下正常运行也是必须的(因此监听器的filter中不需要指定urls)。

寥寥数语,可能不足以描述出监听器状态管理的原貌,请看下图进一步帮助理解。

以上,一个请求将依次触发上述①②③④⑤五个事件回调,每个事件回调都对应着一个监听器,这些监听器分为两类(从颜色上也可看出端倪)。

若Chrome指定的标签页激活了IHeader扩展,②③⑤监听器就会记录当前标签页后续的指定类型的请求信息。若用户在激活了IHeader扩展的标签页更新了Request的请求头或响应头,①或④监听器就会被开启。不用担心监听器开启无限个,我准备了回收机制,单个标签页的所有监听器都会在标签页关闭或IHeader扩展取消激活后释放掉。

首先,为方便管理,先封装下监听器的代码。

/* 独立的监听器 */
var Listener = (function(){
  var webRequest = chrome.webRequest;

  function Listener(type, filter, extraInfoSpec, callback){
    this.type = type; // 事件名称
    this.filter = filter; // 过滤器
    this.extraInfoSpec = extraInfoSpec; // 额外的参数
    this.callback = callback; // 事件回调
    this.init();
  }
  Listener.prototype.init = function(){
    webRequest[this.type].addListener( // 添加一个监听器
      this.callback,
      this.filter,
      this.extraInfoSpec
    );
    return this;
  };
  Listener.prototype.remove = function(){
    webRequest[this.type].removeListener(this.callback); // 移除监听器
    return this;
  };
  Listener.prototype.reload = function(){ // 重启监听器(用于选项页面更新请求类型后重启所有已开启的监听器)
    this.remove().init();
    return this;
  };
  return Listener;
})();

监听器封装好了,剩下的便是管理,监听器控制器基于标签页的维度统一管理标签页上所有的监听器,代码如下。

/* 监听器控制器 */
var ListenerControler = (function(){
  var allListeners = {}; /* 所有的监听器控制器列表 */
  function ListenerControler(tabId){
    if(allListeners[tabId]){ /* 如有就返回已有的实例 */
      return allListeners[tabId];
    }
    if(!(this instanceof ListenerControler)){ /* 强制以构造器方式调用 */
      return new ListenerControler(tabId);
    }

    /* 初始化变量 */
    var _this = this;
    var filter = getFilter(tabId); // 获取当前监听的filter设置
    /* 捕获requestHeaders */
    var l1 = new Listener('onSendHeaders', filter, ['requestHeaders'], function(details){
      _this.saveMesage('request', details); // 记录请求的头域信息
    });
    /* 捕获responseHeaders */
    var l2 = new Listener('onResponseStarted', filter, ['responseHeaders'], function(details){
      _this.saveMesage('response', details); // 记录响应的头域信息
    });
    /* 捕获 Completed Details */
    var l3 = new Listener('onCompleted', filter, ['responseHeaders'], function(details){
      _this.saveMesage('complete', details); // 记录请求完成时的时间等信息
    });

    allListeners[tabId] = this; // 记录当前的标签页控制器
    this.tabId = tabId;
    this.listeners = {  // 记录已开启的监听器
      'onSendHeaders': l1,
      'onResponseStarted': l2,
      'onCompleted': l3
    };
    this.messages = {}; // 当前标签页的请求信息集合
    console.log('tabId=' + tabId + ' listener on');
  }
  ListenerControler.has = function(tabId){...} // 判断是否包含指定标签页的控制器
  ListenerControler.get = function(tabId){...} // 返回指定标签页的控制器
  ListenerControler.getAll = function(){...} // 获取所有的标签页控制器
  ListenerControler.remove = function(tabId){...} // 移除指定标签页下的所有监听器
  ListenerControler.prototype.remove = function(){...} // 移除当前控制器中的所有监听器
  ListenerControler.prototype.saveMesage = function(type, message){...} // 记录请求信息
  return ListenerControler;
})();

通过监听器控制器的统一调度,标签页中的多个监听器才能高效的工作。

实际上,还有很多工作,上述代码还没有体现出来。比方说用户在激活了IHeader扩展的标签页更新了Request的请求头或响应头,①beforeSendHeaders或④headersReceived监听器又是怎么运作的呢?这部分内容,请结合『如何绑定header监听』节点的内容理解。

Page Action图标状态管理

标签页控制器的状态需要由视觉体现出来,因此Page Action图标的管理也是不可避免的。通常,默认的icon可以在manifest.json中指定。

"page_action": {
  "default_icon": "res/images/lightning_default.png", // 默认图标
},

icon有如下3种状态(后两种状态可以互相切换)。

Chrome提供了chrome.pageAction的API供Page Action使用。目前chrome.pageAction拥有如下方法。

以上,setTitle、setIcon 和 show方法比较常用。其中,show方法有两种作用,①展示icon,②更新icon,因此一般是先设置好icon的标题和路径,然后调用show展示出来(或更新)。需要注意的是,Page Action在show方法被调用之前,是不会响应点击的,所以需要在初始化工作结束之前调用show方法。千言万语不如上代码,如下。

/* 声明3种icon状态 */
var UNINIT = 0, // 扩展未初始化
    INITED = 1, // 扩展已初始化,但未激活
    ACTIVE = 2; // 扩展已激活
/* 处理扩展icon状态 */
var PageActionIcon = (function(){
  var pageAction = chrome.pageAction, icons = {}, tips = {};
  icons[INITED] = 'res/images/lightning_green.png'; // 设置不同状态下的icon路径(相对于扩展根目录)
  icons[ACTIVE] = 'res/images/lightning_red.png';

  tips[INITED] = Text('iconTips'); // 其它地方有处理,Text被指向chrome.i18n.getMessage,用以读取_locales中指定语言的对应字段的文本信息
  tips[ACTIVE] = Text('iconHideTips');

  function PageActionIcon(tabId){ // 构造器
    this.tabId  = tabId;
    this.status = UNINIT; // 默认为未初始化状态
    pageAction.show(tabId); // 展示Page Action
  }
  PageActionIcon.prototype.init = function(){...} // 初始化icon
  PageActionIcon.prototype.active = function(){...} // icon切换为激活状态
  PageActionIcon.prototype.hide = function(){...} // 隐藏icon
  PageActionIcon.prototype.setIcon = function(){ // 设置icon
    pageAction.setIcon({ // 设置icon的路径
      tabId : this.tabId,
      path  : icons[this.status]
    });
    pageAction.setTitle({ // 设置icon的标题
      tabId : this.tabId,
      title : tips[this.status]
    });
    return this;
  };
  PageActionIcon.prototype.restore = function(){// 刷新页面后,icon之前的状态会丢失,需要手动恢复
    this.setIcon();
    pageAction.show(this.tabId);
    return this;
  };
  return PageActionIcon;
})();

icon管理的准备工作ok了,剩下的就是使用了,如下。

new PageActionIcon(this.tabId).init();

标签页的状态管理

对于IHeader扩展程序,一个标签页同时包含了监听器状态和icon状态的变化。因此需要再抽象出一个标签页控制器,对两者进行统一管理,从而供外部调用。代码如下。

/* 处理标签页状态 */
var TabControler = (function(){
  var tabs = {}; // 所有的标签页控制器列表
  function TabControler(tabId, url){
    if(tabs[tabId]){ /* 如有就返回已有的实例 */
      return tabs[tabId];
    }
    if(!(this instanceof TabControler)){ /* 强制以构造器方式调用 */
      return new TabControler(tabId);
    }
    /* 初始化属性 */
    tabs[tabId] = this;
    this.tabId = tabId;
    this.url    = url;
    this.init();
  }
  TabControler.get = function(tabId){...} // 获取指定的标签页控制器
  TabControler.remove = function(tabId){
    if(tabs[tabId]){
      delete tabs[tabId]; // 移除指定的标签页控制器
      ListenerControler.remove(tabId); // 移除指定的监听器控制器
    }
  };
  TabControler.prototype.init = function(){...} // 初始化标签页控制器
  TabControler.prototype.switchActive = function(){ // 当前标签页状态切换
    var icon = this.icon;
    if(icon){
      var status = icon.status;
      var tabId = this.tabId;
      switch(status){
        case ACTIVE: // 如果是激活状态,则恢复初始状态,移除监听器控制器
          icon.init(); 
          ListenerControler.remove(tabId);
          Message.send(tabId, 'ListeningCancel'); // 通知内容脚本从而在控制台输出取消提示(后续将讲到消息通信)
          break;
        default: // 如果不是激活状态,则激活之,添加监听器控制器
          icon.active();
          ListenerControler(tabId);
          Message.send(tabId, 'Listening'); // 并通知内容脚本从而在控制台输出监听提示
      }
    }
    return this;
  };
  TabControler.prototype.restore = function(){...} // 恢复标签页控制器的状态(针对页面刷新场景)
  TabControler.prototype.remove = function(){...} // 移除标签页控制器
  return TabControler;
})();

标签页控制器的抽象,有助于封装扩展的内部运行细节,方便了后续各种场景中对扩展的管理 。

标签页关闭或更新的妥善处理

标签页关闭或更新时,为了避免内存泄露和运行稳定,部分数据需要释放或者同步。刚刚封装好的标签页控制器就可以用来做这件事。

首先,Tab关闭时需要释放当前标签页的控制器和监听器对象。

/* 监听tab关闭的事件 */
chrome.tabs.onRemoved.addListener(function(tabId, removeInfo){
  TabControler.remove(tabId); // 释放内存,移除标签页控制器和监听器
});

其次,每次Tab在执行跳转或刷新动作时,Page Action的icon都会回到初始状态并且不可点击,此时需要恢复icon之前的状态。

/* 监听tab更新的事件、包含跳转或刷新的动作 */
chrome.tabs.onUpdated.addListener(function(tabId, changeInfo){
  if(changeInfo.status === 'loading'){ // 页面处于loading时触发
    TabControler(tabId).restore(); // 恢复icon状态
  }
});

以上,页面跳转或刷新时,changeInfo将依次经历两种状态:loading 和complete(部分页面会包含favIconUrltitle信息),如下所示。

随着状态管理的逐渐完善,那么,是时候进行消息通信了(不知道你注意到上述代码中出现的Message对象没有?它就是消息处理的对象)。

 

消息通信

扩展内部消息通信

Chrome扩展内的各页面之间的消息通信,有如下四种方式(以下接口省略chrome前缀)。

类型 消息发送 消息接收 支持版本
一次性消息 extension.sendRequest extension.onRequest v33起废弃(早期方案)
一次性消息 extension.sendMessage extension.onMessage v20+(不建议使用)
一次性消息 runtime.sendMessage runtime.onMessage v26+(现在主流,推荐使用)
长期连接 runtime.connect runtime.onConnect v26+

目前以上四种方案都可以使用。其中extension.sendRequest发送的消息,只有extension.onRequest才能接收到(已废弃不建议使用,可选读Issue 9965005)。extension.sendMessage 或 runtime.sendMessage 发送的消息,虽然extension.onMessage 和 runtime.onMessage都可以接收,但是runtime api的优先触发。若多个监听同时存在,只有第一个响应才能触发消息的sendResponse回调,其他响应将被忽略,如下所述。

If multiple pages are listening for onMessage events, only the first to call sendResponse() for a particular event will succeed in sending the response. All other responses to that event will be ignored.

我们先看一次性的消息通信,它的基本规律如下所示。

图中出现了一种新的消息通信方式,即chrome.extension.getBackgroundPage,通过它能够获取background.js(后台脚本)的window对象,从而调用window下的任意全局方法。严格来说它不是消息通信,但是它完全能够胜任消息通信的工作,之所以出现在图示中,是因为它才是消息从popup.html到background.js的主流沟通方式。那么你可能会问了,为什么content.js中不具有同样的API呢?

这是因为它们的使用方式不同,各自的权限也不同。popup.html或background.js中chrome.extension对象打印如下:

content.js中chrome.extension对象打印如下:

可以看出,前者包含了全量的属性,后者只保留少量的属性。content.js中并没有chrome.extension.getBackgroundPage方法,因此content.js不能直接调用background.js中的全局方法。

回到消息通信的话题,请看消息发送和监听的简单示例,如下所示:

// 消息流:弹窗页面、选项页面 或 background.js --> content.js
// 由于每个tab都可能加载内容脚本,因此需要指定tab
chrome.tabs.query( // 查询tab
  { active: true, currentWindow: true }, // 获取当前窗口激活的标签页,即当前tab
  function(tabs) { // 获取的列表是包含一个tab对象的数组
    chrome.tabs.sendMessage( // 向tab发送消息
      tabs[0].id, // 指定tab的id
      { message: 'Hello content.js' }, // 消息内容可以为任意对象
      function(response) { // 收到响应后的回调
        console.log(response);
      }
    );
  }
);

/* 消息流:
 * 1. 弹窗页面或选项页面 --> background.js
 * 2. background.js --> 弹窗页面或选项页面
 * 3. content.js --> 弹窗页面、选项页面 或 background.js
 */
chrome.runtime.sendMessage({ message: 'runtime-message' }, function(response) {
  console.log(response);
});

// 可任意选用runtime或extension的onMessage方法监听消息
chrome.runtime.onMessage.addListener( // 添加消息监听
  function(request, sender, sendResponse) { // 三个参数分别为①消息内容,②消息发送者,③发送响应的方法
    console.log(sender.tab ?
                "from a content script:" + sender.tab.url :
                "from the extension");
    if (request.message === 'Hello content.js'){
      sendResponse({ answer: 'goodbye' }); // 发送响应内容
    }
    // return true; // 如需异步调用sendResponse方法,需要显式返回true
  }
);
一次性消息通信API

上述涉及到的API语法如下:

属性 类型 支持性 描述
active boolean tab是否激活
audible boolean v45+ tab是否允许声音播放
autoDiscardable boolean v54+ tab是否允许被丢弃
currentWindow boolean v19+ tab是否在当前窗口中
discarded boolean v54+ tab是否处于被丢弃状态
highlighted boolean tab是否高亮
index Number v18+ tab在窗口中的序号
muted boolean v45+ tab是否静音
lastFocusedWindow boolean v19+ tab是否位于最后选中的窗口中
pinned boolean tab是否固定
status String tab的状态,可选值为loadingcomplete
title String tab中页面的标题(需要申请tabs权限)
url String or Array tab中页面的链接
windowId Number tab所处窗口的id
windowType String tab所处窗口的类型,值包含normalpopuppanelappordevtools

注:丢弃的tab指的是tab内容已经从内存中卸载,但是tab未关闭。

综上,我们选用chrome.runtime api即可完美的进行消息通信,对于v25,甚至v20以下的版本,请参考以下兼容代码。

var callback = function(message, sender, sendResponse) {
  // Do something
});
var message = { message: 'hello' }; // message
if (chrome.extension.sendMessage) { // chrome20+
  var runtimeOrExtension = chrome.runtime && chrome.runtime.sendMessage ? 'runtime' : 'extension';
  chrome[runtimeOrExtension].onMessage.addListener(callback); // bind event
  chrome[runtimeOrExtension].sendMessage(message); // send message
} else { // chrome19-
  chrome.extension.onRequest.addListener(callback); // bind event
  chrome.extension.sendRequest(message); // send message
}
长期连接消息通信

想必,一次性的消息通信你已经驾轻就熟了。如果是频繁的通信呢?此时,一次性的消息通信就显得有些复杂。为了满足这种频繁通信的需要,Chrome浏览器专门提供了Chrome.runtime.connect API。基于它,通信的双方就可以建立长期的连接。

长期连接基本规律如下所示:

以上,与上述一次性消息通信一样,长期连接也可以在popup.html、background.js 和 content.js三者中两两之间建立(注意:无论何时主动与content.js建立连接,都需要指定tabId)。如下是popup.html与content.js之间建立长期连接的举例?。

// popup.html 发起长期连接
chrome.tabs.query(
  {active: true, currentWindow: true}, // 获取当前窗口的激活tab
  function(tabs) {
    // 建立连接,如果是与background.js建立连接,应该使用chrome.runtime.connect api
    var port = chrome.tabs.connect( // 返回Port对象
      tabs[0].id, // 指定tabId
      {name: 'call2content.js'} // 连接名称
    );
    port.postMessage({ greeting: 'Hello' }); // 发送消息
    port.onMessage.addListener(function(msg) { // 监听消息
      if (msg.say == 'Hello, who\'s there?') {
        port.postMessage({ say: 'Louis' });
      } else if (msg.say == "Oh, Louis, how\'s it going?") {
        port.postMessage({ say: 'It\'s going well, thanks. How about you?' });
      } else if (msg.say == "Not good, can you lend me five bucks?") {
        port.postMessage({ say: 'What did you say? Inaudible? The signal was terrible' });
        port.disconnect(); // 断开长期连接
      }
    });
  }
);

// content.js 监听并响应长期连接
chrome.runtime.onConnect.addListener(function(port) { // 监听长期连接,默认传入Port对象
  console.assert(port.name == "call2content.js"); // 筛选连接名称
  console.group('Long-lived connection is established, sender:' + JSON.stringify(port.sender));
  port.onMessage.addListener(function(msg) {
    var word;
    if (msg.greeting == 'Hello') {
      word = 'Hello, who\'s there?';
      port.postMessage({ say: word });
    } else if (msg.say == 'Louis') {
      word = 'Oh, Louis, how\'s it going?';
      port.postMessage({ say: word });
    } else if (msg.say == 'It\'s going well, thanks. How about you?') {
      word = 'Not good, can you lend me five bucks?';
      port.postMessage({ say: word });
    } else if (msg.say == 'What did you say? Inaudible? The signal was terrible') {
      word = 'Don\'t hang up!';
      port.postMessage({ say: word });
    }
    console.log(msg);
    console.log(word);
  });
  port.onDisconnect.addListener(function(port) { // 监听长期连接的断开事件
    console.groupEnd();
    console.warn(port.name + ': The phone went dead');
  });
});

控制台输出如下:

建立长期连接涉及到的API语法如下:

属性 类型 描述
name String 连接的名称
disconnect Function 立即断开连接(已经断开的连接再次调用没有效果,连接断开后将不会收到新的消息)
onDisconnect Object 断开连接时触发(可添加监听器)
onMessage Object 收到消息时触发(可添加监听器)
postMessage Function 发送消息
sender MessageSender 连接的发起者(该属性只会出现在连接监听器中,即onConnect 或onConnectExternal中)

扩展程序间消息通信

相对于扩展内部的消息通信而言,扩展间的消息通信更加简单。对于一次性消息通信,共涉及到如下两个API:

对于长期连接消息通信,共涉及到如下两个API:

发送消息可参考如下代码:

var extensionId = "oknhphbdjjokdjbgnlaikjmfpnhnoend"; // 目标扩展id
// 发起一次性消息通信
chrome.runtime.sendMessage(extensionId, { message: 'hello' }, function(response) {
  console.log(response);
});
// 发起长期连接消息通信
var port = chrome.runtime.connect(extensionId, {name: 'web-page-messages'});
port.postMessage({ greeting: 'Hello' });
port.onMessage.addListener(function(msg) {
  // 通信逻辑见『长期连接消息通信』popup.html示例代码
});

监听消息可参考如下代码:

// 监听一次性消息
chrome.runtime.onMessageExternal.addListener( function(request, sender, sendResponse) {
  console.group('simple request arrived');
  console.log(JSON.stringify(request));
  console.log(JSON.stringify(sender));
  sendResponse('bye');
});
// 监听长期连接
chrome.runtime.onConnect.addListener(function(port) {
  console.assert(port.name == "web-page-messages");
  console.group('Long-lived connection is established, sender:' + JSON.stringify(port.sender));
  port.onMessage.addListener(function(msg) {
    // 通信逻辑见『长期连接消息通信』content.js示例代码
  });
  port.onDisconnect.addListener(function(port) {
    console.groupEnd();
    console.warn(port.name + ': The phone went dead');
  });
});

控制台输出如下:

Web页面与扩展间消息通信

除了扩展内部和扩展之间的通信,Web pages 也可以与扩展进行消息通信(单向)。这种通信方式与扩展间的通信非常相似,共需要如下三步便可以通信。

首先,manifest.json指定可接收页面的url规则。

"externally_connectable": {
  "matches": ["https://developer.chrome.com/*"]
}

其次,Web pages 发送信息,比如说在 https://developer.chrome.com/extensions/messaging 页面控制台执行以上『扩展程序间消息通信』小节——消息发送的语句。

最后,扩展监听消息,代码同以上『扩展程序间消息通信』小节——消息监听部分。

至此,扩展程序的消息通信聊得差不多了。基于以上内容,你完全可以自行封装一个message.js,用于简化消息通信。实际上,阅读模式扩展程序就封装了一个message.js,IHeader扩展中的消息通信便基于它。

设置快捷键

一般涉及到状态切换的,快捷键能有效提升使用体验。为此我也为IHeader添加了快捷键功能。

为扩展程序设置快捷键,共需要两步。

  1. manifest.json中添加commands声明(可以指定多个命令)。
    "commands": { // 命令
      "toggle_status": { // 命令名称
        "suggested_key": { // 指定默认的和各个平台上绑定的快捷键
          "default": "Alt+H", 
          "windows": "Alt+H",
          "mac": "Alt+H",
          "chromeos": "Alt+H",
          "linux": "Alt+H"
        }, 
        "description": "Toggle IHeader" // 命令的描述
      }
    },
    
  2. background.js中添加命令的监听。
    /* 监听快捷键 */
    chrome.commands.onCommand.addListener(function(command) {
      if (command == "toggle_status") { // 匹配命令名称
        chrome.tabs.query({active: true, currentWindow: true}, function(tabs) { // 查询当前激活tab
          var tab = tabs[0];
          tab && TabControler(tab.id, tab.url).switchActive(); // 切换tab控制器的状态
        });
      }
    });
    

以上,按下Alt+H键,便可以切换IHeader扩展程序的监听状态了。

设置快捷键时,请注意Mac与Windows、linux等系统的差别,Mac既有Ctrl键又有Command键。另外,若设置的快捷键与Chrome的默认快捷键冲突,那么设置将静默失败,因此请记得绕过以下Chrome快捷键(KeyCue是查看快捷键的应用,请忽略之)。

添加右键菜单

除了快捷键外,还可以为扩展程序添加右键菜单,如IHeader的右键菜单。

为扩展程序添加右键菜单,共需要三步。

  1. 申请菜单权限,需在manifest.json的permissions属性中添加”contextMenus”权限。
    "permissions": ["contextMenus"]
    
  2. 菜单需在background.js中手动创建。
    chrome.contextMenus.removeAll(); // 创建之前建议清空菜单
    chrome.contextMenus.create({ // 创建右键菜单
      title: '切换Header监听模式', // 指定菜单名称
      id: 'contextMenu-0', // 指定菜单id
      contexts: ['all'] // 所有地方可见
    });
    

    由于chrome.contextMenus.create(object createProperties, function callback)方法默认返回新菜单的id,因此它通过回调(第二个参数callback)来告知是否创建成功,而第一个参数createProperties则为菜单项指定配置信息。

  3. 绑定右键菜单的功能。
    chrome.contextMenus.onClicked.addListener(function (menu, tab){ // 绑定点击事件
      TabControler(tab.id, tab.url).switchActive(); // 切换扩展状态
    });
    

安装或更新

Chrome为扩展程序提供了丰富的API,比如说,你可以监听扩展安装或更新事件,进行一些初始化处理或给予友好的提示,如下。

/* 安装提示 */
chrome.runtime.onInstalled.addListener(function(data){
  if(data.reason == 'install' || data.reason == 'update'){
    chrome.tabs.query({}, function(tabs){
      tabs.forEach(function(tab){
        TabControler(tab.id).restore(); // 恢复所有tab的状态
      });
    });
    // 初始化时重启全局监听器 ...
    // 动态载入Notification js文件
    setTimeout(function(){
      var partMessage = data.reason == 'install' ? '安装成功' : '更新成功';
      chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
        var tab = tabs[0];
        if (!/chrome:\/\//.test(tab.url)){ // 只能在url不是"Chrome:// URL"开头的页面注入内容脚本
          chrome.tabs.executeScript(tab.id, {file: 'res/js/notification.js'}, function(){
            chrome.tabs.executeScript(tab.id, {code: 'notification("IHeader'+ partMessage +'")'}, function(log){
              log[0] && console.log('[Notification]: 成功弹出通知');
            });
          });
        } else {
          console.log('[Notification]: Cannot access a chrome:// URL');
        }
      });
    },1000); // 延迟1s的目的是为了调试时能够及时切换到其他的tab下,从而弹出Notification。
    console.log('[扩展]:', data.reason);
  }
});

以上,chrome.tabs.executeScript(integer tabId, object details)接口,用于动态注入内容脚本,且只能在url不是”Chrome:// URL”开头的页面注入。其中tabId参数用于指定目标标签页的id,details参数用于指定内容脚本的路径或语句,它的file属性指定脚本路径,code属性指定动态语句。若分别往同一个标签页注入多个脚本或语句,这些注入的脚本或语句处于同一个沙盒,即全局变量可以共享。

notification.js如下所示。

function notification(message) {
  if (!('Notification' in window)) { // 判断浏览器是否支持Notification功能
    console.log('This browser does not support desktop notification');
  } else if (Notification.permission === "granted") { // 判断是否授予通知的权限
    new Notification(message); // 创建通知
    return true;
  } else if (Notification.permission !== 'denied') { // 首次向用户申请权限
    Notification.requestPermission(function (permission) { // 申请权限
      if (permission === "granted") { // 用户授予权限后, 弹出通知
        new Notification(message); // 创建通知
        return true;
      }
    });
  }
}

最终弹出通知如下。

 

国际化

为了让全球都能使用你开发的扩展,国际化是必须的。从软件工程的角度讲,国际化就是将产品用户界面中可见的字符串全部存放在资源文件中,然后根据用户所处不同的语言环境,展示相应语言的视觉信息。Chrome从v17版本开始就提供了国际化标准API——chrome.i18n。i18n即internationalization(国际化),由于i和n中间共计18个字母,故简称为i18n。

Chrome扩展预留了_locales目录,用于存放多种语言版本的资源文件——message.json。目录结构为 “_locales/locales_code/message.json”,如下所示:

_locales
|-- en
    |-- message.json
|-- zh_CN
    |-- message.json

locales_code不仅包含以上举例的en(英文)、zh_CN(简体中文)等,还包含全球多种其它语言,具体请参考Choosing locales to support,对于不支持的locale,Chrome会自动忽略。

message.json资源文件如下所示,其中key为关键字,其message属性指定了它对应的值,description属性用于描述该key。

{
  "key": {
    "message": "the value for the key",
    "description": "the description for the key"
  },
  ...
}

根据i18n的官网文档

Important: If an extension has a _locales directory, the manifest must define “default_locale”.

一旦扩展中有了_locales目录,那么就必须要在manifest.json中指定”default_locale”,如下所示。

"default_locale": "en",

如何引用国际化字符串

实际效果如图:

以上引用过程,如下所示(图片来自MDN):

预定义消息

以上,提供了这些API还不够,国际化系统还提供了一些预定义的消息,它们如下。

Message Name Description
@@extension_id 扩展ID,可用于拼接链接,即使没有国际化的扩展也可用,注意不能用于manifest.json文件。
@@ui_locale 当前语言,可用于拼接本地化的链接。
@@bidi_dir 当前语言的文字方向,包含ltrrtl,分别为从左到右、从右到左。
@@bidi_reversed_dir 若@@bidi_dir值为ltr,则它的值为rtl,否则为ltr
@@bidi_start_edge 若@@bidi_dir值为rtl,则它的值为left,否则为right
@@bidi_end_edge 若@@bidi_dir值为ltr,则它的值为right,否则为left

预定义的消息可在Chrome扩展的JavaScript和CSS中使用,如下。

var extensionId = chrome.i18n.getMessage('@@extension_id');
location.href = 'chrome-extension://' + extensionId + '/res/options.html';
body {
  direction: __MSG_@@bidi_dir__;
  background-image:url('chrome-extension://__MSG_@@extension_id__/background.png');
}
div {
  padding-__MSG_@@bidi_start_edge__: 5px;
  padding-__MSG_@@bidi_end_edge__: 10px;
}

其它国际化API

除了chrome.i18n.getMessage外,还有另外三个API。

Chrome扩展开发的心得

到目前为止,IHeader是我业余开发时间最长的一款Chrome扩展。从今年的5月8号始,到6月14号,第一版才完工,然后又经过7月、8月近两个月的陆续修改,最终v1.1.0版才成型,这才达到了我最初的开发初衷。

现在网络上流传的各种扩展开发教程非常之多,甚至API翻译的网站也很多,就我所知道的至少有这些:

通过查看这些资源,基本上就能快速上手Chrome扩展开发。

当然,教程再完善也不及官方文档,开发过程中,最难过的就是Chrome开发者网站连接不稳定,经常无法访问(即使自带梯子),因此查看官方网站的资料有些困难,这点比较影响开发进度,所以本文有意多介绍了一些Chrome API的用法。另外,开发好的扩展发布过程中也需要注意两点:

  1. 注册Chrome开发者需要5$,亲测浦发的visa信用卡可以支付,没有网上讲的那么复杂。
  2. 发布的扩展,为方便用户查看,需要完善的文档。由于Chrome webstore的扩展面向全球用户,所以文档至少要支持两种语言:中文和英文。

总之,Chrome扩展,万变不离其宗,无论扩展多么神奇和强大,最终都是通过HTML、CSS、JS来实现功能,脱离不了Web的小天地。因此理论上,只要你会写JS,就完全可以开发Chrome扩展。甚至,连第一个Demo,Chrome都帮你写好了,下载并安装Sample Extensions – Google Chrome网站的随意一个扩展源码,修修改改你就能运行属于自己的扩展了。

当然,一个好的扩展应该是对工作或生活有帮助的。只要你抓住痛点,用心实现功能,利用业余时间开发出一个强大的扩展自然不是问题。

小结

至此,Chrome扩展有关的介绍差不多了,让我们来看看IHeader的效果。借助IHeader扩展程序,我去掉了 www.google.com 网站response的X-Frame-Options字段,终于解决了文章开头的难题,如下所示。

安装好IHeader后,可以戳此链接http://louiszhai.github.io/res/search/index.html?q=123 ,试用IHeader。

不仅如此,IHeader还可以新增、删除或编辑任意指定url的请求响应头,并且即使浏览器重启后,全局监听器依然有效。它适合用于HTTP缓存研究,HTTP接口字段调试,甚至还可以为接口调试时的跨域问题提供临时的解决方案(笔者基于此完成了很多跨域接口的调试工作)。因此,只要您基于HTTP请求响应头去做事情,IHeader都可以帮您简化工作。至于如何使用,这里有一个IHeader-Guide(由于网络原因,Chrome webstore上更新可能不及时,推荐安装Github上的IHeader源码)。

对Chrome扩展感兴趣的同学,欢迎来Github学习交流扩展开发的经验。

本文以IHeader扩展程序为引,逐步讲解Chrome扩展程序的开发,涉及内容较多,难免有所疏漏,欢迎批评斧正,谢谢。、


版权声明:转载需注明作者和出处。

本文作者:louis

本文链接:http://louiszhai.github.io/20…

相关文章

文章TAG:chrome  扩展  展开  开发  【Chrome扩展开发】定制HTTP请求响应头域  

加载全部内容

相关教程
猜你喜欢
大家都在看