您可能正在使用浏览器扩展程序。一些扩展程序非常流行且实用,例如广告拦截器、密码管理器和 PDF 查看器。这些扩展程序(或“附加组件”)的功能并非仅限于此——您可以用它们做更多的事情!本文将向您介绍如何创建一个扩展程序。最终,我们将使其在多个浏览器中运行。
我们将制作一个名为“Reddit 转录器”的扩展程序,它将通过将特定评论移动到评论部分的顶部并为屏幕阅读器添加 aria- 属性来提高 Reddit 的可访问性。我们还将通过添加选项来为评论添加边框和背景以获得更好的文本对比度,从而使我们的扩展程序更进一步。
整个想法是让您很好地了解如何开发浏览器扩展程序。我们将首先为基于 Chromium 的浏览器(例如 Google Chrome、Microsoft Edge、Brave 等)创建扩展程序。在未来的文章中,我们将移植该扩展程序以使其与 Firefox 兼容,以及最近在其 MacOS 和 iOS 版本的浏览器中都添加了对 Web Extensions 支持的 Safari。
GitHub 代码库准备好了吗?让我们一步一步来。
首先,我们需要一个项目的工作空间。我们真正需要的只是创建一个文件夹并为其命名(我将其命名为 transcribers-of-reddit)。然后,在其中创建一个名为 src 的文件夹用于我们的源代码。
入口点是一个包含有关扩展程序的常规信息(即扩展程序名称、描述等)并定义要执行的权限或脚本的文件。
我们的入口点可以是位于我们刚刚创建的 src 文件夹中的 manifest.json 文件。在其中,让我们添加以下三个属性:
{
"manifest_version": 3,
"name": "Reddit 转录器",
"version": "1.0"
}
manifest_version 类似于 npm 或 Node 中的版本。它定义了哪些 API 可用(或不可用)。我们将使用最新的版本 3(也称为 mv3)进行前沿工作。
第二个属性是 name,它指定我们的扩展程序名称。此名称是我们的扩展程序出现在所有地方时显示的名称,例如 Chrome 网上应用店和 Chrome 浏览器中的 chrome://extensions 页面。
然后是 version。它使用版本号标记扩展程序。请记住,此属性(与 manifest_version 相反)是一个字符串,只能包含数字和点(例如 1.3.5)。
实际上,我们可以添加更多内容来帮助添加扩展程序的上下文。例如,我们可以提供一个描述来解释扩展程序的功能。最好提供这些信息,因为它可以让用户更好地了解他们在使用扩展程序时会遇到什么。
在本例中,我们不仅添加了描述,还提供了 Chrome 网上应用店在其扩展程序页面上指向的图标和网址。
{
"description": "使 Reddit 对残疾用户更易于访问。",
"icons": {
"16": "images/logo/16.png",
"48": "images/logo/48.png",
"128": "images/logo/128.png"
},
"homepage_url": "https://lars.koelker.dev/extensions/tor/"
}
扩展程序的一大优势是它们的 API 允许您直接与浏览器交互。但是我们必须明确地授予扩展程序这些权限,这也包含在 manifest.json 文件中。
{
"manifest_version": 3,
"name": "Reddit 转录器",
"version": "1.0",
"description": "使 Reddit 对残疾用户更易于访问。",
"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"
]
}
我们刚刚授予此扩展程序什么权限?首先是 storage。我们希望此扩展程序能够保存用户的设置,因此我们需要访问浏览器的 Web 存储来保存它们。例如,如果用户希望评论显示红色边框,那么我们将保存该设置以备下次使用,而不是让他们再次设置。
我们还授予扩展程序查看用户如何导航到当前屏幕的权限。Reddit 是一个单页应用程序 (SPA),这意味着它不会触发页面刷新。我们需要“捕获”此交互,因为只有在我们单击帖子时,Reddit 才会加载帖子的评论。因此,这就是我们利用 webNavigation 的原因。
我们稍后将介绍在页面上执行代码,因为它需要在 manifest.json 中添加一个全新的条目。
/解释 根据允许的权限,浏览器可能会向用户显示警告以接受权限。但是,只有某些权限是这样,Chrome 对这些权限有很好的概述。
浏览器扩展程序具有内置的国际化 (i18n) API。它允许您管理多种语言的翻译(完整列表)。要使用 API,我们必须在 manifest.json 文件中定义我们的翻译和默认语言:
"default_locale": "en"
这将英语设置为语言。如果浏览器的设置为任何不受支持的其他语言,则扩展程序将回退到默认语言环境(在此示例中为 en)。
我们的翻译在 _locales 目录中定义。让我们在其中为每种要支持的语言创建一个文件夹。每个子目录都有自己的 messages.json 文件。
src
└─ _locales
└─ en
└─ messages.json
└─ fr
└─ messages.json
翻译文件包含多个部分:
这是一个将所有这些整合在一起的示例:
{
"userGreeting": { // 翻译键 (“id”)
"message": "Good $daytime$, $user$!" // 翻译
"description": "用户问候", // 翻译人员的可选描述
"placeholders": { // 可选占位符
"daytime": { // 如消息中所引用
"content": "$1",
"example": "morning" // 我们内容的示例值
},
"user": {
"content": "$1",
"example": "Lars"
}
}
}
}
使用占位符有点困难。首先,我们需要在消息中定义占位符。占位符需要用 $ 字符括起来。之后,我们必须将占位符添加到“占位符列表”。这有点不直观,但 Chrome 希望知道应该为我们的占位符插入什么值。我们(显然)希望在这里使用动态值,因此我们使用特殊的 content 值 $1 来引用我们插入的值。
example 属性是可选的。它可用于向翻译人员提示占位符可能是什么值(但实际上并未显示)。
我们需要为我们的扩展程序定义以下翻译。将它们复制并粘贴到 messages.json 文件中。随意添加更多语言(例如,如果您说德语,请在 _locales 中添加一个 de 文件夹,依此类推)。
{
"name": {
"message": "Reddit 转录器"
},
"description": {
"message": "子reddits 的辅助图像描述。"
},
"popupManageSettings": {
"message": "管理设置"
},
"optionsPageTitle": {
"message": "设置"
},
"sectionGeneral": {
"message": "常规设置"
},
"settingBorder": {
"message": "显示评论边框"
},
"settingBackground": {
"message": "显示评论背景"
}
}
您可能想知道为什么我们在没有 i18n 权限的情况下注册了权限,对吧?Chrome 在这方面有点奇怪,因为您不需要注册每个权限。有些(例如 chrome.i18n)不需要在 manifest 中注册。其他权限需要一个条目,但在安装扩展程序时不会显示给用户。其他一些权限是“混合”的(例如 chrome.runtime),这意味着它们的一些功能可以在不声明权限的情况下使用——但同一 API 的其他功能需要在 manifest 中注册一个条目。您需要查看文档以全面了解这些差异。
最终用户首先看到的是 Chrome 网上应用店中的条目或扩展程序概述页面。我们需要调整 manifest 文件以确保所有内容都已翻译。
{
// 更新这些条目
"name": "__MSG_name__",
"description": "__MSG_description__"
}
应用此语法将使用 messages.json 文件中的相应翻译(例如,_MSGname 使用 name 翻译)。
在 HTML 文件中应用翻译需要一些 JavaScript。
chrome.i18n.getMessage('name');
该代码返回我们定义的翻译(即 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 属性来设置其内容。文档语言也将根据用户语言设置。
管理设置
在我们深入实际编程之前,我们需要创建两个页面:
以下是我们需要创建页面所需的文件夹和文件的概述:
src
├─ css
| └─ paintBucket.css
├─ popup
| ├─ popup.html
| ├─ popup.css
| └─ popup.js
└─ options
├─ options.html
├─ options.css
└─ options.js
.css 文件包含纯 CSS,仅此而已。我不会详细介绍,因为我知道你们大多数读者已经完全了解 CSS 的工作原理。您可以从该项目的 GitHub 代码库中复制和粘贴样式。
请注意,弹出窗口不是选项卡,其大小取决于其中的内容。如果您想使用固定大小的弹出窗口,可以在 html 元素上设置 width 和 height 属性。
这是一个 HTML 骨架,它链接 CSS 和 JavaScript 文件,并在
中添加标题和按钮。```
h1 包含扩展程序名称和版本;按钮用于打开选项页面。标题将不会填充翻译(因为它缺少 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(); });
此脚本首先查找 manifest 文件。Chrome 提供了包含 getManifest 方法的 runtime API(此特定方法不需要 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 文件。以下是一些我们可以使用的标记:
由 lars.koelker.dev 提供的 Reddit 转录器扩展程序。
Reddit 是 Reddit, Inc. 的注册商标。此扩展程序未以任何方式获得 Reddit, Inc. 的认可或关联。
目前还没有实际的选项(只有它们的包装器)。我们需要编写选项页面的脚本。首先,我们在 options.js 中定义变量以访问我们的包装器和默认设置。“冻结”我们的默认设置可以防止我们以后意外修改它们。
const defaultSettings = Object.freeze({ border: false, background: false, }); const generalSection = document.getElementById('generalOptionsWrapper');
接下来,我们需要加载保存的设置。我们可以为此使用(先前注册的)存储 API。具体来说,我们需要定义是要在本地存储数据 (chrome.storage.local) 还是通过登录的所有设备同步设置 (chrome.storage.sync)。让我们在这个项目中使用本地存储。
需要使用 get 方法检索值。它接受两个参数:
1. 我们要加载的条目
2. 包含值的回调
我们的条目可以是字符串(例如,下面的 settings)或条目数组(如果我们想要加载多个条目,则很有用)。回调函数中的参数包含我们先前在 { settings: ... } 中定义的所有条目的对象:
chrome.storage.local.get('settings', ({ settings }) => { const options = settings ?? defaultSettings; // 如果未定义设置,则回退到默认设置 if (!settings) { chrome.storage.local.set({ settings: defaultSettings, }); }
// 创建和显示选项 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 =
`;
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 对象中的实际键 (setting)。
function updateSetting(key, value) { chrome.storage.local.get('settings', ({ settings }) => { chrome.storage.local.set({ settings: { ...settings,
}
})
}); }
同样,我们需要通过将以下条目附加到 manifest.json 来“通知”扩展程序我们的选项页面:
"options_ui": { "open_in_tab": true, "page": "options/options.html" },
根据您的用例,您还可以通过将 open_in_tab 设置为 false 来强制选项对话框作为弹出窗口打开。
### 安装开发扩展程序
现在我们已经成功设置了 manifest 文件并将弹出窗口和选项页面都添加到了组合中,我们可以安装扩展程序来检查我们的页面是否正常工作。导航到 chrome://extensions 并启用“开发者模式”。将出现三个按钮。单击标记为“加载解压”的按钮,然后选择扩展程序的 src 文件夹以加载它。
扩展程序现在应该已成功安装,并且我们的“Reddit 转录器”图块应该在页面上。
我们现在已经可以与扩展程序交互了。单击地址栏旁边的拼图块 (?) 图标,然后单击新添加的“Reddit 转录器”扩展程序。您现在应该会看到一个小的弹出窗口,其中包含一个按钮,用于打开选项页面。
不错吧?在我的设备上它可能看起来有点不同,因为我在这些屏幕截图中启用了深色模式。
如果您启用“显示评论背景”和“显示评论边框”设置,然后重新加载页面,则状态将保留,因为我们将其保存在浏览器的本地存储中。
### 添加内容脚本
好的,我们现在已经可以触发弹出窗口并与扩展程序设置交互,但是扩展程序本身还没有做任何特别有用的事情。为了让它发挥作用,我们将添加一个内容脚本。
在 js 目录中添加一个名为 comment.js 的文件,并确保在 manifest.json 文件中定义它:
"content_scripts": [ { "matches": [ "://www.reddit.com/" ], "js": [ "js/comment.js" ] } ],
content_scripts 由两部分组成:
- matches:此数组保存 URL,这些 URL 告诉浏览器我们希望内容脚本在何处运行。作为 Reddit 的扩展程序,我们希望它在与 ://www.redit.com/* 匹配的任何页面上运行,其中星号是通配符,用于匹配顶级域之后的任何内容。
- js:此数组包含实际的内容脚本。
内容脚本无法与其他(普通)JavaScript 交互。这意味着如果网站的脚本定义了变量或函数,我们就无法访问它。例如:
// script_on_website.js const username = 'Lars';
// content_script.js console.log(username); // 错误:username 未定义
现在让我们开始编写内容脚本。首先,我们在 comment.js 中添加一些常量。这些常量包含稍后将使用的 RegEx 表达式和选择器。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-]' });
const UrlRegex = Object.freeze({ commentPage: /\/r\/.\/comments\/./, subredditPage: /\/r\/.*\// });
const CommentUtils = Object.freeze({ isTorComment: (comment) => comment.querySelector('[data-test-]') ? comment.querySelector('[data-test-]').textContent.includes('m a human volunteer content transcriber for Reddit') : false, torCommentsExist: () => !!document.querySelector(Selectors.torComment), commentWrapperExists: () => !!document.querySelector('[data-reddit-comment-wrapper="true"]') });
接下来,我们检查用户是否直接打开评论页面(“帖子”),然后执行 RegEx 检查并更新 directPage 变量。当用户直接打开 URL(例如,通过将其键入地址栏或单击另一个页面上的 元素(例如 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 评论”移动到评论部分的开头。它还会根据设置中是否启用了边框,有条件地将背景颜色和边框应用于评论。为此,我们调用存储 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); }); }
以下函数等待评论加载,如果找到评论包装器,则调用回调参数。
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 帖子(例如此帖子),只要我们在扩展程序设置中启用了它,它就会被移动到评论部分的开头并突出显示。
### 结论
这是否像您想象的那么难?在我深入研究之前,它肯定比我想象的要简单得多。在设置 manifest.json 并创建任何我们需要的文件和资产后,我们真正做的只是像往常一样编写 HTML、CSS 和 JavaScript。
如果您在途中遇到任何问题,Chrome API 文档是一个很好的资源,可以帮助您重回正轨。
再次声明,这是包含我们在本文中介绍的所有代码的 GitHub 代码库。阅读它,使用它,并让我知道您的想法!
تنصل: جميع الموارد المقدمة هي جزئيًا من الإنترنت. إذا كان هناك أي انتهاك لحقوق الطبع والنشر الخاصة بك أو الحقوق والمصالح الأخرى، فيرجى توضيح الأسباب التفصيلية وتقديم دليل على حقوق الطبع والنشر أو الحقوق والمصالح ثم إرسالها إلى البريد الإلكتروني: [email protected]. سوف نتعامل مع الأمر لك في أقرب وقت ممكن.
Copyright© 2022 湘ICP备2022001581号-3