Chrome 浏览器插件开发

进击的学霸...大约 6 分钟

前段时间做了个工作中使用的小工具,考虑应用的场景决定使用浏览器插件的方式,主要包含一个页面、一个浮层和一个 devtools 面板

项目基础

Chrome 插件 API 文档: https://developer.chrome.com/docs/extensions/reference/open in new window

  • 随便生成一个 vue3 项目,使用多页面配置
  • 将 src 下的 plugins 文件夹中的内容直接复制到打包后的 dist 文件夹内。主要是一些插件相关的配置文件等
  • 定义编译好的 js 输出路径及名字

src 目录结构

├── api
|  ├── index.ts
|  └── request.ts
├── assets
|  ├── style
├── components
|  └── PageInfo.vue
├── pages
|  ├── devtool
|  ├── index
|  └── popup
├── plugins
|  ├── background.js
|  ├── devtool-background.html
|  ├── devtools-background.js
|  ├── icons
|  ├── inject.js
|  ├── manifest.json
|  └── secretKey.pem
├── utils
|  └── index.js
└── vite-env.d.ts

dist 目录结构

dist 目录结构
├── assets
|  ├── devtool.js
|  ├── index-chunk.js
|  ├── index-chunk2.js
|  ├── index.5a9161b8.css
|  ├── index.ba0a4ff3.css
|  ├── index.d753dcfc.css
|  ├── index.d7ab9c59.css
|  ├── index.js
|  └── popup.js
├── background.js
├── devtool-background.html
├── devtools-background.js
├── icons
|  └── 128.png
├── inject.js
├── manifest.json
├── pages
|  ├── devtool
|  ├── index
|  └── popup
└── secretKey.pem

vite.config.ts

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import copy from 'rollup-plugin-copy'
import { resolve } from 'path'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    {
      ...copy({
        targets: [
          { src: 'src/plugins/*', dest: 'dist' }
        ]
      }),
      apply: 'build'
    }
  ],
  root: './src',
  base: '/',
  build: {
    outDir: '../dist',
    rollupOptions: {
      input: {
        index: resolve(__dirname, 'src/pages/index/index.html'),
        popup: resolve(__dirname, 'src/pages/popup/index.html'),
        devtool: resolve(__dirname, 'src/pages/devtool/index.html'),
      },
      output: {
        entryFileNames: `assets/[name].js`,
        chunkFileNames: `assets/[name]-chunk.js`
      }
    }
  },
  resolve: {
    alias: {
    '@': resolve(__dirname, './src/'),
    },
  },
})

manifest.json

// manifest.json
{
  "name": "FE工具",
  "description": "FE工具!",
  "version": "1.0",
  "manifest_version": 3,
  "action": {
    "default_title": "这是一个示例Chrome插件",
    "default_popup": "pages/popup/index.html"
  },
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["assets/popup.js"],
      "css": [],
      "run_at": "document_start"
    }
  ],
  "permissions": [
    "tabs"
  ],
  "homepage_url": "https://lovem.fun",
  "devtools_page": "devtool-background.html"
}

devtool-background.html

<!-- devtool-background.html -->
<meta charset="utf-8">
<script src="/devtools-background.js"></script>

devtools-background.js

// devtools-background.js

let created = false
let checkCount = 0

chrome.devtools.network.onNavigated.addListener(createPanel)
const checkInterval = setInterval(createPanel, 1000)
createPanel()

function createPanel () {
  if (created || checkCount++ > 10) {
    clearInterval(checkInterval)
    return
  }
  clearInterval(checkInterval)
  created = true
  chrome.devtools.panels.create(
    'FE', 'icons/128.png', '/pages/devtool/index.html',
    panel => {
      // panel loaded
      panel.onShown.addListener(onPanelShown)
      panel.onHidden.addListener(onPanelHidden)
    },
  )
}

// Manage panel visibility

function onPanelShown () {
  chrome.runtime.sendMessage('teacherfe-panel-shown')
}

function onPanelHidden () {
  chrome.runtime.sendMessage('teacherfe-panel-hidden')
}

浮层显示,前面的配置完成之后,这只是一个单页应用的开发。写页面这个就不用多说了,里面用到了一些有用的插件 api ,列在下方

  • 获取当前插件的信息,使用的场景是用来打开一个独立页面,就是上面提到的 Index 页面

    const handleOpenHomePage = async () => {
      const pluginInfo = await chrome.management.getSelf()
      chrome.tabs.create({ url: `chrome-extension://${pluginInfo.id}/pages/index/index.html` })
    }
    
  • 获取当前 tab 的 url、更新当前 taburl、重载当前 tab 。使用场景是针对 url 上携带的参数进行业务说明,以及编辑参数后重载页面

    // 获取当前 tab 的信息
    let [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
    url.value = decodeURIComponent(tab.url || '')
    tabId.value = tab.id ? tab.id : 0
    
    // 更新当前 tab 的 url
    chrome.tabs.update({ url: urlTemp.toString(), openerTabId: tabId.value })
    // 重载当前 tab
    setTimeout(() => {
      chrome.tabs.reload(tabId.value)
    }, 100)
    

Index

主要是需要做一个书签页面,添加了开发常用的网址分类等,便于快速跳转。也有一些开发或者查问题用的小工具,做了一个单独的页面,通过在 popup 的页面中主页按钮打开,无甚可说的

Devtools

因为我们的页面是嵌入到原生 win 应用中的,调试的时候需要通过 Chrome 的 inspect 功能,来查看页面控制台,预期是想做一个能直接在面板中解析页面 url 中的业务参数的功能,这样就不用把 url 复制出来在去解析查看了。但是我琢磨了很久也没找到可以获取 inspect 出来的页面的 url,因为这个 inspect 是一个独立的调试窗口,没有对应的 tab 页签。目前的话我已经放弃了自动获取,选择需要手动输入,起码可以少打开个页面把这样。

  • 获取 inspect 对应页签的 url ,前提是要有页签啊

    const tabId = chrome.devtools.inspectedWindow.tabId
    
    const getUrl = async () => {
      const tab = await chrome.tabs.get(tabId)
      tabUrl.value = tab.url || ''
    }
    
    getUrl()
    

联动

最近新增了一个功能:给页面注入一个 js 对象并挂载到 window 上,模拟 jsBridge 的作用。

众嗦粥汁,混合开发中,web 和原生应用之间的通信一般由 jsBridge 进行中转,名字可能略有差别,但作用就是帮助 FE 调用 NA,或者反过来帮助 NA 调用到 FE ,我们应用的 Bridge 主要方法有三个:invokeMethod 、addMethod 、callMethod 。需要模拟的是 addMethod 的流程,这个方法实现的是 FE 注册监听 NA 发来的事件,收到事件后执行回调,并传入事件携带的参数。

FE 代码在初始化执行的过程中就试图在 window 上寻找 Bridge 对象,如果找到了就开始初始化监听事件的注册,找不到就不执行了,所以我需要在页面加载前将 Bridge 对象注入,以便页面 js 执行时可以访问到。一切顺利的话,在页面执行中,我可以在 popup 中向页面发送事件来触发回调,实现流程的模拟

注入实现

新增 inject.js 文件和 QCefClient.js 文件

console.log('我是 injectict.js,我被注入了', chrome)


const head = document.documentElement
const script = document.createElement('script');
const url = chrome.runtime.getURL('QCefClient.js');
script.setAttribute('src', url)
script.setAttribute('async', false)
const firstChild = head.firstChild
head.insertBefore(script, firstChild)

chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
  if (request.type === 'fireEvent') {
    console.log('我特么接收到了fireEvent捏', request)
    window.postMessage(request, request.targetOrigin)
  }
});

console.log('window.QCefClient', window.QCefClient)
// QCefClient.js
class QCefClient {
  static instance;
  static listeners = {};

  // 单例
  static getInstance() {
    if (!QCefClient.instance) {
      QCefClient.instance = new QCefClient();
    }

    return QCefClient.instance;
  }

  static addEventListener(eventName, callback) {
    if (!QCefClient.listeners[eventName]) {
      QCefClient.listeners[eventName] = [];
    }
    QCefClient.listeners[eventName].push(callback);
  }

  static fireEvent(eventName, ...args) {
    if (QCefClient.listeners[eventName]) {
      QCefClient.listeners[eventName].forEach(callback => {
        callback(...args);
      });
    }
  }

  static invokeMethod() {}

  constructor() {
    this.listeners = {};
  }

  addEventListener(eventName, callback) {
    if (!this.listeners[eventName]) {
      this.listeners[eventName] = [];
    }
    this.listeners[eventName].push(callback);
  }

  fireEvent(eventName, ...args) {
    if (this.listeners[eventName]) {
      this.listeners[eventName].forEach(callback => {
        callback(...args);
      });
    }
  }

  invokeMethod() {}
  
}

window.QCefClient = QCefClient.getInstance();

window.addEventListener('message', function(event) {
  if (event.data.type === 'fireEvent') {
    console.log('我特么也接收到了fireEvent捏', event.data)
    window.QCefClient.fireEvent(event.data.eventName, event.data.data)
  }
});

console.log('window.QCefClient', window.QCefClient)

利用插件的 content_scripts 能力,将 inject.js 注入,但是由于上下文不同,所以直接在 inject 中往 window 上挂对象是只能挂在自己的上下文,无法影响到页面。采用的方法是临时创建一个 script 标签,标签中引入一个可信的 js 脚本资源,只能用路径,不能直接内联代码,然后需要在 manifest.json 中添加一些内容

{
  ...,
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["assets/popup.js"],
      "css": [],
      "run_at": "document_start"
    },
    {
      "matches": ["http://localhost:*/*"],
      "js": ["inject.js"],
      "run_at": "document_start"
    }
  ],
  "web_accessible_resources": [{
    "resources": ["QCefClient.js"],
    "matches": ["http://localhost:*/*"]
  }],
  "permissions": [
    "tabs",
    "activeTab",
    "<all_urls>",
    "chrome_extension",
    "web_accessible_resources",
    "scripting"
  ]
}

这样是保证了 Bridge 的注入,然后有注意到我们似乎还用到了一些事件监听,作用就是响应后续由 popup 发送的事件

事件触发

由于事件是动态的,想要实时触发事件,即调用到对应 tab 的 Bridge ,往页面里实时插入脚本的能力就不能满足了,因为脚本里的内容是固定的,而内联代码又不被允许。解决办法是:先由插件内部通信,由 popup 往 inject.js 发送一个消息,inject 中监听到这个消息后通过 window.postMessage 往 tab 页面发送消息,然后再由 QCefClient.js 中的监听 message 的回调来执行具体的消费事件的动作。inject 和 QCefClient 的代码上面贴了这里不重复贴了,放一下事件发布处的逻辑

async function handleInvokeAction(action: any) {
  console.log('action: ', action)
  let [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
  const data: any = {}
  action.params.forEach((item: any) => {
    data[item.name] = item.value
  })
  let sendData = {
    type: 'fireEvent',
    eventName: action.name === 'CUSTOM_EVENT' ? data.eventName : action.name,
    targetOrigin: tab.url,
    data
  }
  console.log('sendData: ', sendData)
  chrome.tabs.sendMessage(tab.id || 0, sendData)
}

里面有一个需要注意的点就是需要有一个 tab 的 id 在流程之间串联,用于找到对应的页面。

后续就是慢慢将事件写在插件中,就可以实现通过插件来 mock NA 的事件消费了。

总结

以上就是使用 vue3 开发一个插件基本要做的,总的来是还是比较简单的,就是做一个多页应用的解决方案,比较耗费时间的是查找需要用的 api ,以及验证使用,因为是业务工具,就不放 github 上了,不过有这些文件基础,复刻一个还是比较容易的哈

评论
  • 按正序
  • 按倒序
  • 按热度