构建一个复杂的浏览器扩展并不简单——特别是对于初次尝试或对自己的操作不太确定的人!不用担心。我们将通过Evil Martians案例书中的一个真实示例,向您展示构建功能完备的Chrome扩展的基本要素!我们还将分享一些其他有用的技巧和酷炫的建议,所以请继续阅读!
正如所提到的,我们将使用我们与一个真实客户的合作作为示例来演示Chrome扩展的创建。这些材料无论您的具体情况如何都将是相关的,本文中描述的方法和解决方案可以在许多情况下应用。此外,虽然本文是从前端工程师的角度编写的,但大多数人仍然可以跟上!
Playbook最近在Product Hunt上发布了这个扩展并收到了极好的评价和有价值的反馈。此外,插件Image Saver也在Chrome网上应用店获得了数百名新用户。那么,让我们开始吧。
一些背景信息
首先,一些背景:Playbook是一个用于视觉资产的创意云存储平台,使它们易于组织、浏览和分享。
我们从2021年开始与Playbook合作,帮助实现了很多事情:基于ML的搜索、AI生成工具、Figma插件等等。它被100万+的创意人士和公司所信任,已经成功地在Product Hunt上发布了多次,并且筹集了超过2200万美元的资金。
回到我们的主要话题,Playbook向我们提出了为他们的项目构建一个复杂的Chrome插件的想法。这将是一个浏览器扩展,允许设计师、艺术家和市场营销人员自动下载并上传任何网站上的所有图像直接到Playbook。
当使用像Midjourney、Canva或Figma这样的在线设计工具时,下载资产的无缝性和速度更高总是更好的体验,对吧?这意味着我们的特需要等于或理想情况下优于这些产品。
有了我们的目标,我们开始工作。
准备创建扩展
与Playbook合作,我们确定了初始迭代需要以下功能:
- Playbook账户登录功能
- 选择默认的Playbook组织和板块以保存资产的能力
- 一个上传功能,允许通过点击上下文菜单直接将图像上传到Playbook
- 一个功能,可以在扩展的弹出窗口中显示当前网页上所有图像的预览(以及选择它们并保存到Playbook的能力)
- 保存当前网页的URL和文本注释的能力
- 在Chrome和其他基于Chromium的浏览器(如Opera)中工作的能力
根据上述,这是扩展的第一个版本的样子:
现在,让我们分享我们如何逐步到达那里的详细步骤。
在Chrome扩展中添加OAuth授权
在需求明确之后,我们首先开始实现授权。由于扩展与主应用程序紧密连接,我们希望连接他们的用户会话,以便扩展可以通过从cookie中获取Playbook的JWT令牌来获取当前授权用户。这将使授权过程无缝且对用户不可见。
这是我们首次面对浏览器扩展特有的功能和限制的地方。
未登录状态(当cookie中没有JWT令牌时)。点击“登录Playbook”按钮会在新标签页中打开带有认证表单的他们的网站。
请记住一件重要的事情:没有任何扩展代码是私有的,它可以被观察到,这意味着没有办法安全地使用密钥!
尽管您不能在代码本身中放置任何敏感信息,但您可以安全地将访问令牌存储在chrome.local.storage
中(但这不是普通的网页本地存储;这是扩展本身的某种特殊存储!)
这是我们为Playbook Chrome扩展使用的完整OAuth流程:
- 在您的服务器上为您的扩展创建一个OAuth2应用程序。(我们使用了Doorkeeper)。我们需要使用应用程序中的
uid
作为client_id
来识别Chrome扩展,以便我们可以信任带有该client_id
的认证请求。 - 从扩展方面,我们需要检查我们是否已经在
chrome.storage.local
中存储了访问令牌,并验证访问令牌是否仍然有效。如果没有,我们将继续流程;如果有,我们可以使用它并跳过其余部分。 - 在开始授予授权代码的过程之前,我们需要在扩展中生成一个名为
code_verifier
的字符串和一个匹配的code_challenge
。为此,我们使用了NPM包pkce-challenge。 - Chrome扩展向服务器发出POST请求(在参数中带有
client_id
)以获取write_key
。 - 如果我们收到密钥,扩展尝试使用
chrome.cookies.get({ name: "jwt" })
方法从Playbook的cookie中获取当前的JWT令牌。扩展需要特定主机的特殊权限才能请求这些cookie,所以我们在manifest.json
中添加了"host_permissions": ["*://*.playbook.com/*"]
和"permissions": ["cookies"]
。 - 然后,扩展向服务器发出请求,该
url
包括client_id
、write_key
(作为状态参数)、redirect_uri
(您可以使用chrome.identity.getRedirectURL()
获取这个)和code_challenge
作为搜索参数。在Authorization
头中,我们包含了我们在前一步中获得的JWT令牌。 - 在服务器的响应中,我们收到
redirect_uri
。然后,使用此字符串的搜索参数,我们可以获取access_grant
,它作为code
参数存储;我们使用query-string库来解析URL字符串。 - 之后,扩展向服务器发送POST请求,带有
client_id
、access_grant
、redirect_uri
和code_verifier
,然后我们在响应中获得access_token
。 - 我们将
access_token
添加到chrome.storage.local
。 - 然后我们使用这个
access_token
在我们的所有请求中从服务器获取数据(在请求体或作为URL参数)。
我们在Chrome扩展的一个特殊位置调用所有这些:扩展的服务工作线程。这是一个在后台独立于网页运行的脚本。与弹出窗口(点击扩展图标后显示的窗口)不同,加载后,服务工作线程可以运行只要它们积极接收事件;但请注意,它们不能访问DOM。
您可以在官方文档的这一部分中阅读有关扩展服务工作线程的更多详细信息。
我们将监听主应用程序的cookie更新(其域为.playbook.com
)。每当用户在主应用程序中注销(或会话过期)时,JWT cookie就会从cookie中删除,这意味着我们也需要通过清除扩展的本地存储来从扩展中注销用户;这是我们存储访问令牌的地方(用户当前已登录的“证据”)。
此外,在另一方面,当添加JWT cookie时,我们需要调用OAuth流程。
在background.ts
文件中,添加下一个监听器:
chrome.cookies.onChanged.addListener(async (reason) => {
if (reason.cookie.domain === '.playbook.com' && reason.cookie.name === 'jwt') {
if (reason.removed && reason.cause !== 'overwrite') {
chrome.storage.local.clear();
} else {
signIn(); // 调用oAuth流程
}
}
});
现在,我们需要为背景脚本和弹出窗口添加一些设置。将服务工作线程和弹出窗口的路径(以及相关权限)添加到您的manifest.json
文件中:
"background": {
"service_worker": "src/background.ts"
},
"action": {
"default_popup": "src/Popup.html"
},
"permissions": [
"storage",
"identity",
"cookies"
]
请注意,您为默认弹出窗口指定的文件应该是HTML,否则它将无法工作。文件Popup.html
应该像这样:
<!DOCTYPE html>
<html lang="en">
<head>
…
</head>
<body>
<div id="root"></div>
<script type="module" src="Popup.tsx"></script>
</body>
</html>
然后,您只需要将弹出组件注入到根中;这个文件叫做Popup.tsx
:
const Popup = () => {
const [token, setToken] = useState(null); // 在组件的状态中存储访问令牌
useEffect(() => {
chrome.storage.local.get(['access_token'], (result) => {
if (result.access_token) {
setToken(result.access_token);
}
});
}, []); // 从扩展的本地存储中接收存储的访问令牌
chrome.storage.onChanged.addListener((changes, namespace) => {
if (namespace === 'local') {
if (Object.keys(changes).some((key) => key === 'access_token')) {
setToken(changes.access_token.newValue);
}
}
}); // 监听本地存储中令牌值的变化
return <div className={styles.container}>
{token ? <LoggedInView /> : <LoggedOutView />}
</div>;
};
const container = document.getElementById('root');
const root = createRoot(container!);
root.render(
<React.StrictMode>
<Popup />
</React.StrictMode>,
);
在这些添加之后,认证流程应该按预期工作,扩展将在弹出窗口中显示当前的认证状态。
创建内容脚本以保存所有图像
现在我们已经设置了弹出窗口并添加了认证,让我们创建一个内容脚本,可以在Chrome本地存储中存储当前网页的所有图像源。
当弹出窗口打开时,我们将执行此脚本,然后我们将在弹出窗口中显示存储的图像。
但首先,什么是内容脚本?它是在当前网页的上下文中运行的文件,可以通过检查document
变量来读取页面的详细信息;然后它可以将信息传递给其父扩展。
要添加内容脚本,我们首先需要在名为content_scripts
的文件夹中创建一个名为saveAllImagesToPreview.ts
的文件(将所有内容脚本存储在一个单独的文件夹中,以便项目结构更易于理解),并将相关权限添加到我们的manifest.json
中:
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"], // 指定内容脚本将被注入的页面。我们只需要在URL以HTTP或HTTPS开头的页面上运行脚本
"js": [
"src/content_scripts/saveAllImagesToPreview.ts",
] // 将被注入到匹配页面的文件列表
}
],
"permissions": [
…,
"activeTab",
"scripting",
]
当弹出组件最初呈现时,我们将在Popup.tsx
文件中执行此脚本:
useEffect(() => {
saveAllImagesOnActiveTab();
}, []);
const saveAllImagesOnActiveTab = () => {
chrome.tabs.query({ active: true, lastFocusedWindow: true }, (tabs) => {
chrome.scripting
.executeScript({
target: { tabId: tabs[0]?.id },
files: ['src/saveAllImagesToPreview.ts.js'],
})
});
};
在内容脚本文件中,我们将添加一个函数,该函数将在脚本注入后立即被调用saveAllImagesToPreview.ts
:
(() => {
let images = document.querySelectorAll('img'); // 从当前页面选择所有图像
let imageSources = Array.from(images)
.filter(
(img) =>
Img.src &&
img.naturalWidth > 128 &&
img.naturalHeight > 128
)
.map((img) => img.src); // 我们排除太小的图像并获取源
chrome.runtime.sendMessage({
action: 'saveAllImagesToPreview',
sources: imageSources,
});
})();
在我们函数的最后一部分,我们将向background.ts
发送一个消息,其中包含过滤后的源作为参数,我们还将传递这个操作的名称(saveAllImagesToPreview
),这样我们就能识别这个消息的含义。
同时,在background.ts
文件中,我们需要监听Chrome API的onMessage
事件;此事件在从扩展进程或内容脚本(就像我们的情况!)发送消息时触发。
chrome.runtime.onMessage.addListener((message) => {
if (message.action === 'saveAllImagesToPreview') {
createNewImagesPreview(message.sources);
}
});
最后,我们调用createNewImagesPreview
函数,它将新预览图像数组分配给扩展的本地存储。我们还为存储的图像生成唯一ID——这里我们使用nanoid,一个用于JavaScript的迷你大小的唯一字符串ID生成器:
const createNewImagesPreview = (sources: string[]) => {
chrome.storage.local.set({
images_preview: sources.map((source) => ({
id: nanoid(),
src: source
})),
});
};
现在,一旦图像存储在本地存储中,我们就可以在弹出组件中获取它们并在其中显示它们:
const [images, setImages] = useState([]); // 在组件的状态中存储图像
useEffect(() => {
chrome.storage.local.get(['images_preview'], (result) => {
if (result.images_preview) {
setImages(result.images_preview);
}
});
}, []); // 从扩展的本地存储中接收存储的图像
chrome.storage.onChanged.addListener((changes, namespace) => {
if (namespace === 'local') {
if (Object.keys(changes).some((key) => key === 'images_preview')) {
setImages(changes.images_preview.newValue);
}
}
}); // 监听本地存储中值的变化
return <div>
{images.map((image) => (
<img
key={image.id}
className={styles.image}
src={image.src}
alt={`预览源 ${image.src}`}
/>
))} // 显示存储的图像
<Button action={onSave} text="保存到Playbook" /> // 点击‘保存’时,我们只是将图像源数组发送到Playbook的后端
</div>;
让我们来看一下:
在背景脚本中创建扩展上下文菜单
除了能够在弹出窗口的预览中选择和保存图像之外,我们希望允许用户能够通过在Google Chrome的上下文菜单中选择选项,直接将网站上的某个图像保存到Playbook:
为此,我们将使用chrome.contextMenus
API向上下文菜单添加一个新选项。然后,点击这个选项后,我们将保存它到Playbook。
首先,我们必须在manifest.json
中添加相关权限。我们还需要指定一个16x16像素的图标,用于显示在我们的新选项旁边:
"icons": {
"16": "16x16.png",
...
},
"permissions": [
...,
"contextMenus"
]
接下来,我们将在onInstalled
事件的监听器内创建上下文菜单选项。这个事件在扩展首次安装、更新到新版本或Chrome更新到新版本时触发。
background.ts
:
import { createContextMenu } from './contextMenu';
chrome.runtime.onInstalled.addListener(() => {
createContextMenu();
});
这是在contextMenu.ts
里面的:
const createContextMenu = () => {
chrome.contextMenus.create({
id: 'Playbook Extension Context Menu',
title: 'Save image to Playbook',
contexts: ['image'], // 我们只想将上下文菜单添加应用到图像上——我们不需要其他类型的元素
});
};
之后,我们只需要在背景脚本中添加监听器;它跟踪点击上下文菜单选项并随后通过向后台发送请求来保存它。
请注意,在background.ts
下面的代码中,对象内的srcUrl
存在是因为我们将上下文设置为仅‘image’:
chrome.contextMenus.onClicked.addListener((info) => {
saveImageToPlaybook(info.srcUrl); // 将图像源保存到Playbook
});
现在我们的上下文菜单工作正常,并且可以成功地向保存所选图像发送请求:
为Chrome扩展添加错误处理和报告
拥有错误报告对于任何产品的快速响应用户问题或防止收入损失至关重要。与主Playbook应用程序一样,对于扩展的错误报告,我们使用Sentry.io。为了保持捆绑包的小体积,我们只使用了@sentry/browser包而没有追踪:这仍然允许我们接收所有可能的错误或关键崩溃所需的信息。
我们可以添加一个名为sentry.ts
的单独文件,其中包含一个将调用Sentry.init()的函数。我们还将通过从manifest.json
导入版本来设置当前发布,这将帮助我们了解错误发生在扩展的哪个版本中。
这是在sentry.ts
里面的:
import manifest from '../../manifest.json';
const initSentry = () => {
if (config.ENVIRONMENT === 'production') {
Sentry.init({
dsn: <你的 Sentry DSN>
release: manifest.version,
// 其他选项
});
}
};
在background.ts
中,我们将在onInstalled
事件的监听器内调用这个函数(我们已经用它来创建上下文菜单):
import { initSentry } from './sentry';
chrome.runtime.onInstalled.addListener(() => {
initSentry();
createContextMenu();
});
现在,每当发生错误时,我们通过使用Chrome API为扩展设置错误徽章来通知用户,将错误详情存储在本地存储中(我们可以显示一个弹出窗口,让用户更了解该错误),并发送消息到sentry:
const handlePluginError = (error: string) => {
chrome.action.setBadgeBackgroundColor({ color: '#FFA500' }); // 设置红色错误徽章
chrome.action.setBadgeText({ text: '!' }); // 在徽章内设置警告符号
chrome.action.setBadgeTextColor({ color: '#FFF' }); // 在徽章内设置白色文本
chrome.storage.local.set({
general_error: error,
}); // 将错误详情保存到LC
Sentry.captureMessage(error); // 向Sentry发送消息
};
让我们来看一下这个。
请注意,您可能只想在生产环境中向Sentry发送错误,因此,在初始化Sentry或发送新消息之前,检查配置文件中的环境变量是合适的。
准备在Chrome网上应用店发布您的扩展
现在,一旦扩展开发过程完成,我们终于可以在Chrome网上应用店发布它了!但首先,我们需要完成一些额外的准备工作:
- 查看Chrome网上应用店政策以确保您的扩展符合所有要求和指南
- 注册您的开发者账户并填写一些额外的信息(发布者名称、验证电子邮件等)
- 检查您的
manifest.json
,并指定name
、description
和version
字段。注意,您上传到商店的每个新版本必须比前一个版本有更大的版本号,所以最好从一个低值开始,如0.0.1 - 拉取最新的主Git分支,运行所有的linter和测试,然后构建您的生产包。确保您的扩展按预期工作,并尝试这个构建版本本地
- 压缩您的扩展文件,确保您将
manifest.json
放在根目录
最后,一切都准备好了!将扩展上传到Chrome网上应用店并提交审核。
注意,审核时间可能会有所不同,但在大多数情况下,需要几天。非常罕见的情况下,如果manifest.json
设置了广泛的权限(如主机权限https://*/*
),或者代码量过大,可能需要几周。
但是,如果一切顺利,审核过程结束后,您的扩展将被发布!祝贺!
总结
我们希望我们的旅程能激励您创建一个Chrome扩展(并帮助避免一些常见的困难)。当然,值得一提:如果您正在寻找理想的现代文件管理解决方案,为创意人士,我们完全推荐Playbook!
并提到我们一直在讨论的扩展,请查看Image Saver,因为它使用起来非常方便,可以保存那些灵感来源!