浏览器插件开发必备概念——定制网页体验

2,393 阅读10分钟

系列文章可以查看《浏览器扩展程序开发笔记》专栏


扩展程序可以使用很多相关的 API 以定制网页的体验:

Active Tab

参考:

在配置清单 manifest.json 的选项 permissions 中声明 activeTab 权限,可以临时获取许可,以访问当前激活的标签页,并在当前页面使用 tabs 相关的 API

{
  // ...
  "permissions": ["activeTab"]
}

由于扩展程序的很多使用场景都只需临时访问当前激活的标签页,而不是针对特定的网页,所以与基于 URL 规则获取的永久访问权限相比,该类型的权限更常用。该权限基于用户的主动请求临时获取的(例如通过点击 Action 控件),而且仅限制在当前激活页面,相对而言更安全。

支持多种用户交互方式激活 activeTab 权限,以与当前标签页进行交互:

  • 通过 Action

    // 点击浏览器工具栏上的扩展程序图标
    chrome.action.onClicked.addListener((tab) => {
      // 获取当前激活的标签页对象
      // 并植入内容脚本
      chrome.scripting.executeScript({
        target: { tabId: tab.id },
        function: reddenPage
      });
    });
    
    // 将网页背景色变为绿色
    function reddenPage() {
      document.body.style.backgroundColor = 'green';
    }
    
  • 通过 Context Menu

  • 通过 Commands

  • 通过 Omnibox

💡 当前标签页获取权限后,当后续的导航在同源 origin 中进行则权限还生效,例如当前页面的 URL 从 https://example.com 切换到 https://example.com/foo;如果导航到其他源 origin,或关闭当前页面后,权限就需要重新获取。

💡 该权限一般用以替代在配置清单 manifest.json 的选项 host_permissions 中声明的 <all_urls> 权限,达到访问(任意 url)当前页面的效果,而且不会在安装扩展程序时弹出警告

without-activetab

without activeTab

with-activetab

with activeTab

Content Scripts

参考:

可使用的 API

内容脚本 content script 是运行在页面中的 JavaScript 代码,它除了可以访问页面的 DOM 对象,还可以以通过信息传递 message passing 的方式与扩展程序进行通讯,可以将它看作是页面与扩展程序之间的桥梁角色。

除了借助扩展程序(通过 message passing 的方式)间接调用 Chrome 提供的 API,它还可以直接访问部分 API:

例如可以将扩展程序的静态资源展示在网页上

// Code for displaying <extensionDir>/images/myimage.png

// 访问扩展程序的静态资源(图片)
var imgURL = chrome.runtime.getURL("images/myimage.png");
// 将图片展示在运行着内容脚本的页面上
document.getElementById("someImage").src = imgURL;

独立运行环境

内容脚本 content script 在一个独立的环境中执行(私有作用域),因此页面和扩展程序都无法访问内容脚本 content script 的变量,可以避免与页面或扩展程序的脚本发生冲突。

如果页面原来就有一个按钮点击的事件监听器

<html>
  <button id="mybutton">click me</button>
  <script>
    const greeting = "hello, ";
    const button = document.getElementById("mybutton");
    button.person_name = "Bob";
    button.addEventListener("click", () =>
      alert(greeting + button.person_name + ".")
    , false);
  </script>
</html>

在内容脚本植入到页面后,设置另一个按钮点击的事件监听器

const greeting = "hola, ";
const button = document.getElementById("mybutton");
button.person_name = "Roberto";
button.addEventListener("click", () =>
  alert(greeting + button.person_name + ".")
, false);

两个脚本互不冲突,所以最后点击一次按钮后,会有两次 alert 弹出。

⚠️ 虽然内容脚本 content scripts 运行在独立环境,但是它可以与网页交互的特点,很可能会被恶意程序利用,因此在内容脚本 content scripts 中请求访问外部服务器的数据时,需要谨慎,避免 XSS 攻击中间人攻击

植入脚本

内容脚本 content script 可以通过声明的方式静态植入,或通过编程的方式动态植入

  • 静态植入

    在配置清单 manifest.json 的选项 cjontent_scripts 中声明的 JavaScript 脚本文件和 CSS 样式文件,会植入到匹配的网页。

    • (必须)属性 match 是包含一系列匹配规则的数组,用以筛选匹配那些网页需要静态植入内容脚本
    • 属性 exclude_match 排除匹配的网页
    • 属性 css 和是包含一系列样式文件(路径)的数组,样式文件会在页面的 DOM 构建完成之前,按照数组元素的顺序依次植入
    • 属性 js 是包含一系列脚本文件(路径)的数组,脚本按照数组元素的顺序依次植入
    • 属性 match_about_blank 是一个布尔值,默认值为 false,以控制内容脚本 content script 是否植入到 about:blank 空页面中(当空页面与 match 条件之一匹配时)
    • 属性 run_at 设置脚本植入的时间点,默认值是 document_idle,让浏览器决定在 document_end(DOM 树构建完成时)和 window.onload 事件触发之间择时植入;还可以设置为 document_startdocument_end
    • 属性 all_frames 是一个布尔值,用以控制植入的脚本是否在标签页中所有的 frame 中运行,默认值是 false,即脚本只是植入到主框架中
    {
     // ...
     "content_scripts": [
       {
         "matches": ["https://*.nytimes.com/*"],
         "css": ["my-styles.css"],
         "js": ["content-script.js"]
       }
     ],
    }
    

    💡 还有一种设置网页匹配规则的方式是属性 include_globsexclude_globs,它们的规则更加灵活

  • 动态植入

    在后台脚本中就可以使用方法 chrome.scripting.executeScript() 为页面动态植入代码,需要在配置清单 manifest.json 中先声明权限

    {
      // ...
      "permissions": ["scripting"],
    }
    

    为了可以在运行时以编程的方式动态地在页面植入脚本,需要在配置清单 manifest.json 的选项 host_permissions 中获取访问相应页面的权限;或者在选项 permissions 中声明 activeTab 权限,这样就可以为当前激活页面植入脚本。

    {
      // ...
      "permissions": [
        "scripting",
        "activeTab"
      ],
      "background": {
        "service_worker": "background.js"
      }
    }
    
    // content-script.js
    // 需要植入的代码
    document.body.style.backgroundColor = 'orange';
    
    // background.js
    chrome.action.onClicked.addListener((tab) => {
      // 在指定的标签页中植入指定的脚本或样式文件
      chrome.scripting.executeScript({
        target: { tabId: tab.id },
        files: ['content-script.js']
      });
    });
    

    方法 chrome.scription.executeScript() 可以接收两个参数,第一个参数是执行脚本的配置对象,第二个参数是回调函数。

    • 执行脚本的配置对象有多个属性:

      • (必须)属性 target 设置植入目标对象(标签页)

      • 属性 files 是一个包含脚本文件(路径)或样式文件(路径)的数组(💡 当前最多只支持一个文件)。如果植入的是 JavaScript 脚本,还支持指定需要执行的函数

        function getTitle() {
          return document.title;
        }
        
        const tabId = getTabId();
        
        chrome.scripting.executeScript(
          {
            target: {tabId: tabId},
            func: getTitle,
          },
          () => { ... }
        );
        

        ⚠️ 如果植入的是函数,它并不会携带在定义处的上下文,因此函数中类似 this 和指向函数外部作用域的变量会失效而报错。

        const color = getUserColor();
        
        function changeBackgroundColor() {
          document.body.style.backgroundColor = color;
        }
        
        const tabId = getTabId();
        
        chrome.scripting.executeScript(
          {
            target: {tabId: tabId},
              func: changeBackgroundColor,
            },
          () => { ... }
        );
        
        // result in a ReferenceError
        // because `color` is undefined when the function executes
        

        如果希望使用函数作用域外部的变量,可以通过选项 args 预先声明,它会作为参数传入到函数中

        const color = getUserColor();
        
        function changeBackgroundColor(backgroundColor) {
          document.body.style.backgroundColor = backgroundColor;
        }
        
        const tabId = getTabId();
        
        chrome.scripting.executeScript(
          {
            target: {tabId: tabId},
            func: changeBackgroundColor,
            args: [color],
          },
          () => { ... }
        );
        
        • 属性 allFrames 是控制注入的脚本运行在标签页里的所有 frame,默认值是 false,只运行在主框架中

          💡 如果希望注入的脚本运行在指定的 frame 中,可以在设置 frameIds 属性 target: { tabId: tabId, FrameIds: frameIds }

    • 第二个参数是回调函数,它接收包含脚本执行完成后返回值的数组作为入参(有多个返回值是因为植入脚本可以在标签页的所有 frame 中都运行),其中主框架的返回值是数组的第一个元素,而其他 frame 的返回值是乱序的

      function getTitle() {
        return document.title;
      }
      const tabId = getTabId();
      chrome.scripting.executeScript(
        {
          target: {tabId: tabId, allFrames: true},
          func: getTitle,
        },
        (injectionResults) => {
          for (const frameResult of injectionResults)
            console.log('Frame Title: ' + frameResult.result);
        }
      );
      

    💡 使用方法 chrome.scriptiong.insertCSS() 植入样式文件,除了支持以文件数组的方式,还支持字符串的形式,对于简单的样式这个方式更方便。该方法是植入样式到页面的,所以没有返回值作为回调函数的入参。

    const css = 'body { background-color: red; }';
    const tabId = getTabId();
    chrome.scripting.insertCSS(
      {
        target: {tabId: tabId},
        css: css,
      },
      () => { ... }
    );
    

监听网页消息

可以借助内容脚本 content scripts,实现普通的网页向扩展程序传递信息,使用方法 window.postMesage() 向自身传递信息,然后在内容脚本 content scripts 中监听消息事件 message 以捕获当前页面发送给自己的信息,然后在事件处理函数中,内容脚本 content scripts 就可以基于拦截的信息,执行相应的操作,例如与扩展程序进行交互。

// content scripts
const port = chrome.runtime.connect();

window.addEventListener("message", (event) => {
  // We only accept messages from ourselves
  if (event.source != window) {
    return;
  }

  if (event.data.type && (event.data.type == "FROM_PAGE")) {
    console.log("Content script received: " + event.data.text);
    port.postMessage(event.data.text);
  }
}, false);
<!-- web page -->
<button>send message</button>

<script>
document.getElementById("theButton").addEventListener("click", () => {
  window.postMessage({ type: "FROM_PAGE", text: "Hello from the webpage!" }, "*");
}, false);
</script>

Cross-Origin XHR

如果希望在扩展程序访问外部服务器,由于在 MV3 版本中,后台脚本 background script 运行在 Service Workers 中,没有全局变量 window 对象,因此无法使用 XMLHttpRequest ,但可以使用方法 fetch() 发起网络请求。

由于内容脚本 content script 以植入网页的方式运行,因此它受到同源策略的限制。而扩展程序的后台脚本 background scripts 就不受此限制,只需要在 host_permissions 中声明后就可以访问相应的远程服务器;如果是通过 fetch() 的方式获取扩展程序内部的静态资源,则不需要声明权限。

⚠️ 在扩展程序中使用从外部服务器的返回的数据时,需要谨慎处理,应该更安全的逻辑代码 和 API

例如对于以下不安全的处理方法

const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/data.json", true);
xhr.onreadystatechange = function() {
  if (xhr.readyState == 4) {
    // WARNING! Might be evaluating an evil script!
    var resp = eval("(" + xhr.responseText + ")");
    ...
  }
}
xhr.send();
const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/data.json", true);
xhr.onreadystatechange = function() {
  if (xhr.readyState == 4) {
    // WARNING! Might be injecting a malicious script!
    document.getElementById("resp").innerHTML = xhr.responseText;
    ...
  }
}
xhr.send();

应该改用更安全的方式

const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/data.json", true);
xhr.onreadystatechange = function() {
  if (xhr.readyState == 4) {
    // JSON.parse does not evaluate the attacker's scripts.
    var resp = JSON.parse(xhr.responseText);
  }
}
xhr.send();
const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/data.json", true);
xhr.onreadystatechange = function() {
  if (xhr.readyState == 4) {
    // textContent does not let the attacker inject HTML elements.
    document.getElementById("resp").textContent = xhr.responseText;
  }
}
xhr.send();

⚠️ 由于内容脚本 content scripts 受到同源政策的限制,可以通过信息传递 message passing 借助扩展程序来 fetch 相应的服务器获取数据。但是这种处理方式会让恶意网页有可乘之机,它们可以伪造信息请求,而让扩展程序访问指定的服务器。

// content-script.js
chrome.runtime.sendMessage(
  {
    contentScriptQuery: 'fetchUrl',
    url: 'https://another-site.com/price-query?itemId=' +
              encodeURIComponent(request.itemId)},
  response => parsePrice(response.text())
);
// background.js
chrome.runtime.onMessage.addListener(
  function(request, sender, sendResponse) {
    if (request.contentScriptQuery == 'fetchUrl') {
      // WARNING: SECURITY PROBLEM - a malicious web page may abuse
      // the message handler to get access to arbitrary cross-origin
      // resources.
      fetch(request.url)
        .then(response => response.text())
        .then(text => sendResponse(text))
        .catch(error => ...)
    return true;  // Will respond asynchronously.
  }
});

该采用更安全的方式,不要通过信息传递 message passing 的方式直接传递完整的 URL,而是传递部分 query 参数,这样就可以在扩展程序中预设(限制)可以访问的域名

// content-script.js
chrome.runtime.sendMessage(
  {
    contentScriptQuery: 'queryPrice',
    itemId: 12345
  },
  price => {...}
);
// backgound.js
chrome.runtime.onMessage.addListener(
  function(request, sender, sendResponse) {
    if (request.contentScriptQuery == 'queryPrice') {
      var url = 'https://another-site.com/price-query?itemId=' +
            encodeURIComponent(request.itemId);
      fetch(url)
        .then(response => response.text())
        .then(text => parsePrice(text))
        .then(price => sendResponse(price))
        .catch(error => ...)
    return true;  // Will respond asynchronously.
  }
});