开发一个简单的 Chrome Extension使用 Plasmo 开发一个 Chrome Extension,用来生成
在此作一个记录,第一次开发 Chrome Extension 的同学也可以看看。
背景
我现在使用 GitHub Blogger 作为个人博客工具,在翻阅文章时,有个体验痛点:无法快速定位到某个章节。
比如,这篇文章涉及的二级、三级标题多达 25+,篇幅也很长。
目前 GitHub 只有仓库 Markdown 文件支持目录能力,但是 Issue 还不支持,所以开发一个 Chrome 扩展程序来解决。
- 支持 h1 ~ h6 标题。
- 支持滚动高亮。
- 支持粘性布局,滚动时始终在可视区域内。
- 样式风格与 GitHub 契合,支持深色模式。
开始之前
项目没必要一步一步去搭,选择社区流行方案即可,我选 Plasmo,开发体验还不错。目录样式参考了 Github、ByteMD。
可粗略看看,了解一些基础概念:
第一篇作者总结得挺好,但里面一些东西在 Manifest V3 发生了变化,可以看看第二篇文章。
基础概念
- manifest.json(清单文件)
- Actions(动作)
- Content Scripts(内容脚本)
- Extension Service Worker(原 V2 的 Background Scripts)
清单文件是每个扩展程序必须的,它会列出扩展程序的结构和行为等信息。
Actions 是点击扩展程序图标时发生的动作,可以是打开一个弹出式窗口、打开侧边栏面板、右键菜单等。
内容脚本主要用于修改网页内容。
Extension Service Worker 是运行在浏览器里的后台脚本。
它们之间可以相互传递消息,详见消息传递。
开发
创建项目:
$ pnpm create plasmo --with-src --entry=contents/inline,popup,background
似乎
--entry
指定入口目录有点问题,我只用到弹出式窗口、内容脚本以及 Service Worker,但生成的模板包括了newtab
,需手动删掉。
Content Scripts
前面提到,内容脚本是用来修改网页内容的,但它跟网页的 JavaScript 环境是隔离的。
分类
在 Plasmo 里,内容脚本分为两类:
- content.ts
- content.tsx
.ts
表示没有 UI 界面的纯脚本,后者则是带 UI 的组件,所以它要默认导出 Component。
此处
.tsx
是以 React 为例,其他框架则是.vue
、.svelte
扩展名。
由于 Plasmo 的 TypeScript 配置将所有文件视为模块,如果你的纯内容脚本没有任何导出,则必须要加上
export {}
。
如果有多个内容脚本,则用
contents
目录,比如contents/foo.tsx
、contents/bar.tsx
。
导出配置
主要用于定义脚本作用的网页地址、执行时机等。
// tos.tsx
import type { PlasmoCSConfig } from 'plasmo'
export const config: PlasmoCSConfig = {
matches: ['https://github.com/*'],
run_at: 'document_end'
}
其中配置项详见 Inject with static declarations。
如果你的脚本要修改网页的
window
对象,要指定world
配置项为'MAIN'
。
指定插入锚点
也就是我们的 UI 脚本要挂载到网页的哪个地方。
// tos.tsx
import type { PlasmoCSConfig, PlasmoGetInlineAnchor } from 'plasmo'
export const config: PlasmoCSConfig = {
matches: ['https://github.com/*'],
run_at: 'document_end'
}
// 🆕
export const getInlineAnchor: PlasmoGetInlineAnchor = async () => ({
element: document.querySelector('#partial-discussion-sidebar'),
insertPosition: 'afterend'
})
export default function Toc() {
return <div className="toc">Toc 组件</div>
}
如果需要挂载多个,导出
getInlineAnchorList
,详见 Inline Anchor。
引入样式文件
假设引入 toc.tsx
同级目录下的 toc.css
文件。
/* toc.css */
.toc {
color: #0969da;
}
// tos.tsx
import type { PlasmoCSConfig, PlasmoGetStyle } from 'plasmo'
import styleText from 'data-text:./toc.css'
// 🆕
export const config: PlasmoCSConfig = {
matches: ['https://github.com/*'],
css: ['./toc.css'],
run_at: 'document_end'
}
export const getStyle: PlasmoGetStyle = () => {
const style = document.createElement('style')
style.textContent = styleText
return style
}
export default function Toc() {
return <div className="toc">Toc 组件</div>
}
导出一个 getStyle
方法,读取文件的内容,然后往网页插入一个 <style>
标签。
对于
data-text:./toc.css
的写法,详见 Import Resolution。
自定义 Root Container
默认情况下,Plasmo 会创建 Shadow DOM,再挂载到页面,这样做的好处是与外部隔离,如上图所示。
有时,我们希望可以用原网页的样式,比如 CSS 变量等。
这样的话,需要导出一个 getRootContainer
方法。
// tos.tsx
import type { PlasmoCSConfig, PlasmoGetStyle, PlasmoGetInlineAnchor } from 'plasmo'
import styleText from 'data-text:./toc.css'
export const config: PlasmoCSConfig = {
matches: ['https://github.com/*'],
css: ['./toc.css'],
run_at: 'document_end'
}
export const getStyle: PlasmoGetStyle = () => {
const style = document.createElement('style')
style.textContent = styleText
return style
}
// 🆕 移除掉
// export const getInlineAnchor: PlasmoGetInlineAnchor = async () => ({
// element: document.querySelector('#partial-discussion-sidebar'),
// insertPosition: 'afterend'
// })
// 🆕
export const getRootContainer = () => {
return new Promise(resolve => {
const timer = setInterval(() => {
const rootContainer = document.querySelector('#plasmo-toc')
if (rootContainer) {
clearInterval(timer)
resolve(rootContainer)
return
}
const rootContainerParent = document.querySelector('.Layout-sidebar')
if (rootContainerParent) {
clearInterval(timer)
const rootContainer = document.createElement('div')
rootContainer.id = 'plasmo-toc'
rootContainerParent.appendChild(rootContainer)
resolve(rootContainer)
}
}, 200)
})
}
export default function Toc() {
return <div className="toc">Toc 组件</div>
}
这里用到
setInterval()
是为了确保挂载点的父级已加载完毕。
以上示例,我在 .Layout-sidebar
下添加了 #plasmo-toc
元素,并将 Toc 组件挂载到上面,以实现上述 getInlineAnchor
的 insertPosition: 'afterend'
的效果。原因是 ReactDOM 的 createRoot()
会覆盖挂载元素的内容,它会吞掉 .Layout-sidebar
的所有内容,这不是我想要的。
自定义 render
前面 run_at
指定为 document_end
,还有其他值:
-
document_start
:在 css 中的任何文件之后、构建任何其他 DOM 或运行任何其他脚本之前注入脚本。 -
document_end
:在 DOM 完成之后,在图片和框架等子资源加载之前立即注入脚本。 -
document_idle
:浏览器会选择一个时间,在document_end
之间以及window.onload
事件触发后立即注入脚本。注入的确切时刻取决于文档的复杂程度和加载用时,并针对网页加载速度进行了优化。在document_idle
运行的内容脚本不需要监听window.onload
事件;它们一定会在 DOM 完成后运行。如果脚本确实需要在window.onload
之后运行,该扩展程序可以使用document.readyState
属性检查onload
是否已触发。这是默认值。
以 TOC 组件为例,当进入页面后,页面加载完毕,然后生成了目录。如果后续 Markdown 内容通过 Ajax 方式更新了,那目录有可能跟最新内容对不上了。这里有两种解决方法:
- 一是,在 TOC 组件内监听内容变化,进而触发组件更新。
- 二是,当内容更新时,先卸载旧的 TOC 组件,再挂载新的 TOC 组件。
按需选择。在 GitHub Issue TOC 的场景,要用第二种方式。原因是,在 GitHub 的非 Issue 页面跳转到 Issue 页面时,应该是使用了 history.pushState()
方式,它不会重新加载页面,导致目录就不会生成了。这种情况靠 document_end
是无法解决的。
// tos.tsx
import type { PlasmoCSConfig, PlasmoGetStyle, PlasmoCSUIJSXContainer, PlasmoRender } from 'plasmo'
import styleText from 'data-text:./toc.css'
export const config: PlasmoCSConfig = {
matches: ['https://github.com/*'],
css: ['./toc.css'],
run_at: 'document_end'
}
export const getStyle: PlasmoGetStyle = () => {
const style = document.createElement('style')
style.textContent = styleText
return style
}
export const getRootContainer = () => {
return new Promise(resolve => {
const timer = setInterval(() => {
const rootContainer = document.querySelector('#plasmo-toc')
if (rootContainer) {
clearInterval(timer)
resolve(rootContainer)
return
}
const rootContainerParent = document.querySelector('.Layout-sidebar')
if (rootContainerParent) {
clearInterval(timer)
const rootContainer = document.createElement('div')
rootContainer.id = 'plasmo-toc'
rootContainerParent.appendChild(rootContainer)
resolve(rootContainer)
}
}, 200)
})
}
// 🆕
export const render: PlasmoRender<PlasmoCSUIJSXContainer> = async ({ createRootContainer }) => {
const url = document.location.href
if (!isGitHubIssuePage(url)) return
const rootContainer = await createRootContainer()
const root = createRoot(rootContainer)
window.__plasmoTocRoot = root
root.render(<Toc />)
}
export default function Toc() {
return <div className="toc">Toc 组件</div>
}
具体逻辑,按需调整。由于重新挂载页面时,要先将旧的 React App 卸载,所以这里记录了window.__plasmoTocRoot = root
供下次挂载用。比如:
async function recreateRoot() {
const rootContainer = await getRootContainer()
if (window.__plasmoTocRoot) {
window.__plasmoTocRoot.unmount()
}
const root = createRoot(rootContainer as Element)
window.__plasmoTocRoot = root
root.render(<Toc />)
onIssueUpdate()
}
Service Worker
这是可选的,如果你用不到 Service Worker 可以在删掉 background.ts
文件,或者导出一个 export {}
,原因同 Content Scripts。
前面提到,从 GitHub Repo 跳转到 GitHub Issue 页面的场景,无法生成目录。所以这里我要借助 Service Worker 来解决这个问题。大致思路是,通过 chrome.webNavigation
来监听网页 History 变化,当跳转到 Issue 页面时,向 Content Scripts 传递消息,告诉它该挂载 TOC 组件了。
事件顺序:onBeforeNavigate → onCommitted → [onDOMContentLoaded] → onCompleted
// background.ts
import { MESSAGE_TYPE } from '@/constants'
import { isGitHubIssuePage } from '@/utils'
chrome.webNavigation.onCompleted.addListener(() => {
chrome.webNavigation.onHistoryStateUpdated.addListener(details => {
const { url, tabId } = details
if (!isGitHubIssuePage(url)) return
sendMessageToContentScript(tabId, { type: MESSAGE_TYPE.PLASMO_TOC_MOUNT, payload: details })
})
})
async function sendMessageToContentScript(tabId: number, message: any) {
try {
chrome.tabs.sendMessage(tabId, message)
} catch (error) {
console.error(`Failed to send message: ${error}`)
}
}
在 Service Worker 内无法获取、操作 DOM。
由于 Service Worker 用到 webNavigation
,需要在清单中声明权限。在 Plasmo 框架是在 package.json
里指定即可,构建时会自动生成到 manifest.json
的。
{
"manifest": {
"permissions": [
"webNavigation"
]
}
}
同时,要在 Content Script 里接收信息:
// toc.tsx
import { MESSAGE_TYPE } from '@/constants'
chrome.runtime.onMessage.addListener(throttle(onBackgroundMessage, 500))
let plasmoTocMounting = false
function onBackgroundMessage(message: { type: string; payload: any }) {
try {
if (message.type !== MESSAGE_TYPE.PLASMO_TOC_MOUNT) return
if (plasmoTocMounting) return
plasmoTocMounting = true
const rootContainer = document.querySelector('#plasmo-toc')
if (rootContainer) return
recreateRoot()
} finally {
plasmoTocMounting = false
}
}
Popup
我这个扩展,其实 Popup 窗口,其实没什么内容,就放了一个 Homepage 和 Report issue 的链接。
// popup/index.tsx
import './index.css'
export default function Popup() {
return (
<div className="popup">
<div className="greeting"> Enjoy it. ❤️</div>
<div className="links">
<a className="link" href="https://github.com/toFrankie/github-issue-toc" target="_blank">
Homepage
</a>
<span className="separator">•</span>
<a
className="link"
href="https://github.com/toFrankie/github-issue-toc/issues"
target="_blank">
Report issue
</a>
</div>
</div>
)
}
提一下,导入样式不用像 Content Script 那样用 data-text:./index.css
来导入,也不用导出 getStyle
方法。
调试
使用 pnpm create plasmo
创建的项目,包含了:
{
"scripts": {
"dev": "plasmo dev",
"build": "plasmo build",
"package": "plasmo package"
}
}
就字面意思,不多说了。
pn dev
产出build/chrome-mv3-dev
pn build
产出build/chrome-mv3-prod
pn package
产出build/chrome-mv3-prod.zip
注意,dev 和 build 的产物是两个不同的扩展,前者在扩展名称加了
DEV |
前缀。
导入本地扩展程序
- 浏览器打开 chrome://extensions
- 开启「开发者模式」
- 在「加载已解压的扩展程序」选择对应的产物目录,并启用该扩展。
调试 Content Scripts
在开发时,源代码变更会自动更新扩展,甚至会重新加载页面。有时可能会看到以下错误信息:
Error: Extension context invalidated.
或页面上出现 Plamso 提示:
Context Invalidated, Press to Reload
原因是 Plasmo 重新加载扩展,会使得旧的扩展上下文失效,故而报错,解决方法是刷新页面。
在 DevTool 的 Source - Content Scripts 面板,可以查看所有扩展程序的脚本。
调试 Service Worker
在 chrome://extensions 对应扩展里,可以看到 「检查视图 Service Worker」或「检查视图 背景页」的入口,点击进入可调试。
调试 Popup
开发时,可以将扩展固定在 Chrome 工具栏,以便于调试。点击扩展图标,打开 Popup 弹窗,然后像网页那样右键检查元素即可。
提交扩展
准备好以下东西,执行 pn package
打包,前往开发者信息中心上传,填好相关信息提交审核即可。
基础信息
使用 Plasmo 的话,package.json
部分字段会在构建时传递到 manifest.json
里,按实际情况填写就好。
packageJson.version
→manifest.version
packageJson.displayName
→manifest.name
packageJson.description
→manifest.description
packageJson.author
→manifest.author
packageJson.homepage
→manifest.homepage_url
其余从 packageJson.manifest
读取。
图标
建议格式为 .png
,不支持 .webp
和 .svg
。
提供 16×16、32×32、48×48、128×128 四种尺寸的图片,以 icon<size>.png
形式命名,置于 assets
目录内。
请注意,Plasmo 项目的
assets
目录位于项目根目录,不是src
目录内,即便是通过--with-src
方式创建的模板项目。
图标视觉设计,参考官方指南。
Chrome Web Store 图片资源
- 商店图标:128×128 的图标
- 屏幕截图:1280×800 或 640×400,1 ~ 5 张
- 小型宣传图:440×280 画布(可选)
- 顶部宣传图:1400×560 画布(可选)
隐私相关说明
一是,要准备扩展程序的用途说明。
二是,要准备请求权限的理由,比如我用到了 host_permissions
和 webNavigation
权限,都要在上传时说明清楚。
所以,没用到的权限就不要加上去了。
转载自:https://juejin.cn/post/7380694342744899622