我们知道 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 的逻辑。 这种思考方式是一种反向思维。在开发中,正向思维和方向思维灵活使用,你会遇见不一样的风景。
消息通讯
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
中的事件监听器作为一个事件处理中心,负责将不同类型的事件分发给相应的处理函数。