如何创建浏览器扩展

Avatar of Lars Kölker
Lars Kölker

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

我敢打赌您现在正在使用浏览器扩展。其中一些非常流行且有用,例如广告拦截器、密码管理器和 PDF 阅读器。这些扩展(或“附加组件”)不仅限于这些用途——您可以用它们做更多的事情!在本文中,我将向您介绍如何创建扩展。最终,我们将使其在多个浏览器中运行。

我们将要制作什么

我们将制作一个名为“Reddit 转录员”的扩展,它将通过将特定评论移动到评论部分的顶部并添加aria-属性来改善 Reddit 的可访问性,以供屏幕阅读器使用。我们还将通过添加选项来为评论添加边框和背景以获得更好的文本对比度,从而进一步扩展我们的扩展。

整个想法是您将获得有关如何开发浏览器扩展的良好介绍。我们将首先为基于 Chromium 的浏览器(例如 Google Chrome、Microsoft Edge、Brave 等)创建扩展。在以后的文章中,我们将移植扩展以使其与 Firefox 以及 Safari 兼容,Safari 最近在其 MacOS 和 iOS 版本的浏览器中添加了对 Web 扩展的支持

准备好了吗?让我们一步一步来。

创建工作目录

首先,我们需要一个项目的工作空间。我们真正需要的只是创建一个文件夹并为其命名(我将其命名为transcribers-of-reddit)。然后,在其中创建一个名为src的另一个文件夹,用于我们的源代码。

定义入口点

入口点是一个包含扩展的一般信息(即扩展名称、描述等)并定义要执行的权限或脚本的文件。

我们的入口点可以是位于我们刚刚创建的src文件夹中的manifest.json文件。在其中,让我们添加以下三个属性

{
  "manifest_version": 3,
  "name": "Transcribers of Reddit",
  "version": "1.0"
}

manifest_version类似于 npm 或 Node 中的版本。它定义了哪些 API 可用(或不可用)。我们将使用最新的版本 3(也称为mv3)进行最前沿的工作。

第二个属性是name,它指定了我们的扩展名称。此名称是在扩展出现的所有位置显示的名称,例如Chrome 网上应用商店和 Chrome 浏览器中的chrome://extensions页面。

然后是version。它使用版本号标记扩展。请记住,此属性(与manifest_version相反)是一个字符串,只能包含数字和点(例如 1.3.5)。

更多manifest.json信息

实际上,我们还可以添加更多内容来帮助为我们的扩展添加上下文。例如,我们可以提供一个description来解释扩展的功能。提供这些信息是个好主意,因为它可以让用户在使用扩展时更好地了解他们将获得什么。

在这种情况下,我们不仅添加了描述,还提供了图标和 Chrome 网上应用商店在扩展页面上指向的网络地址。

{
  "description": "Reddit made accessible for disabled users.",
  "icons": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  },
  "homepage_url": "https://lars.koelker.dev/extensions/tor/"
}
  • description显示在 Chrome 的管理页面(chrome://extensions)上,应简短,少于 132 个字符。
  • icons在很多地方使用。如文档所述,最好以不同分辨率提供相同图标的三个版本,最好是 PNG 文件。您可以随意使用GitHub 存储库中此示例中的图标。
  • homepage_url可用于将您的网站与扩展关联。单击管理页面上的“更多详细信息”时,将显示包含链接的按钮。
我们在扩展管理页面中打开的扩展卡。

设置权限

扩展的一大优势是它们的 API 允许您直接与浏览器交互。但是我们必须明确授予扩展这些权限,这也包含在manifest.json文件中。


{
  "manifest_version": 3,
  "name": "Transcribers of Reddit",
  "version": "1.0",
  "description": "Reddit made accessible for disabled users.",
  "icons": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  },
  "homepage_url": "https://lars.koelker.dev/extensions/tor/",

  "permissions": [
    "storage",
    "webNavigation"
  ]
}

我们刚刚授予此扩展哪些权限?首先是存储。我们希望此扩展能够保存用户的设置,因此我们需要访问浏览器的 Web 存储来保存它们。例如,如果用户希望评论具有红色边框,那么我们将保存该设置以备下次使用,而不是让他们再次设置。

我们还授予扩展权限来查看用户如何导航到当前屏幕。Reddit 是一个单页面应用程序 (SPA),这意味着它不会触发页面刷新。我们需要“捕获”此交互,因为只有当我们点击帖子时,Reddit 才会加载帖子的评论。因此,这就是我们利用webNavigation的原因。

我们将在稍后讨论在页面上执行代码,因为它需要在manifest.json中添加一个全新的条目。

/explanation 根据允许的权限,浏览器可能会向用户显示警告以接受权限。但是,只有某些权限会这样做,并且Chrome 对其进行了很好的概述

管理翻译

浏览器扩展具有内置的国际化 (i18n) API。它允许您管理多种语言的翻译(完整列表)。要使用 API,我们必须在manifest.json文件中定义我们的翻译和默认语言

"default_locale": "en"

这将英语设置为语言。如果浏览器设置为任何不受支持的其他语言,则扩展将回退到默认语言环境(在此示例中为en)。

我们的翻译定义在_locales目录中。让我们在其中为每种您想要支持的语言创建一个文件夹。每个子目录都有自己的messages.json文件。

src 
 └─ _locales
     └─ en
        └─ messages.json
     └─ fr
        └─ messages.json

翻译文件由多个部分组成

  • 翻译键(“id”):此键用于引用翻译。
  • 消息:实际的翻译内容
  • 描述(可选):描述翻译(我不建议使用它们,它们只会使文件膨胀,并且您的翻译键应该足够描述性)
  • 占位符(可选):可用于在翻译中插入动态内容

以下是一个将所有这些整合在一起的示例

{
  "userGreeting": { // Translation key ("id")
    "message": "Good $daytime$, $user$!" // Translation
    "description": "User Greeting", // Optional description for translators
    "placeholders": { // Optional placeholders
      "daytime": { // As referenced inside the message
        "content": "$1",
        "example": "morning" // Example value for our content
      },
      "user": { 
        "content": "$1",
        "example": "Lars"
      }
    }
  }
}

使用占位符更具挑战性。首先,我们需要在消息中定义占位符。占位符需要用$字符括起来。之后,我们必须将占位符添加到“占位符列表”中。这有点不直观,但 Chrome 希望知道应该为我们的占位符插入什么值。我们(显然)希望在这里使用动态值,因此我们使用特殊内容值$1,它引用了我们插入的值。

example属性是可选的。它可以用于向翻译人员提示占位符可能是什么值(但实际上不会显示)。

我们需要为我们的扩展定义以下翻译。将它们复制并粘贴到messages.json文件中。随意添加更多语言(例如,如果您说德语,请在_locales中添加一个de文件夹,依此类推)。

{
  "name": {
    "message": "Transcribers of Reddit"
  },
  "description": {
    "message": "Accessible image descriptions for subreddits."
  },
  "popupManageSettings": {
    "message": "Manage settings"
  },
  "optionsPageTitle": {
    "message": "Settings"
  },
  "sectionGeneral": {
    "message": "General settings"
  },
  "settingBorder": {
    "message": "Show comment border"
  },
  "settingBackground": {
    "message": "Show comment background"
  }
}

您可能想知道为什么我们在没有 i18n 权限的情况下注册了权限,对吧?Chrome 在这方面有点奇怪,因为您不需要注册每个权限。有些(例如chrome.i18n)不需要在清单中注册。其他权限需要条目,但在安装扩展时不会显示给用户。其他一些权限是“混合”的(例如chrome.runtime),这意味着它们的一些函数可以在不声明权限的情况下使用——但同一 API 的其他函数需要在清单中注册。您需要查看文档,以全面了解差异。

在清单文件中使用翻译

我们的最终用户首先看到的是 Chrome 网上应用商店中的条目或扩展程序概述页面。我们需要调整清单文件以确保所有内容都已翻译。

{
  // Update these entries
  "name": "__MSG_name__",
  "description": "__MSG_description__"
}

应用此语法会在我们的 messages.json 文件中使用相应的翻译(例如,_MSG_name_ 使用 name 翻译)。

在 HTML 页面中使用翻译

在 HTML 文件中应用翻译需要一些 JavaScript 代码。

chrome.i18n.getMessage('name');

该代码返回我们定义的翻译(即 Transcribers of Reddit)。占位符可以用类似的方式完成。

chrome.i18n.getMessage('userGreeting', {
  daytime: 'morning',
  user: 'Lars'
});

如果要通过这种方式将翻译应用于所有元素,那将是一件很麻烦的事情。但是,我们可以编写一个小的脚本,根据 data- 属性执行翻译。因此,让我们在 src 目录中创建一个新的 js 文件夹,然后在其中添加一个新的 util.js 文件。

src 
 └─ js
     └─ util.js

这可以完成任务

const i18n = document.querySelectorAll("[data-intl]");
i18n.forEach(msg => {
  msg.innerHTML = chrome.i18n.getMessage(msg.dataset.intl);
});

chrome.i18n.getAcceptLanguages(languages => {
  document.documentElement.lang = languages[0];
});

将该脚本添加到 HTML 页面后,我们可以向元素添加 data-intl 属性来设置其内容。文档语言也将根据用户语言设置。

<!-- Before JS execution -->
<html>
  <body>
    <button data-intl="popupManageSettings"></button>
  </body>
</html>
<!-- After JS execution -->
<html lang="en">
  <body>
    <button data-intl="popupManageSettings">Manage settings</button>
  </body>
</html>

添加弹出窗口和选项页面

在我们深入实际编程之前,我们需要创建两个页面

  1. 一个包含用户设置的选项页面
  2. 一个弹出窗口页面,当与地址栏旁边的扩展程序图标交互时打开。此页面可用于各种场景(例如,显示统计信息或快速设置)。
包含我们设置的选项页面。
包含指向选项页面的链接的弹出窗口。

以下是我们需要创建页面的文件夹和文件概述

src 
 ├─ css
 |    └─ paintBucket.css
 ├─ popup
 |    ├─ popup.html
 |    ├─ popup.css
 |    └─ popup.js
 └─ options
      ├─ options.html
      ├─ options.css
      └─ options.js

.css 文件包含纯 CSS,仅此而已。我不会详细介绍,因为我知道你们中的大多数人已经完全了解 CSS 的工作原理。您可以从此项目的 GitHub 存储库 中复制粘贴样式。

请注意,弹出窗口不是选项卡,其大小取决于其中的内容。如果要使用固定大小的弹出窗口,可以在 html 元素上设置 widthheight 属性。

创建弹出窗口

这是一个 HTML 骨架,它链接了 CSS 和 JavaScript 文件,并在 <body> 中添加了标题和按钮。

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title data-intl="name"></title>

    <link rel="stylesheet" href="../css/paintBucket.css">
    <link rel="stylesheet" href="popup.css">

    <!-- Our "translation" script -->
    <script src="../js/util.js" defer></script>
    <script src="popup.js" defer></script>
  </head>
  <body>
    <h1 id="title"></h1>
    <button data-intl="popupManageSettings"></button>
  </body>
</html>

h1 包含扩展程序名称和版本;button 用于打开选项页面。标题不会填充翻译(因为它缺少 data-intl 属性),并且按钮还没有任何点击处理程序,因此我们需要填充我们的 popup.js 文件

const title = document.getElementById('title');
const settingsBtn = document.querySelector('button');
const manifest = chrome.runtime.getManifest();

title.textContent = `${manifest.name} (${manifest.version})`;

settingsBtn.addEventListener('click', () => {
  chrome.runtime.openOptionsPage();
});

此脚本首先查找清单文件。Chrome 提供了 runtime API,其中包含 getManifest 方法(此特定方法不需要 runtime 权限)。它将我们的 manifest.json 作为 JSON 对象返回。在用扩展程序名称和版本填充标题后,我们可以向设置按钮添加事件侦听器。如果用户与之交互,我们将使用 chrome.runtime.openOptionsPage() 打开选项页面(同样不需要权限条目)。

弹出窗口页面现在已完成,但扩展程序尚不知道它的存在。我们必须通过将以下属性附加到 manifest.json 文件来注册弹出窗口。

"action": {
  "default_popup": "popup/popup.html",
  "default_icon": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  }
},

创建选项页面

创建此页面遵循与我们刚刚完成的非常相似的过程。首先,我们填充 options.html 文件。以下是一些我们可以使用的标记

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title data-intl="name"></title>

  <link rel="stylesheet" href="../css/paintBucket.css">
  <link rel="stylesheet" href="options.css">

  <!-- Our "translation" script -->
  <script src="../js/util.js" defer></script>
  <script src="options.js" defer></script>
</head>
<body>
  <header>
    <h1>
      <!-- Icon provided by feathericons.com -->
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" role="presentation">
        <circle cx="12" cy="12" r="3"></circle>
        <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
      </svg>
      <span data-intl="optionsPageTitle"></span>
    </h1>
  </header>

  <main>
    <section id="generalOptions">
      <h2 data-intl="sectionGeneral"></h2>

      <div id="generalOptionsWrapper"></div>
    </section>
  </main>

  <footer>
    <p>Transcribers of Reddit extension by <a href="https://lars.koelker.dev" target="_blank">lars.koelker.dev</a>.</p>
    <p>Reddit is a registered trademark of Reddit, Inc. This extension is not endorsed or affiliated with Reddit, Inc. in any way.</p>
  </footer>
</body>
</html>

目前还没有实际的选项(只有它们的包装器)。我们需要为选项页面编写脚本。首先,我们在 options.js 中定义变量以访问我们的包装器和默认设置。“冻结”我们的默认设置可以防止我们以后意外修改它们。

const defaultSettings = Object.freeze({
  border: false,
  background: false,
});
const generalSection = document.getElementById('generalOptionsWrapper');

接下来,我们需要加载保存的设置。我们可以为此使用(先前注册的)storage API。具体来说,我们需要定义是要在本地存储数据(chrome.storage.local)还是通过最终用户登录的所有设备同步设置(chrome.storage.sync)。对于此项目,我们选择本地存储。

获取值需要使用 get 方法。它接受两个参数

  1. 我们要加载的条目
  2. 包含值的回调函数

我们的条目可以是字符串(例如,如下面的 settings)或条目数组(如果要加载多个条目则很有用)。回调函数中的参数包含我们之前在 { settings: ... } 中定义的所有条目的对象。

chrome.storage.local.get('settings', ({ settings }) => {
  const options = settings ?? defaultSettings; // Fall back to default if settings are not defined
  if (!settings) {
    chrome.storage.local.set({
     settings: defaultSettings,
    });
 }

  // Create and display options
  const generalOptions = Object.keys(options).filter(x => !x.startsWith('advanced'));
  
  generalOptions.forEach(option => createOption(option, options, generalSection));
});

为了呈现选项,我们还需要创建一个 createOption() 函数。

function createOption(setting, settingsObject, wrapper) {
  const settingWrapper = document.createElement("div");
  settingWrapper.classList.add("setting-item");
  settingWrapper.innerHTML = `
  <div class="label-wrapper">
    <label for="${setting}" id="${setting}Desc">
      ${chrome.i18n.getMessage(`setting${setting}`)}
    </label>
  </div>

  <input type="checkbox" ${settingsObject[setting] ? 'checked' : ''} id="${setting}" />
  <label for="${setting}"
    tabindex="0"
    role="switch"
    aria-checked="${settingsObject[setting]}"
    aria-describedby="${setting}-desc"
    class="is-switch"
  ></label>
  `;

  const toggleSwitch = settingWrapper.querySelector("label.is-switch");
  const input = settingWrapper.querySelector("input");

  input.onchange = () => {
    toggleSwitch.setAttribute('aria-checked', input.checked);
    updateSetting(setting, input.checked);
  };

  toggleSwitch.onkeydown = e => {
    if(e.key === " " || e.key === "Enter") {
      e.preventDefault();
      toggleSwitch.click();
    }
  }

  wrapper.appendChild(settingWrapper);
}

在我们的开关(即单选按钮)的 onchange 事件侦听器中,我们调用函数 updateSetting。此方法会将单选按钮的更新值写入存储中。

为此,我们将使用 set 函数。它有两个参数:我们要覆盖的条目和一个(可选)回调函数(在我们的例子中未使用)。由于我们的 settings 条目不是布尔值或字符串,而是一个包含不同设置的对象,因此我们使用扩展运算符()仅覆盖 settings 对象中的实际键(设置)。

function updateSetting(key, value) {
  chrome.storage.local.get('settings', ({ settings }) => {
    chrome.storage.local.set({
      settings: {
        ...settings,
        [key]: value
      }
    })
  });
}

再次,我们需要通过将以下条目附加到 manifest.json 来“通知”扩展程序我们的选项页面

"options_ui": {
  "open_in_tab": true,
  "page": "options/options.html"
},

根据您的用例,您还可以通过将 open_in_tab 设置为 false 来强制选项对话框作为弹出窗口打开。

安装扩展程序以进行开发

现在我们已经成功设置了清单文件,并将弹出窗口和选项页面都添加到了扩展程序中,我们可以安装扩展程序以检查我们的页面是否正常工作。导航到 chrome://extensions 并启用“开发者模式”。将出现三个按钮。点击标记为“加载未打包”的按钮,然后选择扩展程序的 src 文件夹以加载它。

扩展程序现在应该已成功安装,并且我们的“Transcribers of Reddit”磁贴应该在页面上。

我们已经可以与扩展程序交互了。点击浏览器地址栏旁边的拼图块 (🧩) 图标,然后点击新添加的“Transcribers of Reddit”扩展程序。现在您应该会看到一个带有打开选项页面按钮的小弹出窗口。

不错吧?它在您的设备上可能看起来有点不同,因为我在这些屏幕截图中启用了深色模式。

如果启用“显示评论背景”和“显示评论边框”设置,然后重新加载页面,状态将保持不变,因为我们将其保存在浏览器的本地存储中。

添加内容脚本

好的,所以我们已经可以触发弹出窗口并与扩展程序设置进行交互,但扩展程序本身还没有做任何特别有用的事情。为了让它更有活力,我们将添加一个内容脚本。

js 目录中添加一个名为 comment.js 的文件,并确保在 manifest.json 文件中定义它

"content_scripts": [
  {
    "matches": [ "*://www.reddit.com/*" ],
    "js": [ "js/comment.js" ]
  }
],

content_scripts 由两部分组成

  • matches:此数组保存 URL,用于告诉浏览器我们希望内容脚本在何处运行。作为一个 Reddit 扩展程序,我们希望它在与 ://www.redit.com/* 匹配的任何页面上运行,其中星号是通配符,用于匹配顶级域名后的任何内容。
  • js:此数组包含实际的内容脚本。

内容脚本无法与其他(普通)JavaScript 交互。这意味着如果网站的脚本定义了变量或函数,我们就无法访问它。例如

// script_on_website.js
const username = 'Lars';

// content_script.js
console.log(username); // Error: username is not defined

现在让我们开始编写内容脚本。首先,我们在 comment.js 中添加一些常量。这些常量包含稍后将使用的正则表达式和选择器。CommentUtils 用于确定帖子是否包含“tor 评论”,或者是否存在评论包装器。

const messageTypes = Object.freeze({
  COMMENT_PAGE: 'comment_page',
  SUBREDDIT_PAGE: 'subreddit_page',
  MAIN_PAGE: 'main_page',
  OTHER_PAGE: 'other_page',
});

const Selectors = Object.freeze({
  commentWrapper: 'div[style*="--commentswrapper-gradient-color"] > div, div[style*="max-height: unset"] > div',
  torComment: 'div[data-tor-comment]',
  postContent: 'div[data-test-id="post-content"]'
});

const UrlRegex = Object.freeze({
  commentPage: /\/r\/.*\/comments\/.*/,
  subredditPage: /\/r\/.*\//
});

const CommentUtils = Object.freeze({
  isTorComment: (comment) => comment.querySelector('[data-test-id="comment"]') ? comment.querySelector('[data-test-id="comment"]').textContent.includes('m a human volunteer content transcriber for Reddit') : false,
  torCommentsExist: () => !!document.querySelector(Selectors.torComment),
  commentWrapperExists: () => !!document.querySelector('[data-reddit-comment-wrapper="true"]')
});

接下来,我们检查用户是否直接打开了评论页面(“帖子”),然后执行正则表达式检查并更新directPage变量。这种情况发生在用户直接打开 URL 时(例如,通过在地址栏中输入或点击另一个页面上的<a>元素,比如 Twitter)。

let directPage = false;
if (UrlRegex.commentPage.test(window.location.href)) {
  directPage = true;
  moveComments();
}

除了直接打开页面之外,用户通常会与 SPA 交互。为了捕获这种情况,我们可以使用runtime API,在我们的comment.js文件中添加一个消息监听器。

chrome.runtime.onMessage.addListener(msg => {
  if (msg.type === messageTypes.COMMENT_PAGE) {
    waitForComment(moveComments);
  }
});

现在我们只需要函数了。让我们创建一个moveComments()函数。它将特殊的“tor 评论”移动到评论部分的开头。它还会根据条件(如果设置中启用了边框)为评论应用背景颜色和边框。为此,我们调用storage API 并加载settings条目。

function moveComments() {
  if (CommentUtils.commentWrapperExists()) {
    return;
  }

  const wrapper = document.querySelector(Selectors.commentWrapper);
  let comments = wrapper.querySelectorAll(`${Selectors.commentWrapper} > div`);
  const postContent = document.querySelector(Selectors.postContent);

  wrapper.dataset.redditCommentWrapper = 'true';
  wrapper.style.flexDirection = 'column';
  wrapper.style.display = 'flex';

  if (directPage) {
    comments = document.querySelectorAll("[data-reddit-comment-wrapper='true'] > div");
  }

  chrome.storage.local.get('settings', ({ settings }) => { // HIGHLIGHT 18
    comments.forEach(comment => {
      if (CommentUtils.isTorComment(comment)) {
        comment.dataset.torComment = 'true';
        if (settings.background) {
          comment.style.backgroundColor = 'var(--newCommunityTheme-buttonAlpha05)';
        }
        if (settings.border) {
          comment.style.outline = '2px solid red';
        }
        comment.style.order = "-1";
        applyWaiAria(postContent, comment);
      }
    });
  })
}

applyWaiAria()函数在moveComments()函数内部调用——它添加aria-属性。另一个函数创建了一个唯一的标识符,用于aria-属性。

function applyWaiAria(postContent, comment) {
  const postMedia = postContent.querySelector('img[class*="ImageBox-image"], video');
  const commentId = uuidv4();

  if (!postMedia) {
    return;
  }

  comment.setAttribute('id', commentId);
  postMedia.setAttribute('aria-describedby', commentId);
}

function uuidv4() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}

以下函数等待评论加载,并在找到评论包装器时调用callback参数。

function waitForComment(callback) {
  const config = { childList: true, subtree: true };
  const observer = new MutationObserver(mutations => {
    for (const mutation of mutations) {
      if (document.querySelector(Selectors.commentWrapper)) {
        callback();
        observer.disconnect();
        clearTimeout(timeout);
        break;
      }
    }
  });

  observer.observe(document.documentElement, config);
  const timeout = startObservingTimeout(observer, 10);
}

function startObservingTimeout(observer, seconds) {
  return setTimeout(() => {
    observer.disconnect();
  }, 1000 * seconds);
}

添加服务工作线程

还记得我们在内容脚本中添加了消息监听器吗?此监听器目前没有接收消息。我们需要自己将其发送到内容脚本。为此,我们需要注册一个服务工作线程。

我们必须通过将以下代码附加到manifest.json中来注册我们的服务工作线程。

"background": {
  "service_worker": "sw.js"
}

不要忘记在src目录中创建sw.js文件(服务工作线程始终需要在扩展程序的根目录src中创建)。

现在,让我们为消息和页面类型创建一些常量。

const messageTypes = Object.freeze({
  COMMENT_PAGE: 'comment_page',
  SUBREDDIT_PAGE: 'subreddit_page',
  MAIN_PAGE: 'main_page',
  OTHER_PAGE: 'other_page',
});

const UrlRegex = Object.freeze({
  commentPage: /\/r\/.*\/comments\/.*/,
  subredditPage: /\/r\/.*\//
});

const Utils = Object.freeze({
  getPageType: (url) => {
    if (new URL(url).pathname === '/') {
      return messageTypes.MAIN_PAGE;
    } else if (UrlRegex.commentPage.test(url)) {
      return messageTypes.COMMENT_PAGE;
    } else if (UrlRegex.subredditPage.test(url)) {
      return messageTypes.SUBREDDIT_PAGE;
    }

    return messageTypes.OTHER_PAGE;
  }
});

我们可以添加服务工作线程的实际内容。我们使用历史状态上的事件监听器(onHistoryStateUpdated)来执行此操作,该监听器检测页面何时使用History API(通常用于 SPA 中在不刷新页面的情况下进行导航)进行了更新。在此监听器内部,我们查询活动选项卡并提取其tabId。然后,我们向内容脚本发送一条消息,其中包含页面类型和 URL。

chrome.webNavigation.onHistoryStateUpdated.addListener(async ({ url }) => {
  const [{ id: tabId }] = await chrome.tabs.query({ active: true, currentWindow: true });

  chrome.tabs.sendMessage(tabId, {
    type: Utils.getPageType(url),
    url
  });
});

全部完成!

我们完成了!导航到 Chrome 的扩展程序管理页面(chrome://extensions),然后点击未打包扩展程序上的重新加载图标。如果您打开包含带有图片转录的“Reddit 转录员”评论的 Reddit 帖子(例如这个),只要我们在扩展程序设置中启用了它,它就会被移动到评论部分的开头并突出显示。

“Reddit 转录员”扩展程序通过将特定评论移动到 Reddit 线程评论列表的顶部并为其添加亮红色边框来突出显示该评论。

结论

是不是和你想象的一样难?在我深入研究之前,它绝对比我想象的要简单得多。在设置manifest.json并创建任何我们需要页面文件和资产之后,我们实际上做的只是像往常一样编写 HTML、CSS 和 JavaScript。

如果您在途中遇到困难,Chrome API 文档是一个很好的资源,可以帮助您重回正轨。

再次强调,这是本文中我们介绍的所有代码的 GitHub 仓库。阅读它,使用它,并告诉我您对此有何看法!