Vue3 浏览器插件优化全纪录:新增无痕模式,跨Tab通信与交互体验再升级

112 阅读14分钟

我在之前的文章做过使用Vue3做Chrome插件开发的实战分享,相关话题已经很久没更新了,是时候开启我们的优化篇了。如下是关联上下文链接任意门:

1 思考

1.1 插件当前存在的问题

调用相关API模拟用户点击、hover及scroll等行为虽能提取到目标数据,但存在如下问题:

  • 用户使用体验不佳
  • 可见的“自动化操作”反倒会增加一些不信任感
  • 与用户正常浏览器操作冲突可能出现不可预知问题

1.2 可选优化方案

方案编号方案名称优点缺点是否被选中
方案1Offscreen API① 插件官方支持。② 只需授权即可使用。① 仅支持“静态”页面数据提取。② 对懒加载或需交互加载的数据无能为力。
方案2服务端爬虫开放接口① 灵活选型:支持多种爬虫框架。② 可模拟用户行为抓取任意数据。③ 插件仅需请求服务端接口,简化前端逻辑。① 需单独维护服务端代码,维护成本较高。② 无法随插件打包发布,部署复杂度上升。
方案3隐藏窗口及 Tab 静默模拟操作(✅最终选型)① 使用浏览器已有 API,无需引入额外依赖。② 可模拟用户点击、滚动等操作,提取懒加载或动态内容。③ 不依赖服务端,部署和维护相对简单。① 操作过程中用户可能偶尔感知页面切换(但多数情况下可接受)。② 会稍微占用多一些系统资源和时间,但整体性能影响可控。✅ 是

1.3 最终选型及设计思路

1.3.1 最终选型

  • 隐藏窗口及 Tab 静默模拟操作

1.3.2 设计思路

保留两种模式,用户根据需要灵活选择

image.png

  • 无痕模式:也即此次优化讨论重点,以新建隐藏窗口及tab静默打开目标页面模拟用户行为静默操作获取数据,用户对相关操作基本无感知。

image.png

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
  • 确保无痕模式相关代码在devbuild时都能被准确编译到指定位置,方便依赖及调用,避免异常
...
"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.tsTypeScript 的类型声明文件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核实,这里截取几段其他人的问题描述:

image.png

  • 经测试原生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>

以上就是自动下载主流电商图片及视频插件功能优化简要过程,项目已开源,欢迎交流: