浏览器插件开发 - 消息管理

1,068 阅读2分钟

我们知道 Chrome 扩展程序实际上就是 web 技术。Vue 作为目前最流行的前端框架之一,完全可以应用到扩展程序的开发中。下面仅就 Vue 在插件开发中的应用的一些经验展开。

目录结构

  • assets
  • components
  • composiables (可选, 如果使用 vue3)
  • background
  • contentScript
  • injectScript
  • popup
  • store
  • styles

注入多个 Vue App 到页面

有时我们需要在某些网页的特定位置注入内容,而这些注入的内容就可以通过 Vue 来完成渲染,这也是 Vue 作为渐进式框架的应用。

在你的 contentScript 的入口文件中,我们可以如下实现。

// contentScript/main.js
import App from './App.vue'
import { store } from '@/store'

const root = document.createElement('div')
root.setAttribute('class', 'my-extension-app')
const position = document.body
position.appendChild(root)

new Vue({
  store,
  render: (h) => h(App)
}).$mount(root)

如果你的扩展程序比较复杂,就需要创建多个 App 应用插入到页面中的不同位置。

// contentScript/main.js
import { store } from '@/store'
import Comp1 from './Comp1.vue'
import Comp2 from './Comp2.vue'
import Comp3 from './Comp3.vue'

// Other logics...

const root1 = document.createElement('div')
root1.setAttribute('class', 'my-extension-app-1')
const position1 = document.body
position.appendChild(root1)

new Vue({
  store,
  render: (h) => h(Comp1)
}).$mount(root1)

const root2 = document.createElement('div')
root1.setAttribute('class', 'my-extension-app-2')
const position = document.body
position.appendChild(root2)

new Vue({
  store,
  render: (h) => h(Comp2)
}).$mount(root2)

const root3 = document.createElement('div')
root1.setAttribute('class', 'my-extension-app-3')
const position = document.body
position.appendChild(root3)

new Vue({
  store,
  render: (h) => h(Comp3)
}).$mount(root3)

// Other logics...

上面的实现没有任何问题,但是作为入口文件通常还有其他逻辑,而且入口文件通常只做初始化相关的逻辑,我们希望它精简,直观易懂。显然上面的实现方式使入口文件变得臃肿松散了。

如何更优雅地去实现呢?这时我们抽象出一个 App 类,由它来完成所有这些事情。我们希望入口文件 main.js 看起来一目了然,就像下面这样:

// contentScript/main.js
import { store } from '@/store'
import App from 'app.js'

new App({ store }).mount()

它看上去非常精简了。现在我们要做的是怎么把上面的那些初始化操作封装进一个 App class 中,提取出来放到 app.js。

// app.js
import Comp1 from './Comp1.vue'
import Comp2 from './Comp2.vue'
import Comp3 from './Comp3.vue'

export default class App {
  constructor({ store }) {
    this.injectPosition1 = null
    this.injectPosition2 = null
    this.injectPosition3 = null

    this.Comp1Container = null
    this.Comp2Container = null
    this.Comp3Container = null

    this.Comp1App = null
    this.Comp2App = null
    this.Comp3App = null

    this.store = store
  }

  mount() {
    // Some logic here...

    this.injectPosition1 = document.querySelector('.class1')
    this.Comp1Container1 = this._createContainer('comp1-container')
    this.Comp1App = new Vue({
      store,
      render: (h) => h(Comp1),
    }).$mount(this.injectPosition1)

    this.injectPosition2 = document.querySelector('.class2')
    this.Comp2Container = this._createContainer('comp2-container')
    this.Comp2App = new Vue({
      store,
      render: (h) => h(Comp2),
    }).$mount(this.injectPosition2)

    this.injectPosition3 = document.querySelector('.class3')
    this.Comp3Container = this._createContainer('comp3-container')
    this.Comp3App = new Vue({
      store,
      render: (h) => h(Comp3),
    }).$mount(this.injectPosition3)

    // inject scripts here

    // Other logic...
  }

  unmount() {
    // Some logic here...

    this.injectPosition1 = null
    this.injectPosition2 = null
    this.injectPosition3 = null

    this.Comp1Container = null
    this.Comp2Container = null
    this.Comp3Container = null

    this.Comp1App = null
    this.Comp2App = null
    this.Comp3App = null

    this.store = null

    // Other logic...
  }

  _createContainer(id, position) {
    const container = document.createElement('div')
    container.setAttribute('id', id)
    position.appendChild(container)
    return container
  }
}

我们将初始化操作都封装进了 App class 的 mount() 方法,同时提供一个做清理工作的方法 unmount()。随着需求变动,如果需要扩展功能,都可以对 App class 进行扩展。比如,我们想为 contentScript 提供一个事件中央调度器,所有传给 contentScript 的事件都由这个中样调度器来调度处理。

// contentScript/main.js
import { store } from '@/store'
import { EventBus } from './bus'
import App from 'app.js'

const eventBus = new EventBus()
new App({ store, eventBus }).mount()

这种抽象提取逻辑的方式,在 web 开发中也是很常见的,这也是es6 class 的一种典型应用。

前面我们是先假设已经完成了App 的实现,通过 new App({ store }).mount() 来进行初始化。然后再去实现的 App 的逻辑。 这种思考方式是一种反向思维。在开发中,正向思维和方向思维灵活使用,你会遇见不一样的风景。

推荐学习开源浏览器插件:GitHub - checkly/headless-recorder: Chrome extension that records your browser interactions and generates a Playwright or Puppeteer script.

消息通讯

Chrome 扩展程序的各个组件(background, content-script, popup, options)彼此之间是通过事件通讯。它们之间通过如下 API 发送消息

chrome.runtime.sendMessage({}, callback)
chrome.tabs.sendMessage({}, callback)

注入脚本 (通过 <script> 注入到页面内的脚本) 和插件之间通过 postMessage() 发送消息:

window.possMessage({}, '*')

通常来说,我们可以发送任意结构的数据。如果你的扩展程序只包含几个小功能,这不是问题。但如果你的扩展程序功能较多,业务逻辑比较复杂,随着功能的累积,你的程序中会有各种事件用于处理不同的通讯逻辑,很快你就会迷失在不同的事件中,也不利于调试。

为此,我们可以做两件事来规范发送消息的方式。

首先提取事件类型常量,为每一种事件定义一个常量:

// services/constants.js
const ACTIVE_CHAT_EVENT = 'ACTIVE_CHAT_EVENT'
const CHAT_LIST_EVENT = 'CHAT_LIST_EVENT'

其次我们约定发送的消息数据的结构,必须包含如下字段:

  • messageId
  • from
  • to
  • type
  • timestamp
  • payload
chrome.runtime.sendMessage(
  {
    messageId: 'uuid',
    from: 'inject',
    to: 'content',
    type: ACTIVE_CHAT_EVENT,
    timestamp: new Date().getTime(),
    payload: {},
  },
  callback
)

如果每次发送消息都要手写这么一大段,确实很麻烦。那么,是时候封装一个发送消息的方法了。

export const sendMessage = (data) => {
    // Doing something
}

消息管理

如何系统地处理事件是一个需要好好琢磨的问题。对于扩展插件内部组件的消息监听:

chrome.runtime.onMessage.addListener(function(request, sender) {
  // Doing something
})

注入脚本和插件之间,我们通过 window.addEventListner(callback) 来监听:

window.addEventListener('message', function({ data }) {
  // Doing something
})

以注入脚本和contentScript之间的通讯来举例。假设我们需要从注入脚本发送一个 ACTIVE_CHAT_EVENT 类型的消息,在 content script 内的两个地方需要消费 activeChat 数据:

// injectScript.js
window.postMessage(
  {
    type: ACTIVE_CHAT_EVENT,
    payload: { activeChat: '18688886666@c.us' },
  },
  '*'
)

// contentScript - Navbar.vue
window.addEventListener('message', function ({ data }) {
  if (data.type === ACTIVE_CHAT_EVENT) {
    // Doing something
  }
})

// contentScript - Note.vue
window.addEventListener('message', function ({ data }) {
  if (data.type === ACTIVE_CHAT_EVENT) {
    // Doing something
  }
})

对于 ACTIVE_CHAT_EVENT 类型的消息,我们就在不同位置添加了两个监听器。

假如我们还有 CHAT_LIST_EVENT 类型的消息从注入脚本发送到 content script,也有两个地方需要这个数据:

// injectScript.js
window.postMessage(
  {
    type: CHAT_LIST_EVENT,
    payload: { activeChat: '18688886666@c.us' },
  },
  '*'
)

// contentScript - Note.vue
window.addEventListener('message', function ({ data }) {
  if (data.type === CHAT_LIST_EVENT) {
    // Doing something
  }
})

// contentScript - ChatList.vue
window.addEventListener('message', function ({ data }) {
  if (data.type === CHAT_LIST_EVENT) {
    // Doing something
  }
})

此时,content script 中就有两种类型(ACTIVE_CHAT_EVENT 和 CHAT_LIST_EVENT)的4个事件监听器。你也许会说,在 Note.vue 内我可以用一个事件监听器来处理这两种类型:

// injectScript.js
window.postMessage(
  {
    type: ACTIVE_CHAT_EVENT,
    payload: { activeChat: '18688886666@c.us' },
  },
  '*'
)

window.postMessage(
  {
    type: CHAT_LIST_EVENT,
    payload: { activeChat: '18688886666@c.us' },
  },
  '*'
)

// contentScript - Navbar.vue
window.addEventListener('message', function ({ data }) {
  if (data.type === ACTIVE_CHAT_EVENT) {
    // Doing something
  }
})

// contentScript - Note.vue
window.addEventListener('message', function ({ data }) {
  if (data.type === CHAT_LIST_EVENT) {
    // Doing something
  } else if (data.type === ACTIVE_CHAT_EVENT) {
    // Doing something
  }
})

// contentScript - ChatList.vue
window.addEventListener('message', function ({ data }) {
  if (data.type === CHAT_LIST_EVENT) {
    // Doing something
  }
})

这样,content script 有3个事件监听器来处理两种类型的事件,只是简化了一点而已。

随着你的插件越来越复杂,当你需要的事件类型越来越多的时候,问题就暴露出来了。主要有两个问题:

其一,在某个位置你的监听器可能要处理多个类型的事件,这时它就变得臃肿难以阅读,变得难以管理:

// contentScript - Note.vue
window.addEventListener('message', function ({ data }) {
  if (data.type === CHAT_LIST_EVENT) {
    // Doing something
  } else if (data.type === ACTIVE_CHAT_EVENT) {
    // Doing something
  } else if (data.type === NEW_MESSAGE_EVENT) {
    // Doing something
  } else if (data.type === UNREAD_MESSAGE_EVENT) {
    // Doing something
  } ... else if (...) {
    // Doing something
  }
})

由于每个 if 分支内有业务处理代码,这个监听器可能非常长。使用 switch/case 也并不会简化这个监听器。提取处理方法呢:

// contentScript - Note.vue
window.addEventListener('message', function ({ data }) {
  if (data.type === CHAT_LIST_EVENT) {
    chatListHandler(data)
  } else if (data.type === ACTIVE_CHAT_EVENT) {
    activeChatHandler(data)
  } else if (data.type === NEW_MESSAGE_EVENT) {
    newMessageHandler(data)
  } else if (data.type === UNREAD_MESSAGE_EVENT) {
    unreadMessageHandler(data)
  } ... else if (...) {
    otherHandler(data)
  }
})

它同样没有简化问题,只是减小了她的体积。当你添加新功能增加时间处理时,你仍然需要在这个事件监听器中添加 if 处理分支,定义一个处理了方法。在阅读时,还需要跳跃去寻找这个处理方法。

有没有更好更优雅的方式去处理呢。这里我的处理方式,大家可以借鉴一下。

// bus.js
import handlers from './handlers'

window.addEventListener('message', function ({ data }) {
  const eventType = data.type
  const handler = handlers[eventType]
  if (!handler) {
    console.log(`No handler found for event type: ${eventType}`)
    return
  }

  handler(data)
})
// handlers.js
import {
  CHAT_LIST_EVENT,
  ACTIVE_CHAT_EVENT,
  NEW_MESSAGE_EVENT,
  UNREAD_MESSAGE_EVENT,
} from '@/services/constants'

function chatListHandler(data) {
  // Do something
}

function activeChatHandler(data) {
  // Do something
}

function unreadMessageHandler(data) {
  // Do something
}

function otherHandler(data) {
  // Do something
}

export default {
  [CHAT_LIST_EVENT]: chatListHandler,
  [ACTIVE_CHAT_EVENT]: activeChatHandler,
  [NEW_MESSAGE_EVENT]: newMessageHandler,
  [UNREAD_MESSAGE_EVENT]: unreadMessageHandler,
}

我们将事件处理方法和事件的关联关系单独提取出来作为一个 handlers.js 模块,bus.js 中的事件监听器作为一个事件处理中心,负责将不同类型的事件分发给相应的处理函数。