背景

  1. 之前有开发chrome 插件的的需求,鸭子赶上架做了一个粗略版的基于原生js+jq写的,由于没有用框架进行模块化开发,导致后期维护起来相当困难,一个js文件写到了一万多行,由于功能在不断迭代,基于一款好用的开源chrome v3开发框架变成了迫切需求。
  2. 在网上找了多款开源的框架,都没有找到合适的,基本有两个通病:文档不齐全,有反馈的bug作者没有修复,导致重构的风险增加。
  3. 在网站上找了好久 看到plasmo 文档相当齐全,github start十万加。框架还是相当靠谱。于是基于次框架做了一个使用的插件,和大家一起分享一下。

整体界面预览

在这里插入图片描述
登陆界面
在这里插入图片描述登陆后的界面

在这里插入图片描述
在这里插入图片描述
智能体界面
在这里插入图片描述

侧边栏界面
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述写作界面
在这里插入图片描述绘图界面

在这里插入图片描述图生文界面

技术栈

Plasmo + React + Ant Design(这里要用到它的静态引入方法普通方法会丢失样式) +TS(赶项目这里ts基本就any梭哈了)
开发技术文档:

  1. chrome v3开发文档
  2. plasmo中文文档
  3. Ant Design组件库

插件嵌入的方法

据我了解目前插件注入到界面的主要有三种

  1. 直接插入到主界面,直接注入样式会和主页界面有冲突,之前第一版就是这种情况,各种冲突。
  2. 通过iframe方法嵌入,虽然也能做到样式隔离,但是通讯相对比较麻烦, 和主界面做交互不方便。
  3. 通过shadow-root隔离注入,比较方便,也是plasmo构建时默认的方法,下面也简单介绍一下吧,开发插件先了解前奏,后面也知道所以然。

什么是 Shadow Root?

Shadow Root 是 Web Components 技术中的一个核心概念,它是 Shadow DOM(影子 DOM)的根节点。Shadow DOM 允许开发者创建封装的 DOM 子树,这些子树与主文档 DOM 隔离,从而避免样式冲突和意外的 JavaScript 干扰。简单来说,Shadow Root 是影子树的入口点,让组件内部的 HTML、CSS 和 JavaScript 独立运行。

Shadow Root 的主要特点

  • 封装性:Shadow Root 内的元素不会受到外部 CSS 或 JavaScript 的影响,反之亦然。这有助于构建可复用的组件,如自定义元素(Custom Elements)。
  • 作用域隔离:样式和脚本在 Shadow Root 内生效,不会泄露到外部文档。
  • 性能优化:浏览器可以优化渲染,因为影子树是独立的。
  • 浏览器支持:现代浏览器(如 Chrome、Firefox、Safari、Edge)都支持 Shadow DOM。IE 不支持,但可以通过 polyfill 模拟。

插件嵌入到主页界面的方法,这里选用的的是 shadow-root ,plasmo框架自动注入到界面的默认就是这个方法。
在这里插入图片描述
在这里插入图片描述
图片显示的是插件注入到界面显示的层级以及结构信息,插入到位置框架支持自定义的,具体的可以去看plasmo官方文档。
plasmo官方中文文档:plasmo官方文档

插件功能 (这里就不写插件名字了,只和大家分享开发经验)

  • 插件功能包含一下几个部分: 聊天、智能体,写作,绘图、图升文部分,插件功能集成了目前市面最新的大模型deepseek ,GPT4o、千问等目前市面最流行的大模型。

先给大家看看界面:

在这里插入图片描述
在这里插入图片描述

界面的整体功能

聊天界面的:

  1. 聊天界面的主要集合了大模型目前流行的几个大模型deepseek ,GPT4o、千问等,这里同时也集成了基于内部的智能体部分,做一个支持切换大模型和智能体,切换列表的功能。
  2. 对话聊天部分,采用的是sse流式文本返回的,且支持markdown语法展示,支持化学物理公式,当然样式有借鉴其他插件的部分,本文只讨论技术实现,其他不做过多介绍,先给大家展示一下整体的功能样子。
  3. 插件功能支持侧边栏和固定栏展示,以及侧边栏快捷按钮操作。

在这里插入图片描述

项目整体目录

在这里插入图片描述
以上就是整个项目,目录的整体部分,目录是安装plasmo脚手架后在原有的基础上做了部分改动,细节部分可以配合plasmo官网文档配置,下面会将一些官网文档,不够详细的坑,开发过程中我觉得难点主要就是登陆授权这一部分,以及如何静态引入antdsign组件的部分,下面也会着重讲这两部分功能的实现,并且会配置代码片段,供大家参考(前端小菜鸡,写的可能不太好,大家嘴上积德)

contents界面 :重要功能主要都集中在这个文件下,也算是实现整个插件功能的入口

提示:没有这方面的基础的话建议看看chrome v3开发文档先了解一些基础,看的更明白也知道我在说什么—chrome 插件开发文档
在这里插入图片描述
1.getStyle方法我这里是将个人写的样式和antdesign.css 通过字符串的形式拼接的。

 export const getStyle = () => {
  const style = document.createElement("style")
  style.textContent = contentScss + antdResetCssText
  return style
}

写chrome 插件不能和react脚手架开发框架,直接导入组件使用,这种方式的话样式不生效,当然使用antdesign有部分组件使用是有问题的,需要自己手搓,或者在原有的组件上进行二次封装配置的,这个也是我前期开发比较头疼的问题,也是踩坑最多的,网上相关案例少之又少,很难受。
在这里插入图片描述
个人写的样式都会集中在引入在style_public.scss文件下面的,最后统一导入到getStyle方法中,直接引入到组件是不生效的如下的示例。

import React from "react"
import "./index.scss"
export default function index(props) {
  const { loading } = props
  return (
    <div
      className="loading-lsdo223d"
      style={{ display: loading ? "flex" : "none" }}>
      <div className="loading-content-lsdo12dsd">
					示例代码
      </div>
    </div>
  )
}

此处样式不会生效。

  1. 配置chrome插件根节点挂在主界面的位置
// 插入单个锚地点
export const getInlineAnchor: PlasmoGetInlineAnchor = async () => {
  // 当前文档是在 iframe 中 阻止注入
  if (window.self !== window.top) return null
  // 当前文档不在 iframe 中允许注入
  return document.querySelector("body") as HTMLElement
}

这里有个坑,不加入这段代码的话,写的插件会循环注入到主页,有iframe的引用代码里面,不仅会影响主界面加载速度,写的快捷键按钮也会显示多个,严重影响使用体验,下面示例是判断是否是主页不是主页的话则不注入chrome插件脚本。

 // 当前文档是在 iframe 中 阻止注入
  if (window.self !== window.top) return null
  1. antdesign 静态组件如何引入的问题
import AntDrawer from "antd/es/drawer"
import AntMenu from "antd/es/menu"
import AntMessage from "antd/es/message"
import AntPopover from "antd/es/popover"
import AntTooltip from "antd/es/tooltip"

安装好antdesgin包后,组件都是在antd/es/目录下的引入使用的,使用组件前需要在根节点包裹一个ConfigProvider组件,下面是做了基于组件封装的一个全局样式配置组件

import ConfigProvider from "antd/es/config-provider"
import zh_CN from "antd/es/locale/zh_CN"
import type { ReactNode } from "react"
import React from "react"

export const ThemeProvider = ({ children = null as ReactNode }) => (
  <ConfigProvider
  locale={zh_CN}
    theme={{
      components: {
        Menu: {
          itemSelectedBg: "#f0ebff",
          itemSelectedColor: "#6841ea",
          itemActiveBg: "#f0ebff",
          dropdownWidth: 120,
          itemHeight: 32,
          algorithm: true // 启用算法
        },
        Dropdown:{
          zIndexPopup: 21474836489,
        },

        Select: {
          colorPrimary: "#6841ea",
          zIndexPopup: 21474836489,
          selectorBg: "#4f59661f",
          algorithm: true, // 启用算法
          borderRadius: 8
        },
        Input: {
          colorPrimary: "#6841ea",
          algorithm: true, // 启用算法
          borderRadius: 8
        },

        Slider: {
          colorPrimary: "#6841ea",
          algorithm: true // 启用算法
        },
        Switch: {
          colorPrimary: "#6841ea",
          algorithm: true // 启用算法
        },
        Checkbox: {
          colorPrimary: "#6841ea",
          algorithm: true // 启用算法
        },
        Spin: {
          colorPrimary: "#6841ea",
          algorithm: true // 启用算法
        },
        Radio: {
          colorPrimary: "#6841ea",
          algorithm: true // 启用算法
        },
        Popover: {
          borderRadius: 12
        }
      }
    }}>
    {children}
  </ConfigProvider>
)

根节点嵌套后的的示例如下:

const ContentPage = () => {
  return (
    <Provider store={store}>
        <ThemeProvider>
          <StyleProvider
            container={
              document.querySelector("#extensions").shadowRoot
            }>
            <HomePage openFixedSidbar={false} />
          </StyleProvider>
        </ThemeProvider>
    </Provider>
  )
}

export default ContentPage

StyleProvider和ConfigProvider区别,详情可以看看Ant Design官网

React 中 StyleProvider 和 ConfigProvider 的区别(表格形式)

方面 ConfigProvider StyleProvider
主要用途 配置组件的全局属性和行为,如主题、语言环境、图标、动画等。 专门用于样式隔离和动态样式注入,防止样式冲突。
常见配置项 - theme:设置全局主题
- locale:设置语言环境
- prefixCls:CSS 类名前缀
- getPopupContainer:弹出容器
- componentSize:组件大小
- cache:样式缓存
- hashed:哈希化类名
- container:样式注入容器
使用场景 应用入口处统一配置整个应用的默认设置。 微前端、多实例应用或动态组件加载时进行样式隔离。
示例代码 jsx<br>import { ConfigProvider } from 'antd';<br><ConfigProvider locale={zhCN} theme={{ algorithm: theme.darkAlgorithm }}><br> {/* 内容 */}<br></ConfigProvider><br> jsx<br>import { StyleProvider } from '@ant-design/cssinjs';<br>const cache = createCache();<br><StyleProvider cache={cache}><br> {/* 内容 */}<br></StyleProvider><br>
影响范围 影响组件的行为和样式属性,但不直接处理样式隔离。 专注于样式管理,不涉及组件的行为配置。
依赖库 内置于 Ant Design 库。 来自 @ant-design/cssinjs,通常与 ConfigProvider 结合使用。
使用频率 几乎所有 Ant Design 应用都需要。 主要在高级场景(如微前端)中使用。

登陆部分

登陆部分的话会涉及到content界面和background的通讯,还需要用到chrome v3 里面一个登陆授权的api调用 chrome.identity.launchWebAuthFlow,这一部分相关文档案例也是非常少的,在这里也是卡了很久的一个点,接下来和大家分享一下,线上登陆的界面UI。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

上面分别是登陆时的界面、飞书授权的界面、登陆授权通过后的首页。点击登陆按钮后,会在浏览器上面重新打开一个新的弹框界面,覆盖原来的标签页,这里是mac版本chrome浏览器,windows可能会有所区别,但是授权功能是一样的,点击登陆调用飞书api接口,获取返回的登陆url地址跳转到飞书授权界面,授权成功后重定向到原先的插件界面。

import { getUerInfo, login, logOut } from "http/api/base"
import { changeBaseUrl } from "utils"

import type { PlasmoMessaging } from "@plasmohq/messaging"


const handler: PlasmoMessaging.PortHandler = async (req, response) => {
  if (!req) return response.send({})
  const { acticon, sidbarIndex } = req.body
  switch (acticon) {
    // 登录界面
    case "loginPage":
      const redirectUrl = chrome.identity.getRedirectURL("redirectUrl")
      const data = (await login(redirectUrl)) as any
      const { login_url } = data.data
      // console.log(login_url,"login_url")
      try {
        const res = (await launchWebAuthFlow(login_url)) as any
        if (res.ret === 0) {
          const { _xxx_identifier } = res.data
          const resUserData = (await getUerInfo()) as any
          if (resUserData.ret === 0) {
            const { account, user_name } = resUserData.data
            // 生成用户唯一标识
            const xxx_browser_fp =
              new Date().getTime() + Math.random().toString(36).substr(2)
            response.send({
              res: 0,
              userInfo: {
                _xxx_identifier,
                xxx_browser_fp,
                account,
                user_name
              }
            })
          } else {
            response.send({ res: 1, msg: resUserData.msg })
          }
        } else {
          response.send({ res: 1 })
        }
      } catch (e) {
        response.send({ res: 1, msg: e })
      }
      break

    // 退出登录
    case "logout":
      try {
        const res = await logOut()
        response.send({ res })
      } catch (e) {
        response.send({ res: 1, msg: e })
      }
      break

    // 获取快捷键
    case "getCommands":
      const commands = await getCommands()
      response.send({
        ret: 0,
        commands
      })
      // 获取快捷键信息
      function getCommands() {
        return new Promise((resolve, reject) => {
          chrome.commands.getAll((commands) => {
            resolve(commands)
          })
        })
      }
      break
    default:
      chrome.runtime.openOptionsPage()
  }
}

function PopPageState() { }

// 鉴权弹框
function launchWebAuthFlow(login_url: any) {
  if (!login_url) return
  return new Promise<void>((resolve, reject) => {
    // 打开授权页面
    chrome.identity.launchWebAuthFlow(
      {
        url: login_url,
        interactive: true
      },
      (responseUrl) => {
        try {
          // 获取问号后面的数据
          const parameter = responseUrl
            .substring(responseUrl.indexOf("?") + 1)
            .split("&")
          const _xxx_identifier = encodeURIComponent(
            parameter[0].substring(parameter[0].indexOf("=") + 1)
          )
          const max_age = parameter[1].substring(parameter[1].indexOf("=") + 1)
          // await storage.set('_xxx_identifier', _xxx_identifier)
          const cookie = {
            url: changeBaseUrl(),
            name: "_33dd_identifier",
            value: _xxx_identifier,
            domain: ".xxx",
            path: "/",
            secure: true,
            httpOnly: true,
            sameSite: "no_restriction",
            expirationDate: Number(max_age)
          } as any
          chrome.cookies
            .set(cookie)
            .then((cookie) => {
              console.log(cookie, "cookie")
              resolve({ ret: 0, data: { _xxx_identifier } } as any)
            })
            .catch((error) => {
              reject({ ret: 1, error })
            })
          
        } catch (error) {
          reject({ ret: 1, error })
        }
      }
    )
  })
}
export default handler

上面这一部分是background界面的代码主要涉及到chrome v3的两个api,chrome.identity.getRedirectURLchrome.identity.launchWebAuthFlow

chrome.identity.getRedirectURL

chrome.identity.getRedirectURL() 是 Chrome 浏览器扩展 API 中的一个方法,属于 chrome.identity 模块。它主要用于 OAuth 2.0 身份验证流程,特别是当扩展需要与外部服务(如 Google API)进行身份验证时。

功能描述
  • 用途:该方法生成一个唯一的重定向 URL,扩展可以使用它作为 OAuth 流程中的 redirect_uri 参数。当用户完成授权后,外部服务会将授权码或令牌重定向到这个 URL,扩展可以监听并处理这些数据。
  • 安全性:生成的 URL 是基于扩展的 ID 自动生成的,确保唯一性和安全性,避免冲突。
  • 适用版本:主要在 Manifest V3 扩展中使用(Manifest V2 已弃用)。
参数
  • 无参数:这是一个无参数的方法,直接调用即可。
返回值
  • 类型string(字符串)
  • 内容:返回一个完整的 URL,例如 https://<extension-id>.chromiumapp.org/,其中 <extension-id> 是扩展的唯一 ID。
使用示例

假设你在扩展的后台脚本(background.js)中使用它:

// 获取重定向 URL
const redirectURL = chrome.identity.getRedirectURL();
console.log(redirectURL);  // 输出类似:https://abcdefghijklmnop.chromiumapp.org/

// 在 OAuth 流程中作为重定向 URI 使用
const authURL = `https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=${encodeURIComponent(redirectURL)}&scope=email`;

// 监听重定向(通常通过 chrome.webRequest 或其他方式)
注意事项
  • 权限要求:需要在扩展的 manifest.json 中声明 "identity" 权限,例如:
    {
      "permissions": ["identity"]
    }
    
  • 局限性:仅适用于 Chrome 扩展,不能在普通网页中使用。
  • 兼容性:支持 Chrome 29+,但在 Manifest V3 中推荐使用 chrome.identity.launchWebAuthFlow() 来处理完整的 OAuth 流程。

chrome.identity.launchWebAuthFlow

chrome.identity.launchWebAuthFlow() 是 Chrome 扩展 API,用于启动 OAuth 认证流程,打开认证页面并处理重定向。

参数
  • url (string, 必填):认证 URL。
  • interactive (boolean, 可选):是否允许用户交互,默认 false
  • abortOnLoadForNonInteractive (boolean, 可选):非交互时是否中止加载,默认 false
返回值
  • Promise<string>:成功时返回重定向 URL,失败时抛出错误。
示例
chrome.identity.launchWebAuthFlow({
  url: 'https://example.com/auth?redirect_uri=' + chrome.identity.getRedirectURL(),
  interactive: true
}, (url) => {
  if (chrome.runtime.lastError) console.error(chrome.runtime.lastError);
  else console.log('重定向 URL:', url);
});
注意
  • 需要 "identity" 权限。
  • 适用于 Manifest V3。始终验证重定向 URL。
    // 登录界面
    case "loginPage":
      const redirectUrl = chrome.identity.getRedirectURL("redirectUrl")
      const data = (await login(redirectUrl)) as any
      const { login_url } = data.data
      // console.log(login_url,"login_url")
      try {
        const res = (await launchWebAuthFlow(login_url)) as any
        if (res.ret === 0) {
          const { _xxx_identifier } = res.data
          const resUserData = (await getUerInfo()) as any
          if (resUserData.ret === 0) {
            const { account, user_name } = resUserData.data
            // 生成用户唯一标识
            const xxx_browser_fp =
              new Date().getTime() + Math.random().toString(36).substr(2)
            response.send({
              res: 0,
              userInfo: {
                _xxx_identifier,
                xxx_browser_fp,
                account,
                user_name
              }
            })
          } else {
            response.send({ res: 1, msg: resUserData.msg })
          }
        } else {
          response.send({ res: 1 })
        }
      } catch (e) {
        response.send({ res: 1, msg: e })
      }

最上面也分别解释了chrome.identity.getRedirectURLchrome.identity.launchWebAuthFlow两个api的作用,上面这个代码主要核心点也是这两个api结合起来使用的关键点,

  1. 首先通过chrome.identity.getRedirectURL(“redirectUrl”) 获取插件当前所在的浏览器标窗口的唯一标识地址的。
  2. 然后通过 chrome.identity.launchWebAuthFlow 携带 url地址 也就是你需要授权登陆的地址,打开授权弹框。
  3. 最后通过授权信息获获取响应参数里面接口返回的个人信息,以及授权token,存储在chrome 插件的storage当中。
  4. 成功获取到授权信息后,会自动关闭授权弹框,重定向到你插件所在的窗口界面,授权完成,后面的接口方法都会通过这些信息进行鉴权,整体打开就是这个思路。

整体大概就是这样,插件其他功能感觉也没有啥难点了,这个框架热加载有时候还有点问题,调试起来也是有点麻烦,卡住了的话需要手动重新加载一下插件,运行起来的时候速度相对较慢,但是整体的话还是不错的。其他细节后续一一完善。同时也欢迎大家可以一起讨论一起进步。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐