Skip to content
New issue

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 #340

Open
toFrankie opened this issue Jun 16, 2024 · 0 comments
Open

开发一个简单的 Chrome Extension #340

toFrankie opened this issue Jun 16, 2024 · 0 comments
Labels
2024 2024 年撰写 前端 与 JavaScript、ECMAScript、Web 前端相关的文章 生活随笔 一些杂七杂八的文章

Comments

@toFrankie
Copy link
Owner

toFrankie commented Jun 16, 2024

配图源自 Freepik

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

背景

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

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

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

github-issue-toc

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

开始之前

项目没必要一步一步去搭,选择社区流行方案即可,我选 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

引入样式文件

假设引入 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 组件挂载到上面,以实现上述 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 权限,都要在上传时说明清楚。

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

@toFrankie toFrankie added 2024 2024 年撰写 前端 与 JavaScript、ECMAScript、Web 前端相关的文章 生活随笔 一些杂七杂八的文章 labels Jun 16, 2024
@toFrankie toFrankie changed the title 开发一个简单的 Chrome Extension 程序记录 开发一个简单的 Chrome Extension 程序 Jun 16, 2024
@toFrankie toFrankie changed the title 开发一个简单的 Chrome Extension 程序 开发一个简单的 Chrome Extension Jun 16, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
2024 2024 年撰写 前端 与 JavaScript、ECMAScript、Web 前端相关的文章 生活随笔 一些杂七杂八的文章
Projects
None yet
Development

No branches or pull requests

1 participant