likes
comments
collection
share

开发一个简单的 Chrome Extension使用 Plasmo 开发一个 Chrome Extension,用来生成

作者站长头像
站长
· 阅读数 35

开发一个简单的 Chrome Extension使用 Plasmo 开发一个 Chrome Extension,用来生成

原文链接

在此作一个记录,第一次开发 Chrome Extension 的同学也可以看看。

背景

我现在使用 GitHub Blogger 作为个人博客工具,在翻阅文章时,有个体验痛点:无法快速定位到某个章节。

比如,这篇文章涉及的二级、三级标题多达 25+,篇幅也很长。

目前 GitHub 只有仓库 Markdown 文件支持目录能力,但是 Issue 还不支持,所以开发一个 Chrome 扩展程序来解决。

github-issue-toc

  • 支持 h1 ~ h6 标题。
  • 支持滚动高亮。
  • 支持粘性布局,滚动时始终在可视区域内。
  • 样式风格与 GitHub 契合,支持深色模式。

开发一个简单的 Chrome Extension使用 Plasmo 开发一个 Chrome Extension,用来生成

开始之前

项目没必要一步一步去搭,选择社区流行方案即可,我选 Plasmo,开发体验还不错。目录样式参考了 GithubByteMD

可粗略看看,了解一些基础概念:

第一篇作者总结得挺好,但里面一些东西在 Manifest V3 发生了变化,可以看看第二篇文章。

基础概念

清单文件是每个扩展程序必须的,它会列出扩展程序的结构和行为等信息。

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.tsxcontents/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

开发一个简单的 Chrome Extension使用 Plasmo 开发一个 Chrome Extension,用来生成

引入样式文件

假设引入 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

开发一个简单的 Chrome Extension使用 Plasmo 开发一个 Chrome Extension,用来生成

自定义 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 组件挂载到上面,以实现上述 getInlineAnchorinsertPosition: '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 | 前缀。

导入本地扩展程序

  1. 浏览器打开 chrome://extensions
  2. 开启「开发者模式」
  3. 在「加载已解压的扩展程序」选择对应的产物目录,并启用该扩展。

调试 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.versionmanifest.version
  • packageJson.displayNamemanifest.name
  • packageJson.descriptionmanifest.description
  • packageJson.authormanifest.author
  • packageJson.homepagemanifest.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_permissionswebNavigation 权限,都要在上传时说明清楚。

所以,没用到的权限就不要加上去了。

转载自:https://juejin.cn/post/7380694342744899622
评论
请登录