很多 add-ons 需要访问和修改 web 页面的内容。但是 add-on 的主代码不能直接访问 web 内容。替代方案是, SDK add-ons 需要使用一些分散的脚本代理访问 web 内容,这些脚本被称作内容脚本(content scripts)。本页面描述如何开发和部署内容脚本。

内容脚本是在使用SDK时很令人疑惑的点,但你很有可能不得不使用它们。下面有五个基本原则:

  • add-on 的主代码,包括"main.js"和其他"lib"下的模块,可以使用 SDK 高层次低层次 APIs,但不能直接访问 web 内容
  • 内容脚本 不能使用 SDK 的 API(访问不了 globals 的 exportsrequire),但你可以访问 web 内容
  • SDK API 可以使用,内容脚本,比如 page-modtabs,提供了一些函数,使得 add-on 的主代码可以将内容脚本载入web页面中。
  • 内容脚本可以作为字符串加载,但是更常见的是分离存储为 add-on 的"data"目录下文件。 jpm 不会默认创建"data"目录,所以你必须添加该目录并把脚本放进去。
  • 一个消息传递 API 允许主代码和内容脚本间相互通信。

这个完整的 add-on 表现出所有的这些原则。它的"main.js"使用 tabs 模块附加了一个内容脚本到当前标签页。本例中内容脚本作为字符串传递,内容脚本简单地替换了页面的内容:

// main.js
var tabs = require("sdk/tabs");
var contentScriptString = 'document.body.innerHTML = "<h1>this page has been eaten</h1>";'

tabs.activeTab.attach({
  contentScript: contentScriptString
});

下面的高层次 SDK 模块能使用内容脚本来修改 web 页面:

  • page-mod:使你能附加一个内容脚本到匹配上特定 URL 模式的web页面。
  • tabs:导出一个 Tab 对象来处理浏览器标签页。Tab 对象包括了一个 attach() 函数来附加内容脚本到标签页。
  • page-worker:让你能够恢复一个 web 页面,但不显示它。你可以附加内容脚本到该页面,来访问和操作该页面的 DOM。
  • context-menu:使用内容脚本来和按钮所在的页面交互。

另外,还能使用 HTML 定义了一些 SDK 用户接口组件,并且使用分类的脚本来和这些内容交互。从很多方面来讲,这些脚本就像内容脚本一样,但它们并不是本文的关注点。要学习如何和用户接口模块的内容交互,请参看模块定义文档:panelsidebarframe

这篇指南中列出的几乎所有的示例都是完整并且且最小的,可以在 Github 的 addon-sdk-content-scripts repository 页面上获得。

加载用户脚本

你可以声明一个字符串或者指定 contentScriptcontentScriptFile 选项加载一个单独的脚本。contentScript 选项接受一个作为脚本的字符串:

// main.js

var pageMod = require("sdk/page-mod");
var contentScriptValue = 'document.body.innerHTML = ' +
                         ' "<h1>Page matches ruleset</h1>";';

pageMod.PageMod({
  include: "*.mozilla.org",
  contentScript: contentScriptValue
});

contentScriptFile 选项接受一个作为 resource:// URL 的字符串,指向一个存储在你的 add-on 的 data 目录中的脚本文件。jpm不会默认创建"data"目录,所以你必须创建该目录并将你的用户脚本放进去。

本 add-on 提供一个 URL ,指向"content-script.js"文件,存储在 add-on 根目录下的 data 子目录:

// main.js

var data = require("sdk/self").data;
var pageMod = require("sdk/page-mod");

pageMod.PageMod({
  include: "*.mozilla.org",
  contentScriptFile: data.url("content-script.js")
});
// content-script.js

document.body.innerHTML = "<h1>Page matches ruleset</h1>";

从 Firefox 34 开始,你可以使用"./content-script.js"替代 self.data.url("content-script.js")。所以你可以像这样重写:

var pageMod = require("sdk/page-mod");

pageMod.PageMod({
  include: "*.mozilla.org",
  contentScriptFile: "./content-script.js"
});

除非你的内容脚本非常简单并且固定是一个静态的字符串,请不要使用 contentScript:否则,你会在从 AMO 获取你的add-on上遇到问题。

相反,把脚本放到一个单独的文件并用 contentScriptFile 加载它。这回事你的代码更易维护、安全、调试和审核。

你可以给 contentScriptcontentScriptFile 传递字符串数组来加载多个脚本:

// main.js

var tabs = require("sdk/tabs");

tabs.on('ready', function(tab) {
  tab.attach({
      contentScript: ['document.body.style.border = "5px solid red";', 'window.alert("hi");']
  });
});
// main.js

var data = require("sdk/self").data;
var pageMod = require("sdk/page-mod");

pageMod.PageMod({
  include: "*.mozilla.org",
  contentScriptFile: [data.url("jquery.min.js"), data.url("my-content-script.js")]
});

如果你这么做,这些脚本之间可以直接交互,就像他们被同一个 web 页面加载一样。

你也可以把 contentScriptcontentScriptFile 一起用。如果你这么做,使用 contentScriptFile 定义的脚本会在使用 contentScript 定义的脚本之前加载。这使你能够用 URL 加载比如 jQuery 这样的 JavaScript 库,然后传递一个简单的能够使用jQuery脚本:

// main.js

var data = require("sdk/self").data;
var pageMod = require("sdk/page-mod");

var contentScriptString = '$("body").html("<h1>Page matches ruleset</h1>");';

pageMod.PageMod({
  include: "*.mozilla.org",
  contentScript: contentScriptString,
  contentScriptFile: data.url("jquery.js")
});

除非你的内容脚本非常简单并且固定是一个静态的字符串,请不要使用 contentScript:否则,在从 AMO 获取你的 add-on 上,你会遇到问题。

相反,把脚本放到一个单独的文件并用 contentScriptFile 加载它。这回事你的代码更易维护、安全、调试和审核。

控制附加脚本的时间

contentScriptWhen 选项指定了什么时候加载内容脚本。从这里选一个:

  • "start":页面 document 元素插入 DOM 之后,立即加载脚本。这时 DOM 的内容仍未加载,所以脚本不能与其交互。
  • "ready":页面 DOM 加载完后加载脚本:也就是说,在那个时间点 DOMContentLoaded 事件触发。这时,内容脚本可以和DOM内容交互,但外部引用的样式表和图片可能还没有完成加载。
  • "end":页面上所有内容(DOM、JS、CSS、images)加载完后,加载脚本,就是在 window.onload 事件触发的时候

默认值为 "end"

注意 tab.attach() 不支持 contentScriptWhen,因为它原来就是在页面加载页面的时候被调用的。

传递配置选项

contentScriptOptions 是一个作为只读对象暴露给内容脚本的JSON对象,在 self.options 的属性里:

// main.js

var tabs = require("sdk/tabs");

tabs.on('ready', function(tab) {
  tab.attach({
      contentScript: 'window.alert(self.options.message);',
      contentScriptOptions: {"message" : "hello world"}
  });
});

这里可以使用任何可以转成json的值(object、array、string等等)。

访问 DOM

内容脚本可以访问页面的 DOM,就像任何页面中加载的脚本(页面脚本)一样。但是内容脚本和页面脚本之间是隔离的:

  • 内容脚本不能看到任何由页面脚本添加到页面的 JavaScript 对象
  • 如果页面脚本重定义了某个 DOM 对象的行为,但内容脚本只会看到原来的那个行为。

相反也是如此:页面脚本不能看到内容脚本添加的 JavaScript 对象。

例如,假想一个页面用页面脚本添加变量 foowindow 对象:

<!DOCTYPE html">
<html>
  <head>
    <script>
    window.foo = "hello from page script"
    </script>
  </head>
</html>

在这个脚本后面加载到页面的其他脚本也可以访问 foo。但是内容脚本不能:

// main.js

var tabs = require("sdk/tabs");
var mod = require("sdk/page-mod");
var self = require("sdk/self");

var pageUrl = self.data.url("page.html")

var pageMod = mod.PageMod({
  include: pageUrl,
  contentScript: "console.log(window.foo);"
})

tabs.open(pageUrl);
console.log: my-addon: null

这种隔离策略有着很合理的理由。首先,这意味着内容脚本不会泄露对象给 web 页面,这样可能会打开安全漏洞。第二,这意味着,在内容脚本创建对象的时候,可以不用担心是否会和页面脚本添加的对象相冲突。

这种隔离意味着,例如,如果一个 web 页面加载了 jQuery 库,那么内容脚本不能够看到由该库添加的 jQuery 对象——但是可以看到内容脚本添加的自己的 jQuery 对象,并且它不会和页面脚本的 jQuery 版本冲突。

和页面脚本交互

一般来说,这种内容脚本和页面脚本的隔离正是你所希望的。但是有时候你也许会希望和页面脚本交互:你想在内容脚本和页面脚本之间共享对象来,来在它们之间发送消息。如果你需要这么做,请阅读和页面脚本交互

事件监听器

你可以监听 DOM 的事件,就像在页面脚本中一样,但是有两个重要的区别:

第一,如果你向 setAttribute() 传递字符串,来定义了事件监听器,那么此监听器被当做是在页面上下文中的,所以它不能访问任何内容脚本中的变量。

如下,内容脚本会失败报错"theMessage is not defined":

var theMessage = "Hello from content script!";
anElement.setAttribute("onclick", "alert(theMessage);");

Second, if you define an event listener by direct assignment to a global event handler like onclick, then the assignment might be overridden by the page. For example, here's an add-on that tries to add a click handler by assignment to window.onclick:

var myScript = "window.onclick = function() {" +
               "  console.log('unsafewindow.onclick: ' + window.document.title);" +
               "}";

require("sdk/page-mod").PageMod({
  include: "*",
  contentScript: myScript,
  contentScriptWhen: "start"
});

这个示例会在大多数页面上正常工作,但是会在定义 onclick 的页面上失败:

<html>
  <head>
  </head>
  <body>
    <script>
    window.onclick = function() {
      window.alert("it's my click now!");
    }
    </script>
  </body>
</html>

由于这些原因,最好还是用 addEventListener() 添加一个事件监听器,定义监听器为一个函数:

var theMessage = "Hello from content script!";

anElement.onclick = function() {
  alert(theMessage);
};

anotherElement.addEventListener("click", function() {
  alert(theMessage);
});

和 add-on 通信

为了使 add-on 脚本和内容脚本相互通信,任何一通信端都要访问 port 对象。

  • 要从一头发送消息到另一头,使用 port.emit()
  • 要从另一头接收消息,使用 port.on()

消息是异步的:也就是说,发送方不会等待接收方的回应,而仅仅是发送消息完后继续处理别的事情。

这里有一个简单的 add-on 使用 port 发送一个消息到内容脚本:

// main.js

var tabs = require("sdk/tabs");
var self = require("sdk/self");

tabs.on("ready", function(tab) {
  var worker = tab.attach({
    contentScriptFile: self.data.url("content-script.js")
  });
  worker.port.emit("alert", "Message from the add-on");
});

tabs.open("http://www.mozilla.org");
// content-script.js

self.port.on("alert", function(message) {
  window.alert(message);
});

context-menu 模块没有使用这里描述的通信模型。了解更多关于使用 context-menu 和内容脚本通信的事情,参看 context-menu documentation

在内容脚本中访问 port

内容脚本中,port 对象是作为global下 self 对象的属性。所以要从内容脚本中发送消息的话:

self.port.emit("myContentScriptMessage", myContentScriptMessagePayload);

要从 add-on 代码接收消息

self.port.on("myAddonMessage", function(myAddonMessagePayload) {
  // Handle the message
});

注意 global下 self 对象和 self 模块完全不一样,后者提供一个API给 add-on,用来访问它的数据文件和ID。

在内容脚本中访问 port

在 add-on 代码中,联通 add-on 和某一特定内容脚本上下文的通道被封装入 worker 对象。所以和内容脚本通信的 port 对象其实是其相对应的 worker 对象的一个属性。

但是,这个 worker 没有暴露给 add-on 代码,以及同样所有的模块。

page-worker

page-worker 对象直接整合了 work API。所以要从一个由 page-worker 关联的内容脚本接收消息的话,你可以使用 pageWorker.port.on()

// main.js

var self = require("sdk/self");

var pageWorker = require("sdk/page-worker").Page({
  contentScriptFile: self.data.url("content-script.js"),
  contentURL: "http://en.wikipedia.org/wiki/Internet"
});

pageWorker.port.on("first-para", function(firstPara) {
  console.log(firstPara);
});

要从你的 add-on 发送用户定义的消息,你可以只调用 pageWorker.port.emit()

// main.js

var self = require("sdk/self");

var pageWorker = require("sdk/page-worker").Page({
  contentScriptFile: self.data.url("content-script.js"),
  contentURL: "http://en.wikipedia.org/wiki/Internet"
});

pageWorker.port.on("first-para", function(firstPara) {
  console.log(firstPara);
});

pageWorker.port.emit("get-first-para");
// content-script.js

self.port.on("get-first-para", getFirstPara);

function getFirstPara() {
  var paras = document.getElementsByTagName("p");
  if (paras.length > 0) {
    var firstPara = paras[0].textContent;
    self.port.emit("first-para", firstPara);
  }
}

page-mod

单个 page-mod 对象可以附加它的脚本到多个页面,每个页面有它自己的上下文来运行内容脚本,所以每个页面都需要相互隔离的通道(worker)。

所以 page-mod 没有直接整合 worker 的 API。而是在每次内容脚本被附加到页面时,page-mod 发送一个 attach 事件,它的监听器会给对应的上下文传递一个 worker。通过为 attach 提供一个监听器,你可以访问被一个 page-mod 附加到页面上的内容脚本的 port 对象:

// main.js

var pageMods = require("sdk/page-mod");
var self = require("sdk/self");

var pageMod = pageMods.PageMod({
  include: ['*'],
  contentScriptFile: self.data.url("content-script.js"),
  onAttach: startListening
});

function startListening(worker) {
  worker.port.on('click', function(html) {
    worker.port.emit('warning', 'Do not click this again');
  });
}
// content-script.js

window.addEventListener('click', function(event) {
  self.port.emit('click', event.target.toString());
  event.stopPropagation();
  event.preventDefault();
}, false);

self.port.on('warning', function(message) {
  window.alert(message);
});

上面的 add-on 里有两条消息:

  • 当用户点击页面元素时,click 从 page-mod 被发送到当前 add-on。
  • warning 发送一条傻气的字符串回给page-mod

Tab.attach()

Tab.attach() 方法返回一个 worker,你可以用来和附加的内容脚本通信。

这个 add-on 添加了一个按钮到Firefox:等用户点击按钮是,这个 add-on 附加一个内容脚本到当前的标签页,发送给内容脚本一条名为 "my-addon-message"的消息,并且监听名为"my-script-response"的响应:

//main.js

var tabs = require("sdk/tabs");
var buttons = require("sdk/ui/button/action");
var self = require("sdk/self");

buttons.ActionButton({
  id: "attach-script",
  label: "Attach the script",
  icon: "./icon-16.png",
  onClick: attachScript
});

function attachScript() {
  var worker = tabs.activeTab.attach({
    contentScriptFile: self.data.url("content-script.js")
  });
  worker.port.on("my-script-response", function(response) {
    console.log(response);
  });
  worker.port.emit("my-addon-message", "Message from the add-on");
}
// content-script.js

self.port.on("my-addon-message", handleMessage);

function handleMessage(message) {
  alert(message);
  self.port.emit("my-script-response", "Response from content script");
}

port的API

参看 port 对象的参考文档.

postMessage的API

port 对象加载之前,add-on 代码和内容脚本可以使用另一个 API 通信:

  • 内容脚本调用 self.postMessage() 来发送,并用 self.on() 来接收
  • 内容脚本调用 worker.postMessage() 来发送,并用 worker.on() 来接收

这个API依然可用,并且还有文档,但是没有理由替代前文描述的 port API。 例外是 context-menu 模块,它还是使用 postMessage。

内容脚本的内容脚本

内容脚本可用直接和其他同一个上下文中的内容脚本通信。举个例子,如果一次 Tab.attach() 的调用附加了两个脚本,那么他们可用直接相互查看,就像加载在同一页面内的页面脚本一样。但是如果你调用 Tab.attach() 两次,每次附加一个内容脚本,那么这些内容脚本之间不能通信。你必须使用port API 通过 add-on 的主代码来传递消息。

跨域的内容脚本

默认情况下,内容脚本没有跨域的权限。特别是,它们不能访问在不同 iframe 中的在另外的域名上的内容,也不能发起跨域的 XMLHttpRequests。

但是,你可以把需要的域名添加到 package.json"permissions"键下的 "cross-domain-content"键下,为这些域名打开这些特性。参阅文章跨域内容脚本

 
 

文档标签和贡献者

 此页面的贡献者: zaobao
 最后编辑者: zaobao,