从扩展中访问在页面上下文中定义的变量和函数。

617
我想在我的扩展程序中控制youtube.com的播放器。

manifest.json:

{
    "name": "MyExtension",
    "version": "1.0",
    "description": "Gotta catch Youtube events!",
    "permissions": ["tabs", "http://*/*"],
    "content_scripts" : [{
        "matches" : [ "www.youtube.com/*"],
        "js" : ["myScript.js"]
    }]
}

myScript.js:

function state() { console.log("State Changed!"); }
var player = document.getElementById("movie_player");
player.addEventListener("onStateChange", "state");
console.log("Started!");

问题是控制台给我显示了"Started!",但是当我播放/暂停YouTube视频时却没有"State Changed!"。
当这段代码放在控制台中时,它是有效的。我做错了什么?

21
尝试去掉函数名周围的引号:player.addEventListener("onStateChange", state); - Eduardo
5
值得注意的是,在编写匹配规则时,不要忘记包含 https://http://,在 www.youtube.com/* 中,如果没有包含协议前缀,就无法打包扩展,并且会抛出 Missing scheme separator error 错误。 - Nilay Vishwakarma
2
另请参见https://bugs.chromium.org/p/chromium/issues/detail?id=478183 - Pacerier
7个回答

1248
根本原因:
内容脚本在一个“隔离”环境中执行,意味着它无法访问主页面上的JS函数和变量,也无法暴露自己的JS内容,比如你的情况中的state()方法。 解决方案:
使用下面所示的方法将代码注入到页面的JS上下文(主页面)中。 关于使用chrome API:
 • 通过在<all_urls>上允许的externally_connectable消息传递,自Chrome 107版本开始可用。
 • 通过与正常内容脚本使用CustomEvent消息传递,详见下一段落。

在与正常内容脚本进行消息传递时:
使用CustomEvent,如这里,或这里,或这里。简而言之,注入的脚本向正常内容脚本发送消息,正常内容脚本调用chrome.storagechrome.runtime.sendMessage,然后通过另一个CustomEvent消息将结果发送回注入的脚本。不要使用window.postMessage,因为您的数据可能会破坏那些期望特定格式的消息的站点。

注意!
页面可能会重新定义内置原型或全局对象,并窃取您的私人通信数据或导致您的注入代码失败。防范这种情况很复杂(请参考Tampermonkey或Violentmonkey的“vault”),因此请确保验证所有接收到的数据。

目录

那么,什么是最好的选择呢?如果代码应该始终运行,那么使用ManifestV3的声明式方法(#5);如果需要从扩展脚本(如弹出窗口或服务工作者)进行条件性注入,则使用chrome.scripting(#4);否则,使用基于内容脚本的方法(#1和#3)。
- 内容脚本控制注入: - 方法1:注入另一个文件 - ManifestV3兼容 - 方法2:注入嵌入代码 - MV2 - 方法2b:使用函数 - MV2 - 方法3:使用内联事件 - ManifestV3兼容
- 扩展脚本控制注入(例如后台服务工作者或弹出窗口脚本): - 方法4:使用executeScript的world - 仅ManifestV3
- 声明式注入: - 方法5:在manifest.json中使用world - 仅ManifestV3,Chrome 111+
- 注入代码中的动态值

方法一:注入另一个文件(ManifestV3/MV2)

当你有大量代码时,这种方法特别适用。将代码放在扩展中的一个文件中,比如script.js。然后在你的内容脚本中加载它,就像这样:

var s = document.createElement('script');
s.src = chrome.runtime.getURL('script.js');
s.onload = function() { this.remove(); };
// see also "Dynamic values in the injected code" section in this answer
(document.head || document.documentElement).appendChild(s);
js文件必须在web_accessible_resources中公开:
  • ManifestV2的manifest.json示例

    "web_accessible_resources": ["script.js"],
    
  • ManifestV3的manifest.json示例

    "web_accessible_resources": [{
      "resources": ["script.js"],
      "matches": ["<all_urls>"]
    }]
    
如果不这样做,控制台将出现以下错误:
拒绝加载 chrome-extension://[EXTENSIONID]/script.js。资源必须在 web_accessible_resources 清单键中列出,以便被扩展之外的页面加载。
方法2:注入嵌入代码(MV2)
当您想快速运行一小段代码时,这种方法非常有用。(另请参阅:如何使用Chrome扩展禁用Facebook热键?)。
var actualCode = `// Code here.
// If you want to use a variable, use $ and curly braces.
// For example, to use a fixed random number:
var someFixedRandomValue = ${ Math.random() };
// NOTE: Do not insert unsafe variables in this way, see below
// at "Dynamic values in the injected code"
`;

var script = document.createElement('script');
script.textContent = actualCode;
(document.head||document.documentElement).appendChild(script);
script.remove();

注意:模板文字仅在Chrome 41及以上版本中支持。如果您希望在Chrome 40及更低版本中使用该扩展,请使用以下方法:
var actualCode = ['/* Code here. Example: */' + 'alert(0);',
                  '// Beware! This array have to be joined',
                  '// using a newline. Otherwise, missing semicolons',
                  '// or single-line comments (//) will mess up your',
                  '// code ----->'].join('\n');

方法2b:使用函数(MV2)

对于大块的代码,引用字符串是不可行的。可以使用一个函数来代替使用数组,并将其转换为字符串:

var actualCode = '(' + function() {
    // All code is executed in a local scope.
    // For example, the following does NOT overwrite the global `alert` method
    var alert = null;
    // To overwrite a global variable, prefix `window`:
    window.alert = null;
} + ')();';
var script = document.createElement('script');
script.textContent = actualCode;
(document.head||document.documentElement).appendChild(script);
script.remove();

这种方法有效,因为字符串的+运算符和一个函数将所有对象转换为字符串。如果您打算多次使用该代码,最好创建一个函数以避免代码重复。一个实现可能如下所示:
function injectScript(func) {
    var actualCode = '(' + func + ')();'
    ...
}
injectScript(function() {
   alert("Injected script");
});

注意:由于函数被序列化,原始范围和所有绑定的属性都会丢失!
var scriptToInject = function() {
    console.log(typeof scriptToInject);
};
injectScript(scriptToInject);
// Console output:  "undefined"

方法三:使用内联事件(ManifestV3/MV2)

有时候,你想要立即运行一些代码,例如在创建<head>元素之前运行一些代码。这可以通过插入一个带有textContent<script>标签来实现(参见方法2/2b)。

另一种选择,但不推荐使用的方法是使用内联事件。不推荐使用的原因是,如果页面定义了禁止内联脚本的内容安全策略,那么内联事件监听器将被阻止。然而,扩展程序注入的内联脚本仍然会运行。 如果你仍然想要使用内联事件,以下是方法:

var actualCode = '// Some code example \n' + 
                 'console.log(document.documentElement.outerHTML);';

document.documentElement.setAttribute('onreset', actualCode);
document.documentElement.dispatchEvent(new CustomEvent('reset'));
document.documentElement.removeAttribute('onreset');

注意:此方法假设没有其他全局事件监听器处理“reset”事件。如果有其他事件,您也可以选择其他全局事件。只需打开JavaScript控制台(F12),输入“document.documentElement.on”,然后选择可用的事件之一。
方法4:使用chrome.scripting API“world”(仅适用于ManifestV3)
- Chrome 95或更新版本,使用“chrome.scripting.executeScript”和“world:'MAIN'” - Chrome 102或更新版本,使用“chrome.scripting.registerContentScripts”和“world:'MAIN'”,还允许使用“runAt:'document_start'”来确保页面脚本的早期执行。
与其他方法不同,此方法适用于后台脚本或弹出脚本,而不适用于内容脚本。请参阅documentationexamples获取更多信息。
方法五:在 manifest.json 中使用 world(仅适用于 ManifestV3)
在 Chrome 111 或更高版本中,您可以在 manifest.json 的 content_scripts 声明中添加 "world": "MAIN",以覆盖默认值 ISOLATED。脚本按照列出的顺序运行。
  "content_scripts": [{
    "world": "MAIN",
    "js": ["page.js"],
    "matches": ["<all_urls>"],
    "run_at": "document_start"
  }, {
    "js": ["content.js"],
    "matches": ["<all_urls>"],
    "run_at": "document_start"
  }],

注入代码中的动态值(MV2)

有时候,您需要将任意变量传递给注入的函数。例如:

var GREETING = "Hi, I'm ";
var NAME = "Rob";
var scriptToInject = function() {
    alert(GREETING + NAME);
};

要注入这段代码,你需要将变量作为参数传递给匿名函数。务必正确实现!以下方法不会起作用:
var scriptToInject = function (GREETING, NAME) { ... };
var actualCode = '(' + scriptToInject + ')(' + GREETING + ',' + NAME + ')';
// The previous will work for numbers and booleans, but not strings.
// To see why, have a look at the resulting string:
var actualCode = "(function(GREETING, NAME) {...})(Hi, I'm ,Rob)";
//                                                 ^^^^^^^^ ^^^ No string literals!

解决方案是在传递参数之前使用JSON.stringify。例如:
var actualCode = '(' + function(greeting, name) { ...
} + ')(' + JSON.stringify(GREETING) + ',' + JSON.stringify(NAME) + ')';

如果你有很多变量,值得使用JSON.stringify一次,以提高可读性,如下所示:
...
} + ')(' + JSON.stringify([arg1, arg2, arg3, arg4]).slice(1, -1) + ')';

注入代码中的动态值(ManifestV3)
  • 使用方法1并添加以下行:

    s.dataset.params = JSON.stringify({foo: 'bar'});
    

    然后注入的script.js可以读取它:

    (() => {
      const params = JSON.parse(document.currentScript.dataset.params);
      console.log('injected params', params);
    })();
    

    为了隐藏页面脚本中的参数,您可以将脚本元素放在封闭的ShadowDOM中。

  • 方法4 executeScript有args参数,registerContentScripts目前没有(希望将来会添加)。


136
这个答案应该成为官方文档的一部分。官方文档应该包含推荐的方法,--> 三种完成同样任务的方式……有问题吗? - Mars Robertson
4
尽可能情况下,通常方法1更好,这是因为Chrome对某些扩展使用了CSP(内容安全策略)限制。 - Qantas 94 Heavy
15
扩展程序的CSP并不影响内容脚本。只有页面的CSP是相关的。第一种方法可以通过使用“ script-src”指令来阻止,该指令排除了扩展的来源;第二种方法可以通过使用排除“ unsafe-inline”的CSP来阻止。 - Rob W
7
有人问为什么我使用script.parentNode.removeChild(script);删除脚本标签。我这样做的原因是因为我喜欢清理我的“垃圾”。当行内脚本被插入文档后,它会立即执行,因此<script>标签可以安全地被删除。 - Rob W
9
另一种方法:在您的内容脚本中的任何位置使用location.href ="javascript: alert('yeah')";。这对于短代码片段更容易,并且还可以访问页面的JS对象。 - Métoule
显示剩余58条评论

112

罗布·W的优秀答案中唯一缺少的是如何在注入的页面脚本和内容脚本之间进行通信。

在接收端(无论是您的内容脚本还是注入的页面脚本),添加事件侦听器:

document.addEventListener('yourCustomEvent', function (e) {
  var data = e.detail;
  console.log('received', data);
});

在发起方面(内容脚本或注入页面脚本)发送事件:

var data = {
  allowedTypes: 'those supported by structured cloning, see the list below',
  inShort: 'no DOM elements or classes/functions',
};

document.dispatchEvent(new CustomEvent('yourCustomEvent', { detail: data }));

注意:

  • DOM消息传递使用结构化克隆算法,除了原始值外,它只能传输某些数据类型。它无法发送类实例、函数或DOM元素。
  • 在Firefox中,要从内容脚本发送一个对象(即非原始值)到页面上下文中,您必须使用cloneInto(一个内置函数)将其显式克隆到目标中,否则它将失败并显示安全违规错误。

  • document.dispatchEvent(new CustomEvent('yourCustomEvent', {
      detail: cloneInto(data, document.defaultView),
    }));
    

1
我实际上在我的回答的第二行中链接了代码和解释,链接地址为https://dev59.com/5Wkw5IYBdhLWcg3w2-Mk。 - Rob W
1
你有更新方法的参考资料吗(例如错误报告或测试用例)?CustomEvent 构造函数取代了已弃用的 document.createEvent API。 - Rob W
1
在使用CustomEvent构造函数时,一定要特别注意传递第二个参数。我曾经遇到过两个非常令人困惑的问题:1. 只是简单地在'detail'周围加上单引号,却使得我的内容脚本监听器接收到的值变成了null。2. 更重要的是,由于某种原因,我不得不使用JSON.parse(JSON.stringify(myData)),否则它也会变成null。鉴于此,我认为以下Chromium开发者的说法——自动使用“结构化克隆”算法——并不正确。https://bugs.chromium.org/p/chromium/issues/detail?id=260378#c18 - jdunk
4
我认为官方的方法是使用 window.postMessage:https://developer.chrome.com/extensions/content_scripts#host-page-communication。 - Enrique
2
如何从内容脚本发送响应到发起脚本 - Vinay
显示剩余4条评论

9

我也曾面临过已加载脚本的顺序问题,通过顺序加载脚本来解决。这种加载是基于Rob W的答案

function scriptFromFile(file) {
    var script = document.createElement("script");
    script.src = chrome.extension.getURL(file);
    return script;
}

function scriptFromSource(source) {
    var script = document.createElement("script");
    script.textContent = source;
    return script;
}

function inject(scripts) {
    if (scripts.length === 0)
        return;
    var otherScripts = scripts.slice(1);
    var script = scripts[0];
    var onload = function() {
        script.parentNode.removeChild(script);
        inject(otherScripts);
    };
    if (script.src != "") {
        script.onload = onload;
        document.head.appendChild(script);
    } else {
        document.head.appendChild(script);
        onload();
    }
}

使用示例如下:
var formulaImageUrl = chrome.extension.getURL("formula.png");
var codeImageUrl = chrome.extension.getURL("code.png");

inject([
    scriptFromSource("var formulaImageUrl = '" + formulaImageUrl + "';"),
    scriptFromSource("var codeImageUrl = '" + codeImageUrl + "';"),
    scriptFromFile("EqEditor/eq_editor-lite-17.js"),
    scriptFromFile("EqEditor/eq_config.js"),
    scriptFromFile("highlight/highlight.pack.js"),
    scriptFromFile("injected.js")
]);

实际上,我对JS还比较新手,所以请随时联系我,告诉我更好的方法。


3
这种插入脚本的方式不好,因为它会污染网页的命名空间。如果网页使用了名为 formulaImageUrlcodeImageUrl 的变量,那么你实际上破坏了页面的功能。如果你想将变量传递给网页,建议将数据附加到脚本元素上(例如:script.dataset.formulaImageUrl = formulaImageUrl;),然后在脚本中使用 (function() { var dataset = document.currentScript.dataset; alert(dataset.formulaImageUrl;) })(); 来访问数据。 - Rob W
@RobW 感谢您的留言,尽管它更多关于这个示例。您能否请澄清一下,为什么我应该使用IIFE而不是只获取“dataset”? - Dmitry Ginzburg
4
document.currentScript 仅在脚本执行时指向该脚本标签。如果您想要访问脚本标签及其属性/属性(例如 dataset),则需要将其存储在变量中。我们需要一个 IIFE 来获取闭包以存储此变量,而不会污染全局命名空间。 - Rob W
2
你可以使用IIFE,但是使用它的成本微不足道,所以我不认为污染命名空间比IIFE更好。我重视确保不会以某种方式破坏其他网页,并且能够使用短变量名的确定性。使用IIFE的另一个优点是,如果需要,您可以更早地退出脚本(return;)。 - Rob W
我一直在提到 injected.js,而不是注入 injected.js 的内容脚本。在内容脚本中,IIFE 并不重要,因为你对(全局)命名空间有完全的控制。 - Rob W
显示剩余2条评论

8
在内容脚本中,我向头部添加了一个绑定'onmessage'处理程序的脚本标签,在处理程序中,我使用eval执行代码。 在两个内容脚本中,我都使用onmessage处理程序,这样我就可以实现双向通信。 Chrome文档
//Content Script

var pmsgUrl = chrome.extension.getURL('pmListener.js');
$("head").first().append("<script src='"+pmsgUrl+"' type='text/javascript'></script>");


//Listening to messages from DOM
window.addEventListener("message", function(event) {
  console.log('CS :: message in from DOM', event);
  if(event.data.hasOwnProperty('cmdClient')) {
    var obj = JSON.parse(event.data.cmdClient);
    DoSomthingInContentScript(obj);
 }
});

pmListener.js是一个监听post message的url的工具

//pmListener.js

//Listen to messages from Content Script and Execute Them
window.addEventListener("message", function (msg) {
  console.log("im in REAL DOM");
  if (msg.data.cmnd) {
    eval(msg.data.cmnd);
  }
});

console.log("injected To Real Dom");

这样,我就可以让CS与真实Dom之间进行双向通信。这非常有用,例如,如果您需要监听WebSocket事件,或者任何内存变量或事件。

8

我创建了一种实用函数,可以在页面上下文中运行代码并获取返回值。

这是通过将函数序列化为字符串并注入到网页中来完成的。

该实用程序在GitHub上提供。

使用示例 -



// Some code that exists only in the page context -
window.someProperty = 'property';
function someFunction(name = 'test') {
    return new Promise(res => setTimeout(()=>res('resolved ' + name), 1200));
}
/////////////////

// Content script examples -

await runInPageContext(() => someProperty); // returns 'property'

await runInPageContext(() => someFunction()); // returns 'resolved test'

await runInPageContext(async (name) => someFunction(name), 'with name' ); // 'resolved with name'

await runInPageContext(async (...args) => someFunction(...args), 'with spread operator and rest parameters' ); // returns 'resolved with spread operator and rest parameters'

await runInPageContext({
    func: (name) => someFunction(name),
    args: ['with params object'],
    doc: document,
    timeout: 10000
} ); // returns 'resolved with params object'



4
如果您希望注入纯函数而不是文本,可以使用以下方法:

function inject(){
    document.body.style.backgroundColor = 'blue';
}

// this includes the function as text and the barentheses make it run itself.
var actualCode = "("+inject+")()"; 

document.documentElement.setAttribute('onreset', actualCode);
document.documentElement.dispatchEvent(new CustomEvent('reset'));
document.documentElement.removeAttribute('onreset');

你可以将参数传递给函数(不幸的是,无法将对象和数组字符串化)。将其添加到括号中,就像这样:

function inject(color){
    document.body.style.backgroundColor = color;
}

// this includes the function as text and the barentheses make it run itself.
var color = 'yellow';
var actualCode = "("+inject+")("+color+")"; 


这很酷...但第二个版本,使用颜色变量,对我来说不起作用...我得到了“无法识别”的错误,代码抛出了一个错误...没有将其视为变量。 - 11teenth
2
第一个例子非常好用。非常感谢您的回答。即使内联脚本受限,这也可以工作,您先生值得尊敬。 - John Yepthomi
1
不需要来回传递消息的绝佳解决方法。 - forgetso
1
content-script.js:拒绝执行内联事件处理程序,因为它违反了以下内容安全策略指令:“script-src 'report-sample'”。 - stallingOne

1
如果您想在注入的代码(ManifestV3)中使用动态值,并且您注入的脚本类型是模块,那么您不能像Rob的回答中所描述的那样使用document.currentScript.dataset,而是可以将参数作为URL参数传递,并在注入的代码中检索它们。以下是一个示例:
内容脚本:
var s = document.createElement('script');
s.src = chrome.runtime.getURL('../override-script.js?extensionId=' + chrome.runtime.id);
s.type = 'module';
s.onload = function () {
    this.remove();
};
(document.head || document.documentElement).appendChild(s);

注入的代码(在我的情况下是override-script.js):
let extensionId = new URL(import.meta.url).searchParams.get("extensionId")

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接