We read every piece of feedback, and take your input very seriously.
To see all available qualifiers, see our documentation.
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
在此作一个记录,第一次开发 Chrome Extension 的同学也可以看看。
我现在使用 GitHub Blogger 作为个人博客工具,在翻阅文章时,有个体验痛点:无法快速定位到某个章节。
比如,这篇文章涉及的二级、三级标题多达 25+,篇幅也很长。
目前 GitHub 只有仓库 Markdown 文件支持目录能力,但是 Issue 还不支持,所以开发一个 Chrome 扩展程序来解决。
github-issue-toc
项目没必要一步一步去搭,选择社区流行方案即可,我选 Plasmo,开发体验还不错。目录样式参考了 Github、ByteMD。
可粗略看看,了解一些基础概念:
第一篇作者总结得挺好,但里面一些东西在 Manifest V3 发生了变化,可以看看第二篇文章。
清单文件是每个扩展程序必须的,它会列出扩展程序的结构和行为等信息。
Actions 是点击扩展程序图标时发生的动作,可以是打开一个弹出式窗口、打开侧边栏面板、右键菜单等。
内容脚本主要用于修改网页内容。
Extension Service Worker 是运行在浏览器里的后台脚本。
它们之间可以相互传递消息,详见消息传递。
创建项目:
$ pnpm create plasmo --with-src --entry=contents/inline,popup,background
似乎 --entry 指定入口目录有点问题,我只用到弹出式窗口、内容脚本以及 Service Worker,但生成的模板包括了 newtab,需手动删掉。
--entry
newtab
前面提到,内容脚本是用来修改网页内容的,但它跟网页的 JavaScript 环境是隔离的。
在 Plasmo 里,内容脚本分为两类:
.ts 表示没有 UI 界面的纯脚本,后者则是带 UI 的组件,所以它要默认导出 Component。
.ts
此处 .tsx 是以 React 为例,其他框架则是 .vue、.svelte 扩展名。
.tsx
.vue
.svelte
由于 Plasmo 的 TypeScript 配置将所有文件视为模块,如果你的纯内容脚本没有任何导出,则必须要加上 export {}。
export {}
如果有多个内容脚本,则用 contents 目录,比如 contents/foo.tsx、contents/bar.tsx。
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'。
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。
getInlineAnchorList
假设引入 toc.tsx 同级目录下的 toc.css 文件。
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> 标签。
getStyle
<style>
对于 data-text:./toc.css 的写法,详见 Import Resolution。
data-text:./toc.css
默认情况下,Plasmo 会创建 Shadow DOM,再挂载到页面,这样做的好处是与外部隔离,如上图所示。
有时,我们希望可以用原网页的样式,比如 CSS 变量等。
这样的话,需要导出一个 getRootContainer 方法。
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() 是为了确保挂载点的父级已加载完毕。
setInterval()
以上示例,我在 .Layout-sidebar 下添加了 #plasmo-toc 元素,并将 Toc 组件挂载到上面,以实现上述 getInlineAnchor 的 insertPosition: 'afterend' 的效果。原因是 ReactDOM 的 createRoot() 会覆盖挂载元素的内容,它会吞掉 .Layout-sidebar 的所有内容,这不是我想要的。
.Layout-sidebar
#plasmo-toc
getInlineAnchor
insertPosition: 'afterend'
createRoot()
前面 run_at 指定为 document_end,还有其他值:
run_at
document_end
document_start:在 css 中的任何文件之后、构建任何其他 DOM 或运行任何其他脚本之前注入脚本。
document_start
document_end:在 DOM 完成之后,在图片和框架等子资源加载之前立即注入脚本。
document_idle:浏览器会选择一个时间,在 document_end 之间以及 window.onload 事件触发后立即注入脚本。注入的确切时刻取决于文档的复杂程度和加载用时,并针对网页加载速度进行了优化。在 document_idle 运行的内容脚本不需要监听 window.onload 事件;它们一定会在 DOM 完成后运行。如果脚本确实需要在 window.onload 之后运行,该扩展程序可以使用 document.readyState 属性检查 onload 是否已触发。这是默认值。
document_idle
window.onload
document.readyState
onload
以 TOC 组件为例,当进入页面后,页面加载完毕,然后生成了目录。如果后续 Markdown 内容通过 Ajax 方式更新了,那目录有可能跟最新内容对不上了。这里有两种解决方法:
按需选择。在 GitHub Issue TOC 的场景,要用第二种方式。原因是,在 GitHub 的非 Issue 页面跳转到 Issue 页面时,应该是使用了 history.pushState() 方式,它不会重新加载页面,导致目录就不会生成了。这种情况靠 document_end 是无法解决的。
history.pushState()
// 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 供下次挂载用。比如:
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 可以在删掉 background.ts 文件,或者导出一个 export {},原因同 Content Scripts。
background.ts
前面提到,从 GitHub Repo 跳转到 GitHub Issue 页面的场景,无法生成目录。所以这里我要借助 Service Worker 来解决这个问题。大致思路是,通过 chrome.webNavigation 来监听网页 History 变化,当跳转到 Issue 页面时,向 Content Scripts 传递消息,告诉它该挂载 TOC 组件了。
chrome.webNavigation
事件顺序: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 的。
webNavigation
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 窗口,其实没什么内容,就放了一个 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 方法。
data-text:./index.css
使用 pnpm create plasmo 创建的项目,包含了:
pnpm create plasmo
{ "scripts": { "dev": "plasmo dev", "build": "plasmo build", "package": "plasmo package" } }
就字面意思,不多说了。
pnpm dev
build/chrome-mv3-dev
pnpm build
build/chrome-mv3-prod
pnpm package
build/chrome-mv3-prod.zip
注意,dev 和 build 的产物是两个不同的扩展,前者在扩展名称加了 DEV | 前缀。
DEV |
在开发时,源代码变更会自动更新扩展,甚至会重新加载页面。有时可能会看到以下错误信息:
Error: Extension context invalidated.
或页面上出现 Plamso 提示:
Context Invalidated, Press to Reload
原因是 Plasmo 重新加载扩展,会使得旧的扩展上下文失效,故而报错,解决方法是刷新页面。
在 DevTool 的 Source - Content Scripts 面板,可以查看所有扩展程序的脚本。
在 chrome://extensions 对应扩展里,可以看到 「检查视图 Service Worker」或「检查视图 背景页」的入口,点击进入可调试。
开发时,可以将扩展固定在 Chrome 工具栏,以便于调试。点击扩展图标,打开 Popup 弹窗,然后像网页那样右键检查元素即可。
准备好以下东西,执行 pnpm 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 读取。
packageJson.manifest
建议格式为 .png,不支持 .webp 和 .svg。
.png
.webp
.svg
提供 16×16、32×32、48×48、128×128 四种尺寸的图片,以 icon<size>.png 形式命名,置于 assets 目录内。
icon<size>.png
assets
请注意,Plasmo 项目的 assets 目录位于项目根目录,不是 src 目录内,即便是通过 --with-src 方式创建的模板项目。
src
--with-src
图标视觉设计,参考官方指南。
一是,要准备扩展程序的用途说明。 二是,要准备请求权限的理由,比如我用到了 host_permissions 和 webNavigation 权限,都要在上传时说明清楚。
host_permissions
所以,没用到的权限就不要加上去了。
The text was updated successfully, but these errors were encountered:
No branches or pull requests
背景
我现在使用 GitHub Blogger 作为个人博客工具,在翻阅文章时,有个体验痛点:无法快速定位到某个章节。
比如,这篇文章涉及的二级、三级标题多达 25+,篇幅也很长。
目前 GitHub 只有仓库 Markdown 文件支持目录能力,但是 Issue 还不支持,所以开发一个 Chrome 扩展程序来解决。
开始之前
项目没必要一步一步去搭,选择社区流行方案即可,我选 Plasmo,开发体验还不错。目录样式参考了 Github、ByteMD。
可粗略看看,了解一些基础概念:
基础概念
清单文件是每个扩展程序必须的,它会列出扩展程序的结构和行为等信息。
Actions 是点击扩展程序图标时发生的动作,可以是打开一个弹出式窗口、打开侧边栏面板、右键菜单等。
内容脚本主要用于修改网页内容。
Extension Service Worker 是运行在浏览器里的后台脚本。
它们之间可以相互传递消息,详见消息传递。
开发
创建项目:
Content Scripts
前面提到,内容脚本是用来修改网页内容的,但它跟网页的 JavaScript 环境是隔离的。
分类
在 Plasmo 里,内容脚本分为两类:
.ts
表示没有 UI 界面的纯脚本,后者则是带 UI 的组件,所以它要默认导出 Component。导出配置
主要用于定义脚本作用的网页地址、执行时机等。
指定插入锚点
也就是我们的 UI 脚本要挂载到网页的哪个地方。
引入样式文件
假设引入
toc.tsx
同级目录下的toc.css
文件。导出一个
getStyle
方法,读取文件的内容,然后往网页插入一个<style>
标签。自定义 Root Container
默认情况下,Plasmo 会创建 Shadow DOM,再挂载到页面,这样做的好处是与外部隔离,如上图所示。
有时,我们希望可以用原网页的样式,比如 CSS 变量等。
这样的话,需要导出一个
getRootContainer
方法。以上示例,我在
.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 方式更新了,那目录有可能跟最新内容对不上了。这里有两种解决方法:
按需选择。在 GitHub Issue TOC 的场景,要用第二种方式。原因是,在 GitHub 的非 Issue 页面跳转到 Issue 页面时,应该是使用了
history.pushState()
方式,它不会重新加载页面,导致目录就不会生成了。这种情况靠document_end
是无法解决的。具体逻辑,按需调整。由于重新挂载页面时,要先将旧的 React App 卸载,所以这里记录了
window.__plasmoTocRoot = root
供下次挂载用。比如:Service Worker
这是可选的,如果你用不到 Service Worker 可以在删掉
background.ts
文件,或者导出一个export {}
,原因同 Content Scripts。前面提到,从 GitHub Repo 跳转到 GitHub Issue 页面的场景,无法生成目录。所以这里我要借助 Service Worker 来解决这个问题。大致思路是,通过
chrome.webNavigation
来监听网页 History 变化,当跳转到 Issue 页面时,向 Content Scripts 传递消息,告诉它该挂载 TOC 组件了。由于 Service Worker 用到
webNavigation
,需要在清单中声明权限。在 Plasmo 框架是在package.json
里指定即可,构建时会自动生成到manifest.json
的。同时,要在 Content Script 里接收信息:
Popup
我这个扩展,其实 Popup 窗口,其实没什么内容,就放了一个 Homepage 和 Report issue 的链接。
提一下,导入样式不用像 Content Script 那样用
data-text:./index.css
来导入,也不用导出getStyle
方法。调试
使用
pnpm create plasmo
创建的项目,包含了:就字面意思,不多说了。
pnpm dev
产出build/chrome-mv3-dev
pnpm build
产出build/chrome-mv3-prod
pnpm package
产出build/chrome-mv3-prod.zip
导入本地扩展程序
调试 Content Scripts
在开发时,源代码变更会自动更新扩展,甚至会重新加载页面。有时可能会看到以下错误信息:
或页面上出现 Plamso 提示:
原因是 Plasmo 重新加载扩展,会使得旧的扩展上下文失效,故而报错,解决方法是刷新页面。
调试 Service Worker
在 chrome://extensions 对应扩展里,可以看到 「检查视图 Service Worker」或「检查视图 背景页」的入口,点击进入可调试。
调试 Popup
开发时,可以将扩展固定在 Chrome 工具栏,以便于调试。点击扩展图标,打开 Popup 弹窗,然后像网页那样右键检查元素即可。
提交扩展
准备好以下东西,执行
pnpm 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
目录内。Chrome Web Store 图片资源
隐私相关说明
一是,要准备扩展程序的用途说明。
二是,要准备请求权限的理由,比如我用到了
host_permissions
和webNavigation
权限,都要在上传时说明清楚。所以,没用到的权限就不要加上去了。
The text was updated successfully, but these errors were encountered: