我在之前的文章做过使用Vue3做Chrome插件开发的实战分享,相关话题已经很久没更新了,是时候开启我们的优化篇
了。如下是关联上下文链接任意门:
- 开篇:《Vue3 浏览器插件开发脚手架推荐:vitesse-webext 全解析》,重点介绍了vitesse-webext脚手架特性及试用情况
- 导入:《【实战教程】用 Vue3 开发浏览器插件:一键下载电商详情图片和视频》,重点介绍了基于vitesse-webext脚手架从0到1开发一个自动下载主流电商详情图片视频等资源这样一个浏览器插件的全过程
1 思考
1.1 插件当前存在的问题
调用相关API模拟用户点击、hover及scroll等行为虽能提取到目标数据,但存在如下问题:
- 用户使用体验不佳
- 可见的“自动化操作”反倒会增加一些不信任感
- 与用户正常浏览器操作冲突可能出现不可预知问题
1.2 可选优化方案
方案编号 | 方案名称 | 优点 | 缺点 | 是否被选中 |
---|---|---|---|---|
方案1 | Offscreen API | ① 插件官方支持。② 只需授权即可使用。 | ① 仅支持“静态”页面数据提取。② 对懒加载或需交互加载的数据无能为力。 | 否 |
方案2 | 服务端爬虫开放接口 | ① 灵活选型:支持多种爬虫框架。② 可模拟用户行为抓取任意数据。③ 插件仅需请求服务端接口,简化前端逻辑。 | ① 需单独维护服务端代码,维护成本较高。② 无法随插件打包发布,部署复杂度上升。 | 否 |
方案3 | 隐藏窗口及 Tab 静默模拟操作(✅最终选型) | ① 使用浏览器已有 API,无需引入额外依赖。② 可模拟用户点击、滚动等操作,提取懒加载或动态内容。③ 不依赖服务端,部署和维护相对简单。 | ① 操作过程中用户可能偶尔感知页面切换(但多数情况下可接受)。② 会稍微占用多一些系统资源和时间,但整体性能影响可控。 | ✅ 是 |
1.3 最终选型及设计思路
1.3.1 最终选型
- 隐藏窗口及 Tab 静默模拟操作
1.3.2 设计思路
保留两种模式,用户根据需要灵活选择
- 常规模式:保持《【实战教程】用 Vue3 开发浏览器插件:一键下载电商详情图片和视频》实现的功能不变——也即在打开的tab中直接模拟用户点击、scroll或hover行为获取完整数据,用户对相关操作可见。
- 无痕模式:也即此次优化讨论重点,以新建隐藏窗口及tab静默打开目标页面模拟用户行为静默操作获取数据,用户对相关操作基本无感知。
2 实战
2.1 manifest新增权限:
- 修改src\manifest.ts权限
- 新增
storage
权限:用于持久化记录用户对模式的切换 - 新增
scripting
权限,用于向新建的隐藏的窗口及tab注入content-script js代码,此为优化方案实现的关键
- 新增
...
permissions: [
'tabs',
'storage',
'activeTab',
'notifications',
'downloads',
'scripting',
],
...
2.2 优化下载控制器
- 优化的原因是需适配无痕模式及常规两种模式可选,通过是否传递sourceContentTabId作区分,由于无痕模式涉及跨tab通信,隐藏tab操作的结果需要经过background回显给正常的tab,sourceContentTabId是关键钥匙
import { Hostname } from "~/constants";
import { Result, PlainSettings, RequestData } from "~/types/schemas";
import { getPageUrl } from "~/utils/common";
import { sendMessage } from 'webext-bridge/content-script'
import { useCollectAliData } from "./useCollectAliData";
import { useCollectTmallData } from "./useCollectTmallData";
import { useCollectJDData } from "./useCollectJDData";
const useDownloadController = (
settings: PlainSettings, sourceId?: number) => {
// content---->background推送下载打包消息
const sendToBackground = (data: Result) => {
const requestData: RequestData = {
data,
...(sourceId !== undefined && { sourceId }), // ✅ 有则加,没有就不传
}
sendMessage(
'download-resources',
requestData,
'background'
)
}
// downloadSubmitHandle(将下载任务提交到后台)
const downloadHandle = async () => {
try {
let result: Result = {}
const pageUrl = getPageUrl()
const uri = new URL(pageUrl)
const hostname = uri.hostname
const { processData: processAliData } = useCollectAliData(pageUrl)
const { processData: processTmallData } = useCollectTmallData(pageUrl)
const { processData: processJDData } = useCollectJDData(pageUrl)
if (Hostname.ALI === hostname) {
result = await processAliData(
settings
)
} else if (Hostname.TMALL === hostname || Hostname.TAOBAO === hostname) {
result = await processTmallData(
settings
)
} else if (Hostname.JD === hostname) {
result = await processJDData(
settings
)
}
// 提交后台下载打包消息提示
await sendToBackground(result)
} catch (error) {
console.log("页面解析出现异常:", error)
}
}
return {
downloadHandle
}
}
export default useDownloadController
2.3 无痕模式代理代码
2.3.1 隐藏窗口及tab需要动态注入的agent.js
- 涉及文件及路径:
src\contentScripts\agent.ts
- 代理的职责如下
- 监听background发送的消息,解析获取源tab经background中转过来的关键信息如url、sourceContentTabId、用户自定义配置信息等(注意:content与content之间不能直接通信);
- 监听页面关键元素,条件具备才触发调用下载控制器操作,避免空转,否则即使收到了background发送的参数同步消息,仍需等待
import { onMessage } from 'webext-bridge/content-script'
import useDownloadController from '~/composables/useDownloadController'
import { generateObserverTargetNodeRule } from '~/utils/common';
// 防止SPA异步渲染导致数据无法抓取
function waitForRenderReady(nodeRule: string): Promise<void> {
return new Promise((resolve) => {
const targetNode = document.querySelector(nodeRule)
if (targetNode) {
return resolve();
}
const observer = new MutationObserver(() => {
const targetNode = document.querySelector(nodeRule)
if (targetNode) {
console.log('✅ Agent页面渲染完成,开始抓取数据');
observer.disconnect();
resolve();
}
});
observer.observe(document, { subtree: true, childList: true });
});
}
// 监听参数同步消息通知
onMessage('agent-param-sync', async (data) => {
let shouldSkip = false
const { url, settings, sourceId } = data.data;
console.log('获取到的sourceId======>', sourceId)
if (!sourceId) {
console.warn("代理tab没有获取到目标tabId");
shouldSkip = true
}
try {
if (!shouldSkip) {
await waitForRenderReady(generateObserverTargetNodeRule(url))
const { downloadHandle } = useDownloadController(settings, sourceId!);
await downloadHandle();
}
} catch (error) {
console.error('下载过程中发生错误:', error);
}
});
2.3.2 配置无痕模式agent.js vite config
- 涉及文件及路径:
vite.config.content.agent.mts
- 对vite编译进行约束及规范,确定输入及输出,确保编译输出到指定位置指定文件:
extension/dist/contentScripts/agent.global.js
,该文件路径非常重要,是隐藏模式content-script注入的关键代码
import { defineConfig } from 'vite'
import { sharedConfig } from './vite.config.mjs'
import { isDev, r } from './scripts/utils'
import packageJson from './package.json'
// bundling the content script using Vite
export default defineConfig({
...sharedConfig,
define: {
'__DEV__': isDev,
'__NAME__': JSON.stringify(packageJson.name),
// https://github.com/vitejs/vite/issues/9320
// https://github.com/vitejs/vite/issues/9186
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),
},
build: {
watch: isDev
? {}
: undefined,
outDir: r('extension/dist/contentScripts'),
cssCodeSplit: false,
emptyOutDir: false,
sourcemap: isDev ? 'inline' : false,
lib: {
entry: r('src/contentScripts/agent.ts'),
name: packageJson.name,
formats: ['iife'],
},
rollupOptions: {
output: {
entryFileNames: 'agent.global.js',
extend: true,
},
},
},
})
2.3.3 package.json新增agent.js相关编译行为
- 涉及文件及路径:
package.json
- 确保
无痕模式
相关代码在dev
及build
时都能被准确编译到指定位置,方便依赖及调用,避免异常
...
"scripts": {
...
"dev": "npm run clear && cross-env NODE_ENV=development run-p dev:*",
"dev:js": "run-p dev:js:main dev:js:agent",
"dev:js:main": "vite build --config vite.config.content.mts --mode development",
"dev:js:agent": "vite build --config vite.config.content.agent.mts --mode development",
"build": "cross-env NODE_ENV=production run-s clear build:web build:prepare build:background build:js",
"build:js": "run-p build:js:main build:js:agent",
"build:js:main": "vite build --config vite.config.content.mts",
"build:js:agent": "vite build --config vite.config.content.agent.mts",
...
},
...
2.3.4 manifest中content-script增加agent.js
- 确保无痕模式下agent.js获得content相关权限,例如能够解析并操作DOM
content_scripts: [
{
matches: [
// 1688
"*://detail.1688.com/*",
// 天猫
"*://detail.tmall.com/*",
// 淘宝
"*://item.taobao.com/*",
// 京东
"*://item.jd.com/*",
],
js: [
'dist/contentScripts/index.global.js',
'dist/contentScripts/agent.global.js',
],
css: [
'dist/contentScripts/style.css'
]
},
],
2.3.5 content与background、content与content(经background中转)通信规约
2.3.5.1 webext-bridge消息通道
- 涉及文件及路径:
shim.d.ts
.d.ts
是 TypeScript 的类型声明文件,shim
表示“填补”某些模块或类型的声明,常用于增强已有模块的类型系统。shim.d.ts
具有如下特点,无论处于开发提效考虑还是规范性考虑还是代码可读性考虑,webext-bridge消息通道在shim.d.ts
进行管理及拓展都可以认为是最佳实践:- 不需要自动导入,自动生效
- 会有类型提示
- 可以自行添加自己的协议
import type { ProtocolWithReturn } from 'webext-bridge'
import { RequestData, AgentRequestData, ResponseData, SimpleStatus } from '~/types/schemas'
declare module 'webext-bridge' {
export interface ProtocolMap {
'download-resources': RequestData,
'start-agent': AgentRequestData,
'agent-param-sync': AgentRequestData
}
}
- 另附消息通道中使用到的数据类型或者数据结构
- 涉及文件及路径:
src\types\schemas.ts
- 涉及文件及路径:
// 请求结构体
export interface RequestData {
data: Result,
sourceId?: number,
}
// 代理请求结构体
export interface AgentRequestData {
url: string
settings: PlainSettings
sourceId?: number
}
2.3.5.2 浏览器原生消息通道
2.3.5.2.1 问题:既然脚手架集成好了webext-bridge
为什么还要定义原生消息通道呢?
- 原因:当同时存在多个content时,
webext-bridge
指定content tabId进行通信时存在bug(无法正常通信),后续在避坑填坑中会详解。
2.3.5.2.2 操作步骤
- 涉及文件及路径:
src\types\native-message-protocol.ts
import { ResponseData, SimpleStatus } from '~/types/schemas'
export enum NativeMessageType {
JOB_STATE_NOTIFY = 'job-state-notify',
START_AGENT = 'start-agent'
}
export interface NativeMessageMap {
[NativeMessageType.JOB_STATE_NOTIFY]: ResponseData,
[NativeMessageType.START_AGENT]: {
sourceId: number | undefined,
status: SimpleStatus
}
}
- 附
native-message-protocol
用到的数据类型或数据结构- 涉及文件及路径:
src\types\schemas.ts
- 涉及文件及路径:
// 响应结构体
export interface ResponseData {
status: number,
message: string
detailMessage?: string
}
// 请求结构体
export interface RequestData {
data: Result,
sourceId?: number,
}
// 代理请求结构体
export interface AgentRequestData {
url: string
settings: PlainSettings
sourceId?: number
}
export enum SimpleStatus {
SUCCESS,
FAILED
}
- 定义原生消息工具函数
src\utils\native-message.ts
- 适当简化原生消息发送及接收的过程
import { NativeMessageMap } from '~/types/native-message-protocol'
export function sendNativeMessage<K extends keyof NativeMessageMap>(
type: K,
data: NativeMessageMap[K],
tabId?: number
): Promise<any> {
const payload = {
type, data
}
if (tabId != null) {
return browser.tabs.sendMessage(tabId, payload)
} else {
return browser.runtime.sendMessage(payload)
}
}
2.3.6 优化Popup支持常规模式、无痕模式选择并“记住”选择
- 涉及文件及路径:
src\popup\Popup.vue
- 重点是引入了脚手架已经集成好的
useWebExtensionStorage
,响应式变量,已经实现了读写及自动刷新,可以直接调用
<script setup lang="ts">
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage'
import { Item, ModeType } from '~/types/schemas'
const extractionMode = useWebExtensionStorage<ModeType>(
'extraction-mode',
'hidden'
)
const supportList: Item[] = [
{
id: '1',
name: '1688',
status: true
},
{
id: '2',
name: '天猫商城',
status: true
},
{
id: '3',
name: '淘宝商城',
status: true
},
{
id: '4',
name: '京东商城',
status: true
},
{
id: '5',
name: '1688跨境',
status: false
},
{
id: '6',
name: '拼多多',
status: false
}
]
const version: string = 'v1.1.0'
</script>
<template>
<main>
<div class="bg-white box-shadow px-[24px] py-[20px] w-max">
<!-- ✅ 新增提取模式切换区域 -->
<div class="mb-4">
<span class="block font-bold mb-2.5" text="[14px] gray-500"
>模式选择</span
>
<div class="flex gap-x-4">
<label
class="cursor-pointer flex items-center gap-x-1"
:class="{
'text-brand font-600': extractionMode === 'visible',
'text-gray-300': extractionMode !== 'visible'
}"
>
<input
type="radio"
value="visible"
v-model="extractionMode"
class="w-4 h-4 rounded-full border border-gray-400 checked:border-4 checked:border-brand checked:bg-white appearance-none cursor-pointer"
/>
常规模式
</label>
<label
class="cursor-pointer flex items-center gap-x-1"
:class="{
'text-brand font-600': extractionMode === 'hidden',
'text-gray-300': extractionMode !== 'hidden'
}"
>
<input
type="radio"
value="hidden"
v-model="extractionMode"
class="w-4 h-4 rounded-full border border-gray-400 checked:border-4 checked:border-brand checked:bg-white appearance-none cursor-pointer"
/>
无痕模式
</label>
</div>
</div>
<span class="block font-bold mb-2.5" text="[14px] gray-500"
>支持列表展示</span
>
<div class="flex-col-center gap-y-1">
<SupportItem
v-for="item in supportList"
:key="item.id"
:name="item.name"
:status="item.status"
></SupportItem>
</div>
<div class="text-gray-300 font-[5px] text-center mt-[16px]">
版本号:{{ version }}
</div>
</div>
</main>
</template>
2.3.7 优化Content根据用户选择的模式调用“不同的”资源下载控制器
- 涉及文件及路径:
src\contentScripts\views\App.vue
- 主要新增或优化了这些功能:
- 双击下载时实时获取用户选择的模式,根据选择模式的不同地处理流程
- 常规模式:content---->模拟用户行为后解析获取数据---->通知background下载---->状态同步content
- 无痕模式:content---->关键信息同步background,background创建隐藏窗口及tab---->隐藏窗口及tab模拟用户行为后解析数据---->通知background下载,关闭隐藏窗口及tab---->状态同步content
- 双击下载时实时获取用户选择的模式,根据选择模式的不同地处理流程
<script setup lang="ts">
import 'uno.css'
import { useMouseInElement } from '@vueuse/core'
import { useDrag } from '~/composables/useDrag'
import { useSwitchGroups } from '~/composables/useSwitchGroups'
import { useToast } from '~/composables/useToast'
import useDownloadController from '~/composables/useDownloadController'
import { useStaticClickOutside as useClickOutside } from '~/composables/useClickOutside'
import { Settings, StatusType } from '~/types/schemas'
import { sendMessage } from 'webext-bridge/content-script'
import { getPageUrl, plainSettings } from '~/utils/common'
import { NotificationMessage } from '~/constants'
import browser from 'webextension-polyfill'
import {
NativeMessageMap,
NativeMessageType
} from '~/types/native-message-protocol'
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage'
import type { ModeType } from '~/types/schemas'
// 任务状态
const taskStatus = ref<StatusType>(StatusType.INIT)
// 是否关闭悬浮按钮(包含设置面板)
const isClose = ref(false)
// 是否展示设置面板
const isShowSettingsCard = ref(false)
// 悬浮按钮
const floatingButtonRef = ref()
// 设置面板
const settingsCardRef = ref()
// 设置按钮
const settingsButtonRef = ref()
// 关闭按钮
const closeButtonRef = ref()
// 创建Settings接口对象,并设置默认值
const settings: Settings = {
isReadMe: ref(true),
isMainVideo: ref(true),
isMainImages: ref(true),
isSkus: ref(true),
isDetailImages: ref(true),
isDetailVideo: ref(false)
}
const defaultToastStyle = 'min-h-[36px] min-width-[60px] px-[24px] py-[18px]'
// 设置面板具体的配置项
const { switchGroups } = useSwitchGroups(settings)
// toast hook
const { showToastWithDelay: showToast } = useToast(defaultToastStyle)
// 关闭悬浮按钮Handle
const closeHandle = (event: MouseEvent) => {
if (!isShowSettingsCard.value) {
isClose.value = true
} else {
isShowSettingsCard.value = false
}
event.stopPropagation()
}
// useMouseInElement监听鼠标是否移出元素外部
const { isOutside, stop: stopMouseTracker } =
useMouseInElement(floatingButtonRef)
// 是否展开
const isExpand = computed(() => isShowSettingsCard.value || !isOutside.value)
// 设置按钮点击处理函数
const settingsShowHandle = (event: MouseEvent) => {
isShowSettingsCard.value = !isShowSettingsCard.value
event.stopPropagation()
}
// 可拖拽功能调用
const { y, adaptiveStyles } = useDrag(floatingButtonRef)
// 获取用户选择的模式
const extractionMode = useWebExtensionStorage<ModeType>(
'extraction-mode',
'hidden'
)
const downloadHandle = async () => {
taskStatus.value = StatusType.PROCESSING
await showToast(NotificationMessage.STARTING)
if (extractionMode.value === 'hidden') {
console.log('用户当前选择的是hidden模式')
const data = {
url: getPageUrl(),
settings: plainSettings(settings)
}
sendMessage('start-agent', data, 'background')
} else {
console.log('用户当前选择的是visible模式')
const { downloadHandle } = useDownloadController(plainSettings(settings))
await downloadHandle()
}
}
useClickOutside(
settingsCardRef,
() => {
if (isShowSettingsCard.value) {
isShowSettingsCard.value = false
}
},
isShowSettingsCard,
[settingsButtonRef, closeButtonRef]
)
const nativeMessageHandle = (
message: unknown,
sender: browser.Runtime.MessageSender,
sendResponse: (response?: any) => void
) => {
// First, verify the message has the expected structure
if (typeof message === 'object' && message !== null && 'type' in message) {
const typedMessage = message as {
type: keyof NativeMessageMap
data: any
}
if (typedMessage.type === NativeMessageType.JOB_STATE_NOTIFY) {
const data = typedMessage.data
const handleNotification = async () => {
switch (data.status) {
case 1001:
await showToast(NotificationMessage.SUBMITTING)
break
case 1002:
await showToast(NotificationMessage.PACKAGING)
break
case 1003:
taskStatus.value = StatusType.FINISHED
await showToast(NotificationMessage.DOWNLOAD_COMPLETED, 'success', {
duration: 15000,
closeButton: true
})
break
case 1004:
taskStatus.value = StatusType.FAILED
await showToast(NotificationMessage.CONTROLLER_ERROR, 'error', {
duration: 15000,
closeButton: true
})
break
}
sendResponse({ status: 'ok' })
}
handleNotification()
return true
}
}
// Return undefined for messages we don't handle
return undefined
}
browser.runtime.onMessage.addListener(nativeMessageHandle)
onUnmounted(() => {
browser.runtime.onMessage.removeListener(nativeMessageHandle)
stopMouseTracker()
})
console.log('Referrer:', document.referrer)
console.log('Navigator.webdriver:', navigator.webdriver)
</script>
<template>
<div
ref="floatingButtonRef"
class="z-99999 max-h-[100px] fixed right-0"
v-if="!isClose"
:style="{ top: `${y}px` }"
>
<!-- 设置弹出面板 -->
<div
ref="settingsCardRef"
class="bg-white box-shadow px-[24px] py-[20px] flex flex-col gap-y-4 rounded-[10px] w-max h-max max-h-[270px] absolute right-[68px]"
:style="adaptiveStyles"
:class="[isShowSettingsCard ? 'visible' : 'invisible']"
>
<div v-for="(item, idx) in switchGroups" :key="idx">
<span class="block font-bold mb-2.5" text="[14px] gray-500">{{
item.name
}}</span>
<div class="grid grid-cols-1 gap-y-1.5">
<SwitchWithLabel
:label="detailItem.name"
v-model="detailItem.status.value"
v-for="detailItem in item.children"
/>
</div>
</div>
</div>
<!-- 按钮面板 -->
<div class="flex flex-col items-end gap-y-2">
<!-- 关闭按钮 -->
<CloseButton
ref="closeButtonRef"
:is-expand="isExpand"
:is-hidden-tooltip="!isShowSettingsCard"
@update:close-action="closeHandle"
label="关闭悬浮按钮"
/>
<!-- 下载按钮 -->
<DownloadButton
:is-expand="isExpand"
:is-hidden-tooltip="!isShowSettingsCard"
:task-status="taskStatus"
@update:download-submit="downloadHandle"
/>
<!-- 设置按钮 -->
<SettingsButton
:is-expand="isExpand"
:is-hidden-tooltip="!isShowSettingsCard"
label="点击设置"
@update:show-settings-card="settingsShowHandle"
ref="settingsButtonRef"
class="relative"
/>
</div>
</div>
</template>
2.3.8 优化Background支持两种模式操作
- 涉及文件及路径:
src\background\main.ts
import { onMessage, sendMessage } from 'webext-bridge/background'
import { showNotification } from '~/utils/common'
import useDownloadService from '~/composables/useDownloadService';
import { sendNativeMessage } from '~/utils/native-message';
import { NativeMessageType } from '~/types/native-message-protocol';
// only on dev mode
if (import.meta.hot) {
// @ts-expect-error for background HMR
import('/@vite/client')
// load latest content script
import('./contentScriptHMR')
}
browser.runtime.onInstalled.addListener((): void => {
// eslint-disable-next-line no-console
console.log('Extension installed')
})
onMessage('download-resources', async (data) => {
const sourceData = data.data.data
const senderId = data.sender.tabId
const sourceId = data.data.sourceId
const contentTabId = sourceId ? sourceId : senderId
try {
await sendNativeMessage(NativeMessageType.JOB_STATE_NOTIFY, {
status: 1001,
message: '提交后台处理中',
}, contentTabId);
const { downloadSourceAsZip } = useDownloadService();
console.log('sourceData===================>', sourceData)
await sendNativeMessage(NativeMessageType.JOB_STATE_NOTIFY, {
status: 1002,
message: '资源打包中',
}, contentTabId);
await downloadSourceAsZip(sourceData)
showNotification({ title: '资源下载成功', message: "请移步系统默认下载目录查验" });
await sendNativeMessage(NativeMessageType.JOB_STATE_NOTIFY, {
status: 1003,
message: '资源下载成功',
}, contentTabId);
} catch (error) {
console.log("后端出现异常:", error)
// 处理 error 的类型问题
const errorMessage = error instanceof Error ? error.message : '未知错误';
await browser.tabs.sendMessage(contentTabId, {
type: "notification",
data: {
code: 1004,
message: "失败请重试",
detailMessage: errorMessage
},
})
}
if (senderId && senderId !== sourceId) {
try {
const tab = await chrome.tabs.get(senderId)
const windowId = tab.windowId
await chrome.windows.remove(windowId)
console.log(`Window ${windowId} closed`)
} catch (err) {
console.warn('Failed to close window:', err)
}
}
})
// 监听代理启用消息通知
onMessage('start-agent', async (data) => {
const agentArgs = data.data
const { url } = agentArgs
const sourceId = data.sender.tabId
chrome.windows.create({
url,
type: 'popup',
state: "minimized"
}, (win) => {
if (!win || !win.tabs || !win.tabs[0]) {
console.warn('Failed to create window or retrieve tab');
return;
}
const tabId = win.tabs[0].id!;
if (!tabId) return;
chrome.tabs.onUpdated.addListener(function listener(updatedTabId, info) {
if (updatedTabId === tabId && info.status === 'complete') {
chrome.tabs.onUpdated.removeListener(listener);
chrome.scripting.executeScript({
target: { tabId },
files: [
'./dist/contentScripts/agent.global.js'
]
}, () => {
sendMessage('agent-param-sync', { ...agentArgs, sourceId }, { context: 'content-script', tabId })
});
}
});
});
})
3 填坑避坑
3.1 脚手架集成好了webext-bridge
为什么还要用原生信息通道?
- 这个其实是不得已为之的,
webext-bridge
存在bug(官方已确认,修复时间为止),具体表现在存在多个content时background指定content tabId发送消息时出现异常,感兴趣的小伙伴可以查询相关联的issues核实,这里截取几段其他人的问题描述:
- 经测试原生API是没有问题的
- 后续可以关注这个问题修复
4 历史遗留Bug修复:个别页面rem不为16px导致UnoCSS基于rem样式“崩溃”问题
4.1 基础知识
4.1.1 content样式
- 仔细阅读
vite.config.content.mts
配置,vite会将使用到的(import)合并打包到一个样式文件中,也就是说此处给我们提供了一个修改脚手架样式的切口
import { defineConfig } from 'vite'
import { sharedConfig } from './vite.config.mjs'
import { isDev, r } from './scripts/utils'
import packageJson from './package.json'
// bundling the content script using Vite
export default defineConfig({
...sharedConfig,
define: {
'__DEV__': isDev,
'__NAME__': JSON.stringify(packageJson.name),
// https://github.com/vitejs/vite/issues/9320
// https://github.com/vitejs/vite/issues/9186
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),
},
build: {
watch: isDev
? {}
: undefined,
outDir: r('extension/dist/contentScripts'),
cssCodeSplit: false,
emptyOutDir: false,
sourcemap: isDev ? 'inline' : false,
lib: {
entry: r('src/contentScripts/index.ts'),
name: packageJson.name,
formats: ['iife'],
},
rollupOptions: {
output: {
entryFileNames: 'index.global.js',
extend: true,
},
},
},
})
4.1.2 shadowDOM
的形式注入的,shadowDOM
可以拥有自己“独立”的样式,只要让
- 仔细阅读
src\contentScripts\index.ts
,content是以shadowDOM
的形式注入的,shadowDOM
可以拥有自己“独立”的样式,只要让shadowDOM
不受宿主干扰就能解决问题。
/* eslint-disable no-console */
import { createApp } from 'vue'
import App from './views/App.vue'
import { setupApp } from '~/logic/common-setup'
import { Toaster } from 'vue-sonner';
// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value
(() => {
// mount component to context window
const container = document.createElement('div')
container.id = __NAME__
const root = document.createElement('div')
const styleEl = document.createElement('link')
// 特别需要注意此处
const shadowDOM = container.attachShadow?.({ mode: __DEV__ ? 'open' : 'closed' }) || container
styleEl.setAttribute('rel', 'stylesheet')
styleEl.setAttribute('href', browser.runtime.getURL('dist/contentScripts/style.css'))
shadowDOM.appendChild(styleEl)
shadowDOM.appendChild(root)
document.body.appendChild(container)
const app = createApp(App)
// 挂载 vue-sonner 的 Toaster 组件到全局 document.body
const toasterContainer = document.createElement('div');
toasterContainer.id = 'vue-sonner-container';
document.body.appendChild(toasterContainer);
const toasterApp = createApp({
render: () => h(Toaster, {
richColors: true, position: 'top-center'
}),
});
toasterApp.mount(toasterContainer);
setupApp(app)
app.mount(root)
})()
4.2 解决办法
4.2.1 新建src\styles\contentStyle.css
html,
:host,
:root {
font-size: 16px !important;
}
4.2.2 修改src\contentScripts\index.ts
- 增加导入
import '~/styles/contentStyle.css'
/* eslint-disable no-console */
import { createApp } from 'vue'
import App from './views/App.vue'
import { setupApp } from '~/logic/common-setup'
import { Toaster } from 'vue-sonner';
import '~/styles/contentStyle.css'
// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value
(() => {
...
})()
5 Content交互UI简单升级
- 涉及文件及路径:src\components\DownloadButton.vue
- 基于状态更新下载按钮的图标:可在默认(默认下载图标)、进行中(默认下载图标替换为gif圆圈)、成功(显示绿色成功角标)、失败(显示红色失败角标)四种状态切换
<template>
<HoverTooltip :status="showTooltip" label="双击下载资源">
<div
@dblclick="dblclickHandle"
ref="downloadButtonRef"
class="cursor-pointer h-[18px] p-y-[8px] p-x-[2px] rounded-tl-full rounded-bl-full box-shadow flex flex-row justify-start items-center"
:class="[
isExpand ? 'w-[48px] opacity-100' : 'w-[36px] opacity-[.68]',
statusStyle
]"
>
<LoadingIcon
v-if="taskStatus === StatusType.PROCESSING"
class="text-white text-[18px] pl-2.5 p-y-1"
></LoadingIcon>
<div v-else class="flex items-center">
<eva-cloud-download-outline
class="text-white text-[18px] pl-2.5 p-y-1 relative"
/>
<Transition name="fade">
<weui-done2-filled
class="absolute bottom-[3px] text-[#07C160] text-[9px] font-bold"
:class="[isExpand ? 'left-[50%]' : 'left-[66%]']"
v-if="taskStatus === StatusType.FINISHED"
></weui-done2-filled>
</Transition>
<Transition name="fade"
><lets-icons-close-round-fill
class="absolute bottom-[3px] text-[#FA5151] text-[9px] font-bold"
:class="[isExpand ? 'left-[50%]' : 'left-[66%]']"
v-if="taskStatus === StatusType.FAILED"
></lets-icons-close-round-fill
></Transition>
</div>
</div>
</HoverTooltip>
</template>
<script setup lang="ts">
import { useMouseInElement } from '@vueuse/core'
import { StatusType } from '~/types/schemas'
type withSubmitActionButtonProps = {
label?: string
isExpand: boolean
isHiddenTooltip: boolean
taskStatus: StatusType
}
const props = withDefaults(defineProps<withSubmitActionButtonProps>(), {
isExpand: true,
isHiddenTooltip: false,
taskStatus: StatusType.INIT
})
const downloadButtonRef = ref()
const { isOutside } = useMouseInElement(downloadButtonRef)
const showTooltip = computed(
() => props.isHiddenTooltip && props.isExpand && !isOutside.value
)
const emit = defineEmits(['update:downloadSubmit'])
const dblclickHandle = () => {
emit('update:downloadSubmit')
}
const statusStyle = computed(() => {
return props.taskStatus === StatusType.PROCESSING
? 'bg-gray cursor-not-allowed pointer-events-none'
: 'bg-gradient-to-r from-brand to-brand-secondary'
})
</script>
以上就是自动下载主流电商图片及视频插件功能优化简要过程,项目已开源,欢迎交流:
- Gitee:gitee.com/cy4010/squi…
- Github:github.com/chengyong40…