很多 add-ons 需要访问和修改 web 页面的内容。但是 add-on 的主代码不能直接访问 web 内容。替代方案是, SDK add-ons 需要使用一些分散的脚本代理访问 web 内容,这些脚本被称作内容脚本(content scripts)。本页面描述如何开发和部署内容脚本。
内容脚本是在使用SDK时很令人疑惑的点,但你很有可能不得不使用它们。下面有五个基本原则:
- add-on 的主代码,包括"main.js"和其他"lib"下的模块,可以使用 SDK 高层次和低层次 APIs,但不能直接访问 web 内容
- 内容脚本 不能使用 SDK 的 API(访问不了 globals 的
exports
、require
),但你可以访问 web 内容 - SDK API 可以使用,内容脚本,比如 page-mod 和 tabs,提供了一些函数,使得 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 用户接口组件,并且使用分类的脚本来和这些内容交互。从很多方面来讲,这些脚本就像内容脚本一样,但它们并不是本文的关注点。要学习如何和用户接口模块的内容交互,请参看模块定义文档:panel、sidebar、frame。
这篇指南中列出的几乎所有的示例都是完整并且且最小的,可以在 Github 的 addon-sdk-content-scripts repository 页面上获得。
加载用户脚本
你可以声明一个字符串或者指定 contentScript
或 contentScriptFile
选项加载一个单独的脚本。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
加载它。这回事你的代码更易维护、安全、调试和审核。
你可以给 contentScript
或 contentScriptFile
传递字符串数组来加载多个脚本:
// 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 页面加载一样。
你也可以把 contentScript
和 contentScriptFile
一起用。如果你这么做,使用 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 对象。
例如,假想一个页面用页面脚本添加变量 foo
到 window
对象:
<!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("https://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 });
在内容脚本中访问 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: "https://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: "https://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"
键下,为这些域名打开这些特性。参阅文章跨域内容脚本。