【实战】用Vue3做Chrome插件开发:自动打包下载主流电商详情图片视频等资源

《vitesse-webext:Vue3进行Chrome插件开发神器》在这篇文章我重点介绍了vitesse-webext这个开源项目的特性、Tips及试用情况,其基于Vue3+TypeScript+UnoCSS的组合搭配及高度集成性减少了手工配置的不少麻烦,可谓省心省力。

编程没有捷径,多动手多思考多沉淀才能有所精进,毕竟实践出真知,本文以使用vitesse-webext进行Chrome插件实战开发作为切入,继续分享一些在实际开发中的所见、所思、所得:

📌 1. 需求导入

1.1 业务诉求

✅ 一键自动下载某猫、某688、某宝、某东商品详情页图片、视频等关键信息的插件;

✅ 根据需要灵活设置需要下载的资源;

✅ 下载的媒体资源要能明确区分类型;

✅ 有必要的操作提示或信息提醒。

1.2 产品设计

丛林精灵松鼠喜欢采集存储、食物,是种勤劳又可爱的小动物,插件的诉求正好也是采集存储数据,与松鼠非常契合,squirrel-gather(松鼠采集)名称由此而来。

1.3 竞品学习参考

闭门造车只会带来羸弱,海南百川才能一直成长,登高山能望远,插件学习并借鉴了Fatkun、沉浸式翻译两个优秀插件的功能及UI设计并做了一些优化尝试。

🚩 功能参考

  • Fatkun :标杆,上架久、功能全(先前有段时间貌似没有维护影响到了一部分老用户的使用提亚),下载资源自动分组、下载资源可以批量、单个选择操作非常灵活、支持批量下载所有页面图片、支持主流电商图片下载等核心功能值得学习、借鉴,操作步骤相对繁杂不够自动化 (并不一定是问题,跟插件自身的设计与定位有关)、图片下载漫灌的方式加入下载栏超过一定数量会出现夯死 下载交互不友好、缺少必要的通知提示、不支持视频下载、资源要自己挑选等是可以研究优化的方向。

🚩 界面参考

  • 沉浸式翻译 :标杆,绝非只是功能好用,UI设计也走心,在content页面插入悬浮按钮进行交互的UI设计值得学习借鉴

📌 2. 环境搭建

2.1 项目创建及初始化

shell 复制代码
cd [your dir]
npx degit antfu/vitesse-webext squirrel-gather
cd squirrel-gather
pnpm i // 项目初始化

2.2 chrome插件相关配置

2.2.1 package.json

  • 由于src\manifest.ts会从pkg中读取一些变量,如下变量建议核实按需修改
json 复制代码
{
  "name": "squirrel-gather",
  "displayName": "squirrel-gather",
  "version": "1.0.1",
  ...
  "description": "Download images, videos, and other media from product detail pages on 1688, Tmall, etc.",
  ...
}

2.2.2 配置src\manifest.ts

  • 找到src\manifest.ts,配置manifest.json(项目会自动、动态地将manifest.ts转换manifest.json)
    • 将转换好格式的icon存入src\assets目录,并配置好default_iconicons等属性,(icon生成方法参考《vitesse-webext:Vue3进行Chrome插件开发神器》中关于各类尺寸插件图标制作方法部分所述
    • 申请所需权限:'tabs'、'activeTab'、'notifications'、'downloads',项目没有用到'storage'可以移除
    • 配置content_scriptsweb_accessible_resources,涉及与页面交互增加目标网站路由/域名匹配,加入主流电商目标网站域名,模糊匹配
    • 删除侧边栏相关的配置(项目不涉及侧边栏)
ts 复制代码
import fs from 'fs-extra'
import type { Manifest } from 'webextension-polyfill'
import type PkgType from '../package.json'
import { isDev, isFirefox, port, r } from '../scripts/utils'

export async function getManifest() {
  const pkg = await fs.readJSON(r('package.json')) as typeof PkgType

  // update this file to update this manifest.json
  // can also be conditional based on your need
  const manifest: Manifest.WebExtensionManifest = {
    manifest_version: 3,
    name: pkg.displayName || pkg.name,
    version: pkg.version,
    description: pkg.description,
    action: {
      default_icon: {
        16: './assets/icon-16.png',
        32: './assets/icon-32.png',
        48: './assets/icon-48.png',
        128: './assets/icon-128.png',
      },
      default_popup: './dist/popup/index.html',
    },
    options_ui: {
      page: './dist/options/index.html',
      open_in_tab: true,
    },
    background: isFirefox
      ? {
        scripts: ['dist/background/index.mjs'],
        type: 'module',
      }
      : {
        service_worker: './dist/background/index.mjs',
      },
    icons: {
      16: './assets/icon-16.png',
      32: './assets/icon-32.png',
      48: './assets/icon-48.png',
      128: './assets/icon-128.png',
    },
    permissions: [
      'tabs',
      'activeTab',
      'notifications',
      'downloads',
      // 'sidePanel',
    ],
    host_permissions: ['*://*/*'],
    content_scripts: [
      {
        matches: [
          // 1688
          "*://detail.1688.com/*",
          // 天猫
          "*://detail.tmall.com/*",
          // 淘宝
          "*://item.taobao.com/*",
          // 京东
          "*://item.jd.com/*",
        ],
        js: [
          'dist/contentScripts/index.global.js',
        ],
        css: [
          'dist/contentScripts/style.css'
        ]
      },
    ],
    web_accessible_resources: [
      {
        resources: ['dist/contentScripts/style.css'],
        matches: [// 1688
          "*://detail.1688.com/*",
          // 天猫
          "*://detail.tmall.com/*",
          // 淘宝
          "*://item.taobao.com/*",
          // 京东
          "*://item.jd.com/*",
        ],
      },
    ],
    content_security_policy: {
      extension_pages: isDev
        // this is required on dev for Vite script to load
        ? `script-src \'self\' http://localhost:${port}; object-src \'self\'`
        : 'script-src \'self\'; object-src \'self\'',
    },
  }

  return manifest
}

2.3 UnoCSS配置

UnoCSS可以通过即时生成样式类,帮助高效地编写和应用CSS,其与传统 CSS 框架如Tailwind CSS需提前配置一套全局类名不同,UnoCSS通过按需生成类避免冗余从而提高页面加载性能。框架提前集成好了UnoCSS并预设好了可能用到的部件,找到unocss.config.ts按需配置即可:

  • theme 是一个配置对象,用于定义设计系统中的全局样式变量。这些变量可以在 rulesshortcuts 中被引用(通过动态占位符如 $引用),从而实现一致的样式管理和复用。如果与内置冲突,优先生效自定义的。如下配置主要实现了自定义品牌色(通过brand访问)及自己想要的灰色色阶
  • shortcuts用于将多个原子化类组合成一个新的类,简化使用
ts 复制代码
import { defineConfig } from 'unocss/vite'
import { presetAttributify, presetIcons, presetUno, transformerDirectives } from 'unocss'

export default defineConfig({
  presets: [
    presetUno(),
    presetAttributify(),
    presetIcons(),
  ],
  transformers: [
    transformerDirectives(),
  ],
  theme: {
    colors: {
      brand: {
        DEFAULT: '#FF9155',    // class="bg-brand"
        secondary: '#FFB773',   // class="bg-brand-secondary"
      },
      gray: {
        DEFAULT: '#BFBFBF', // class="bg-gray"
        '100': '#EAEAEA',// class="bg-gray-100"
        '200': '#BFBFBF',// class="bg-gray-200"
        '300': '#959595',// class="bg-gray-300"
        '400': '#6A6A6A',// class="bg-gray-400"
        '500': '#404040',// class="bg-gray-500"
        '600': '#151515',// class="bg-gray-600"
      }
    }
  },
  shortcuts: {
    'flex-row-between': 'flex flex-row items-center justify-between',
    'flex-row-center': 'flex items-center justify-center',
    'box-shadow': 'shadow-[0px_4px_4px_rgba(0,0,0,0.25)]',
    'flex-col-center': 'flex flex-col justify-center'
  },
})

2.4 安装Extensions Reloader

📌 3. 依赖及封装

3.1 @types/chrome

  • 依赖添加
shell 复制代码
pnpm install --save-dev @types/chrome
  • 找到tsconfig.json配置
shell 复制代码
{
  "compilerOptions": {
    "types": ["chrome"]
  }
}

3.2 jszip(资源打包下载)

shell 复制代码
pnpm install jszip/pnpm add jszip

3.3 p-limit(控制并发)

css 复制代码
pnpm install p-limit/pnpm add p-limit

3.4 vue-sonner(弹窗消息)

  • 添加依赖
shell 复制代码
pnpm install vue-sonner/pnpm add vue-sonner
  • 由于只在content使用,找到src\contentScripts\index.ts进行挂载配置即可(此处有坑,详见后文)
    • richColors: true, // 色彩搭配比较好看
    • position: 'top-center' // 默认展示在顶部中间位置,按需配置
ts 复制代码
import { createApp } from 'vue'
import App from './views/App.vue'
import { setupApp } from '~/logic/common-setup'
import { Toaster } from 'vue-sonner';

(() => {
  ...

  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)
})()
  • 效果演示

3.4.1 基于vue-sonner进行封装

  • 新建src\constants.ts,增加
ts 复制代码
// 消息提示
export enum NotificationMessage {
  STARTING = '开始解析页面数据',
  SUBMITTING = '解析成功已提交后台处理',
  PACKAGING = '资源提取成功正打包压缩',
  DOWNLOAD_COMPLETED = '下载成功请默认下载目录查验',
  ERROR = '任务执行失败请重试',
  BACKGROUND_ERROR = '处理器出现异常请重试',
  CONTROLLER_ERROR = '控制器出现异常请重试'
}
  • 新建src\types.ts,增加ToastType、ToastOptions
ts 复制代码
// Toast 类型
export type ToastType = 'info' | 'success' | 'error' | 'warning';

// Toast 配置
export interface ToastOptions {
  closeButton?: boolean; // 是否显示关闭按钮
  duration?: number;     // 显示时长
}
  • 新建src\composables\useToast.ts,根据不同的信息类型生效不同样式Toast、支持是否显示按钮、显示时间按需配置,可选是否调用随机延迟(利用时间差错开Toast,增强UI体验)
ts 复制代码
import { toast } from 'vue-sonner';
import { NotificationMessage } from '~/constants';
import { ToastOptions, ToastType } from '~/types';


/**
 * 显示 Toast 弹窗的 Hook
 * @returns {Object} - 包含 showToast 方法的对象
 */
export function useToast(style: string) {
  /**
   * 显示 Toast 弹窗
   * @param {NotificationMessage} notificationMessageValue - 提示消息内容
   * @param {ToastType} type - Toast 类型,默认为 'info'
   * @param {ToastOptions} options - 其他配置项
   */
  const showToast = (
    notificationMessageValue: NotificationMessage,
    type: ToastType = 'info',
    options: ToastOptions = {}
  ): void => {
    const { closeButton = false, duration = 5000 } = options;

    // Toast 配置
    const toastConfig = {
      class: style,
      duration,
      closeButton,
    };

    // 根据类型调用对应的 toast 方法
    const toastMethods = {
      info: toast.info,
      success: toast.success,
      error: toast.error,
      warning: toast.warning
    };

    toastMethods[type](notificationMessageValue, toastConfig);
  };

  // Toast随机延迟
  const generateRadomDelay = (min = 1500, max = 3500) => {
    return Math.ceil((Math.random() * (max - min + 1)) + min)
  }

  const showToastWithDelay = async (message: NotificationMessage, type?: ToastType, options?: ToastOptions) => {
    return new Promise<void>((resolve) => {
      showToast(message, type, options);
      setTimeout(() => resolve(), generateRadomDelay()); // 等待 Toast 消失
    });
  };

  return {
    showToast,
    showToastWithDelay
  };
}

3.5 @vueuse/core(useVue)

  • 主要用到了useDraggable、useMouseInElement、onClickOutside,省去了很多自定义的烦恼,显著提升开发效率
shell 复制代码
pnpm install @vueuse/core或pnpm add @vueuse/core

📌 4. 数据结构设计

4.1 数据结构

  • src\types.ts,常规的数据结构或接口定义,有注释,不赘述
ts 复制代码
// sku信息
export interface Sku {
  label: string | null
  url: string | null
}

// 消息发送结构体定义
export type JsonValue =
  | string
  | number
  | boolean
  | null
  | JsonObject
  | JsonArray
  | { [key: string]: JsonValue }

export type JsonObject = { [key: string]: JsonValue };
export type JsonArray = JsonValue[];

// 网页(content)解析结果
export interface Result {
  title?: string
  skus?: Sku[]
  mainVideoUrl?: string
  mainImages?: string[]
  detailImages?: string[]
  detailVideoUrl?: string
  isReadMe?: boolean
  url?: string,
  [key: string]: JsonValue | undefined | Sku[]; // 添加索引签名
}

// 通知
export interface NotificationOptions {
  title: string;
  message: string;
};


// 设置选项
export interface Item {
  id?: string
  name: string
  status?: boolean
}

// 可交互按钮类型属性
export interface ActionButtonProps {
  label: string
  isExpand: boolean
  isHiddenTooltip: boolean
}

// 设置面板选项
export interface Settings {
  isReadMe: Ref<boolean>
  isMainVideo: Ref<boolean>
  isMainImages: Ref<boolean>
  isSkus: Ref<boolean>
  isDetailImages: Ref<boolean>
  isDetailVideo: Ref<boolean>,
  [key: string]: Ref<boolean>; // 添加索引签名
}


// 任务类型
export enum TaskType {
  // 可并发执行
  CONCURRENT = "可并发执行",
  // 需顺序执行
  SEQUENTIAL = "需顺序执行"
}

// 设置项处理函数
export interface SettingsHandler<T> {
  key: keyof Settings; // settings 的字段名
  handler: () => Promise<T> | T; // 处理函数
  resultKey: keyof Result; // 使用 keyof 确保 resultKey 是 Result 的合法字段
  defaultValue: T; // 根据 resultKey 的类型定义默认值
  taskType: TaskType
}


// Toast 类型
export type ToastType = 'info' | 'success' | 'error' | 'warning';

// Toast 配置
export interface ToastOptions {
  closeButton?: boolean; // 是否显示关闭按钮
  duration?: number;     // 显示时长
}


// 页面内容解析handle统一接口
export interface PlatformHandler {
  handleTitle: () => Promise<string>;
  handleSkus: () => Promise<Sku[]>;
  handleMainImages: () => Promise<string[]>;
  handleMainVideo: () => Promise<string>;
  handleDetailImages: () => Promise<string[]>;
  handleDetailVideo?: () => Promise<string>;
}

// 响应结构体
export interface Response extends JsonObject {
  code: number;
  message: string;
  data: JsonValue;
  source: 'background' | 'content' | 'popup' | 'sidepanel';
  status: 'success' | 'failed' | 'unknown';
  [key: string]: JsonValue; // 添加索引签名并确保符合 JsonValue 类型
}

4.2 常量

  • src\constants.ts,常量,不赘述
ts 复制代码
// 域名配置
export enum Hostname {
  ALI = 'detail.1688.com',
  TMALL = 'detail.tmall.com',
  TAOBAO = 'item.taobao.com',
  JD = 'item.jd.com'
}

// 消息提示
export enum NotificationMessage {
  STARTING = '开始解析页面数据',
  SUBMITTING = '解析成功已提交后台处理',
  PACKAGING = '资源提取成功正打包压缩',
  DOWNLOAD_COMPLETED = '下载成功请默认下载目录查验',
  ERROR = '任务执行失败请重试',
  BACKGROUND_ERROR = '处理器出现异常请重试',
  CONTROLLER_ERROR = '控制器出现异常请重试'
}

4.3 utils

  • 代码路径:src\utils\common.ts
ts 复制代码
import { NotificationOptions } from '~/types';

// 延迟异步函数
export const delay = (ms: number): Promise<void> => {
  return new Promise<void>((resolve) => setTimeout(resolve, ms));
}


// 获取URL地址
export const getPageUrl = (): string => {
  return window.location.href
}


// chrome通知
export const showNotification = ({ title, message }: NotificationOptions): void => {
  chrome.notifications.create({
    type: 'basic',
    iconUrl: '/assets/icon-48.png',
    title,
    message,
    priority: 2,
  });
}

// 数值转字符串
export const formatNumber = (num: number, length: number): string => {
  return num.toString().padStart(length, '0');
};


// 去掉指定符号后面的字符串
export const removeDashAndAfter = (str: string, dash: string = '-') => {
  const dashIndex = str.indexOf(dash)
  // 如果找到 "-",则截取 "-" 之前的部分
  if (dashIndex !== -1) {
    return str.slice(0, dashIndex).trim(); // 使用 trim() 去掉可能的空格
  }
  // 如果没有 "-",返回原字符串
  return str
}


// URL补全URL协议头
export const completeUrlProtocol = (url: string): string => {
  return url.startsWith('//') ? `${window.location.protocol}${url}` : url
}


// 文本信息去除"/",如果存在会影响文件保存:如果存在会当成文件夹处理
export const normalizeText = (text: string, reg: RegExp = /\//g): string => {
  return text.replace(reg, '_').trim()
}


// 判断元素是否在视口中
// 检查元素是否在视口中
export const isInViewport = (element: any) => {
  const rect = element.getBoundingClientRect();
  return (
    rect.top >= 0 &&
    rect.left >= 0 &&
    rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
    rect.right <= (window.innerWidth || document.documentElement.clientWidth)
  );
}

📌 5. 界面

未引入第三方库包纯手敲,手生当时练下手好了,content相关的UI在src\contentScripts\views\App.vue进行组装,非常典型的SPA页面

vue 复制代码
<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 } from '~/types'

// 下载任务提交状态
const isSubmit = ref(false)
// 是否关闭悬浮按钮(包含设置面板)
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 } = useMouseInElement(floatingButtonRef)
// 是否展开
const isExpand = computed(() => isShowSettingsCard.value || !isOutside.value)

// 设置按钮点击处理函数
const settingsShowHandle = (event: MouseEvent) => {
  isShowSettingsCard.value = !isShowSettingsCard.value
  event.stopPropagation()
}

// 可拖拽功能调用
const { y, adaptiveStyles } = useDrag(floatingButtonRef)

// 提交下载任务等待background反馈结果并触发Toast
const { downloadHandle } = useDownloadController(isSubmit, settings, showToast)

// 监听设置面板外部任何区域时关闭设置面板,设置按钮、关闭按钮除外
useClickOutside(
  settingsCardRef,
  () => {
    if (isShowSettingsCard.value) {
      isShowSettingsCard.value = false
    }
  },
  isShowSettingsCard,
  [settingsButtonRef, closeButtonRef]
)
</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"
        :is-submit="isSubmit"
        @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>
  • 效果演示:

5.1 content相关

5.1.1 带标签的Switch按钮

  • 代码:src\components\SwitchWithLabel.vue,使用defineModel双向绑定简化了代码
vue 复制代码
<template>
  <div class="flex-row-between gap-x-[48px]">
    <span class="text-gray-300" :style="{ fontSize }">
      {{ label }}
    </span>

    <input type="checkbox" class="hidden" v-model="isChecked" />
    <label
      class="w-[36px] h-[20px] rounded-full relative cursor-pointer"
      :class="[isChecked ? checkedColor : bgColor]"
      @click="isChecked = !isChecked"
    >
      <span
        class="absolute w-[14px] h-[14px] top-[50%] rounded-full -translate-x-[50%] -translate-y-[50%]"
        :class="[ballColor, isChecked ? 'left-[28%]' : 'left-[72%]']"
      ></span>
    </label>
  </div>
</template>

<script setup lang="ts">
// 定义类型
interface SwitchWithLabelProps {
  label?: string
  fontSize?: string
  checkedColor?: string
  bgColor?: string
  ballColor?: string
}

// 定义组件默认值
withDefaults(defineProps<SwitchWithLabelProps>(), {
  label: '待设置项',
  fontSize: '12px',
  checkedColor: 'bg-brand',
  bgColor: 'bg-gray',
  ballColor: 'bg-white'
})

// 定义双向绑定属性值(3.4+新特性,简化代码)
const isChecked = defineModel<boolean>({ default: false })
</script>
  • 效果

5.1.2 带提示信息的视图元素

  • 代码:src\components\HoverTooltip.vue,hover时提示操作信息,并在必要的时候"屏蔽"提示信息
vue 复制代码
<template>
  <div class="relative w-max">
    <span v-show="status" :class="tooltipClass">
      {{ label }}
    </span>
    <slot></slot>
  </div>
</template>

<script setup lang="ts">
interface HoverTooltipProps {
  label: string
  status: boolean
}

withDefaults(defineProps<HoverTooltipProps>(), {
  label: '双击下载资源',
  status: true
})

const tooltipClass = computed(() => {
  return [
    'absolute',
    'right-full',
    '-translate-x-1/4',
    'top-1/2',
    'transform',
    '-translate-y-1/2',
    'transition-all',
    'duration-300',
    'ease-in-out',
    'bg-gray-500',
    'px-4',
    'py-1',
    'rounded-md',
    'text-white',
    'text-xs',
    'whitespace-nowrap',
    'delay-200'
  ]
})
</script>
  • 效果展示

5.1.3 可点击且带Hover提示的通用按钮

  • src\components\ActionButton.vue,emit支持传参,也可不传参
vue 复制代码
<template>
  <HoverTooltip :label="label" :status="showTooltip">
    <span ref="actionButtonRef" @click="clickHandle">
      <slot></slot>
    </span>
  </HoverTooltip>
</template>

<script lang="ts" setup>
import { useMouseInElement } from '@vueuse/core'
import { ActionButtonProps } from '~/types'

const props = withDefaults(defineProps<ActionButtonProps>(), {
  label: '',
  isExpand: true,
  isHiddenTooltip: false
})

const actionButtonRef = ref()
const { isOutside } = useMouseInElement(actionButtonRef)

const showTooltip = computed(
  () => props.isHiddenTooltip && props.isExpand && !isOutside.value
)

const emit = defineEmits(['action'])

const clickHandle = (...args: any[]) => {
  if (args.length) {
    emit('action', ...args)
  } else {
    emit('action')
  }
}
</script>
5.1.4 关闭component
  • 代码:src\components\CloseButton.vue
vue 复制代码
<template>
  <ActionButton
    :label="label"
    :is-expand="isExpand"
    :is-hidden-tooltip="isHiddenTooltip"
    @action="clickHandle"
  >
    <material-symbols-close-rounded
      :class="[isExpand ? 'visible' : 'invisible']"
      class="text-white text-[12px] bg-gray-200 rounded-full box-shadow mr-1 p-[1px]"
    />
  </ActionButton>
</template>

<script setup lang="ts">
import { ActionButtonProps } from '~/types'

withDefaults(defineProps<ActionButtonProps>(), {
  label: '',
  isExpand: true,
  isHiddenTooltip: false
})

const emit = defineEmits(['update:closeAction'])

const clickHandle = (event: MouseEvent) => {
  emit('update:closeAction', event)
}
</script>
  • 演示
5.1.4.1 下载提交component
  • 代码:src\components\DownloadButton.vue,渐变配色,Hover时可伸展缩进并改变透明度
vue 复制代码
<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 flex-row-start-center"
      :class="[
        isExpand ? 'w-[48px] opacity-100' : 'w-[36px] opacity-[.68]',
        isSubmit
          ? 'bg-gray cursor-not-allowed pointer-events-none'
          : 'bg-gradient-to-r from-brand to-brand-secondary'
      ]"
    >
      <LoadingIcon
        v-if="isSubmit"
        class="text-white text-[18px] pl-2.5 p-y-1"
      ></LoadingIcon>
      <eva-cloud-download-outline
        v-else
        class="text-white text-[18px] pl-2.5 p-y-1 relative"
      />
    </div>
  </HoverTooltip>
</template>

<script setup lang="ts">
import { useMouseInElement } from '@vueuse/core'

type withSubmitActionButtonProps = {
  label?: string
  isExpand: boolean
  isHiddenTooltip: boolean
  isSubmit: boolean
}

const props = withDefaults(defineProps<withSubmitActionButtonProps>(), {
  isExpand: true,
  isHiddenTooltip: false,
  isSubmit: false
})

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')
}
</script>
  • 效果演示
5.1.4.2 设置标签component
  • 代码:src\components\SettingsButton.vue,支持显示隐藏,支持点击设置面板外部隐藏(注意此处有坑,详见后文)
vue 复制代码
<template>
  <ActionButton
    :label="label"
    :is-expand="isExpand"
    :is-hidden-tooltip="isHiddenTooltip"
    @action="clickHandle"
  >
    <uil-setting
      class="text-gray-300 text-[16px] cursor-pointer p-[4px] bg-white rounded-full flex-row-center box-shadow mr-2"
      :class="[isExpand ? 'visible' : 'invisible']"
    />
  </ActionButton>
</template>

<script lang="ts" setup>
import { ActionButtonProps } from '~/types'

withDefaults(defineProps<ActionButtonProps>(), {
  label: '',
  isExpand: true,
  isHiddenTooltip: false
})

const emit = defineEmits(['update:showSettingsCard'])

const clickHandle = (event: MouseEvent) => {
  emit('update:showSettingsCard', event)
}
</script>
  • 效果展示

5.2 popup(不关心具体实现可跳过)

popup相关的UI在src\popup\Popup.vue进行组装,同样也是非常典型的SPA页面

5.2.1 支持对象组件

  • 代码:src\components\SupportItem.vue
vue 复制代码
<template>
  <div class="flex-row-between gap-x-[48px] text-[12px]">
    <span class="text-gray-300">{{ name }}</span>
    <teenyicons-shield-tick-solid
      class="text-[16px] p-[2px]"
      :class="[status ? 'text-brand' : 'text-gray']"
    />
  </div>
</template>

<script setup lang="ts">
defineProps({
  name: {
    type: String,
    default: '未知站点'
  },
  status: {
    type: Boolean,
    default: false
  }
})
</script>
  • 效果展示

📌 6. 钩子

6.1 拖拽钩子

  • 代码:src\composables\useDrag.ts,对useDraggable进行了封装
    • 只支持纵向拖拽
    • 支持adaptiveStyles计算属性导出,方便依赖组件基于纵向拖拽的位置"相对地"修正自己的位置,确保依赖组件在视图中能合适地调整位置从而完整地显示出来
ts 复制代码
import {
  useDraggable
} from '@vueuse/core'


export function useDrag(elementRef: Ref<HTMLElement | null>, maxHeight: number = 268) {
  // 可拖拽功能
  const { y } = useDraggable(elementRef, {
    initialValue: {
      x: 0,
      y: 80
    },
    axis: 'y',
    preventDefault: true, // 禁止触发滚动
    onMove: (position) => {
      // 限制拖拽范围在窗口可视范围内
      const floatingButtonElement = elementRef.value as HTMLElement
      // 获取悬浮按钮的宽高等信息
      const rect = floatingButtonElement.getBoundingClientRect()
      const minY = 0
      const maxY = window.innerHeight - rect.height

      if (position.y < minY) {
        position.y = minY
      }
      if (position.y > maxY) {
        position.y = maxY
      }
    }
  })


  const adaptiveStyles = computed(() => {
    return window.innerHeight - y.value < maxHeight ? { bottom: '0' } : { top: '50%' }
  })

  return {
    y,
    adaptiveStyles
  };
}

6.2 配置项(静态数据导出)钩子

  • 代码:src\composables\useSwitchGroups.ts,在useSwitchGroups status是Ref,方便对每一个属性进行动态追踪
ts 复制代码
import { Settings } from "~/types";

export function useSwitchGroups(settings: Settings) {

  const switchGroups = [
    {
      name: '功能设定',
      children: [
        {
          name: '生成源链接文件',
          status: settings.isReadMe
        }
      ]
    },
    {
      name: '资源下载选项',
      children: [
        {
          name: '主图视频',
          status: settings.isMainVideo
        },
        {
          name: '主图图片',
          status: settings.isMainImages
        },
        {
          name: 'SKU名称及图片',
          status: settings.isSkus
        },
        {
          name: '详情页图片',
          status: settings.isDetailImages
        },
        {
          name: '讲解/详情视频',
          status: settings.isDetailVideo
        }
      ]
    }
  ];

  return {
    switchGroups
  };
}

6.3 Toast钩子

  • 代码:src\composables\useToast.ts,在上文vue-sonner(弹窗消息)封装中已提到,不赘述

6.4 外部点击监听钩子

  • 代码:src\composables\useClickOutside.ts,详见下文提到的自定义clickOutside,不赘述

6.5 数据采集钩子

6.5.1 思考与选型

在页面content页面注入悬浮按钮提供用户交互,根据交互按需提取页面数据,通过对比如下几种方案的优缺点,兼顾考虑到页面相对稳定、实现成本,最终选型方案是DOM解析提取,其在用户体验上也有一定可控性。

💡思路1:DOM解析提取

纯DOM操作提取所需数据

✅优点:由于悬浮按钮基本上是content loaded之后再"渲染"出来的,所以大部分数据都可以获取到,即使存在一些"懒加载"的情况,只要操作得当,例如主动触发点击或者scroll等事件,基本上能获取到所需数据。DOM操作相对比较简单,引入异步后并发操作也方便。

❎缺点:不同站点需要点对点分析,工作量较大;页面元素一旦出现变动,写死的DOM规则或者过滤器无法适配;DOM有感操作可能影响用户体验

💡思路2:请求监听拦截处理

如利用chrome.webRequest(chrome.webRequest API 在 Manifest V3 中已经被 chrome.declarativeNetRequest 取代,导致其可操作性受到一定限制。部分站点使用 HTTPS + Content Security Policy(CSP)会限制数据的拦截,这可能是我测试极其不稳定的原因)对网络请求进行细粒度控制,可以在请求的各个阶段(如请求发送前、收到响应头、完成请求等)执行自定义逻辑,支持动态修改请求和响应。

✅优点:只需要对网络请求进行抓包分析即可

❎缺点:相关API经测试非常不稳定,积压或延迟严重,无法满足业务上快速响应的需求;无法完全脱离DOM操作,DOM有感操作可能影响用户体验

💡思路3:网络爬虫

如使用Puppeteer、Puppeteer Core等借助无头浏览器隐式重放请求后基于DOM操作获取数据(核心其实与思路1类似),或者抓包分析后获取到请求API及其参数后重放请求获取数据,或者在content中调用第三方代码如python脚本解析获取数据

✅优点:即使涉及到DOM操作也是隐式操作用户无感;如果是请求重放几乎不受页面UI变动影响

❎缺点:开发复杂度、难度增加;部分库包内置浏览器会显著增加插件包体体积;请求重放面临反爬虫机制的挑战

💡思路4:"前后端分离"

我们把面向用户提供交互的统称为"前端",插件本身就是前端,解析数据提供API的称为后端,至于后端是通过Node实现、Python实现、Go实现,是爬虫方案还是调用官方接口插件本身并不关心

✅优点:解耦前后端可以快速并行开发

❎缺点:需要写一套后端代码也即维护两套代码,也暂时没有这个时间

6.5.2 面向接口编程

基于DOM解析提取页面数据方案既定,如何约束数据提取过程及数据输出格式就很关键了,好在电商行业诸如SPU、SKU、主副图、主图视频、详情页这些基本上是通识性概念,为模型设计提供了巨大便利,我们将页面数据提取、处理、包装的过程称为解析器,在插件中我们做了这些尝试:

  • 提前规范提取的数据、存放的数据及需要推送给后端的数据(与插件中用户可设置项对应)
ts 复制代码
// sku信息
export interface Sku {
  label: string | null
  url: string | null
}

// 消息发送结构体定义
export type JsonValue =
  | string
  | number
  | boolean
  | null
  | JsonObject
  | JsonArray
  | { [key: string]: JsonValue }

export type JsonObject = { [key: string]: JsonValue };
export type JsonArray = JsonValue[];

// 网页(content)解析结果
export interface Result {
  title?: string
  skus?: Sku[]
  mainVideoUrl?: string
  mainImages?: string[]
  detailImages?: string[]
  detailVideoUrl?: string
  isReadMe?: boolean
  url?: string,
  [key: string]: JsonValue | undefined | Sku[]; // 添加索引签名
}
  • 定义好解析器的接口,后续无论面向哪个平台都按"接口"规范办事(与插件中用户可设置项对应)
ts 复制代码
// 页面内容解析handle统一接口(解析器统一接口)
export interface PlatformHandler {
  handleTitle: () => Promise<string>;
  handleSkus: () => Promise<Sku[]>;
  handleMainImages: () => Promise<string[]>;
  handleMainVideo: () => Promise<string>;
  handleDetailImages: () => Promise<string[]>;
  handleDetailVideo?: () => Promise<string>;
}

6.5.3 无畏并发

基于DOM解析提取页面数据 有个明显的优点是大部分数据都是"静态"的,静态也就意味着多个解析器可以同步提取,这为并发处理提供了可能性。当然肯定也有动态数据或者懒加载数据的,这些数据如果需要事件触发,并发处理可能会相互干扰导致最终结果输出不准确,很显然并发不能一刀切入要有的放矢。如何解决这个问题呢?线程池可以吗?答案是肯定的。我们通过对解析器具体任务进行分类(并发任务、顺序任务)并维护两套线程池(并发池、顺序池)实现了合理并发的诉求(详见src\composables\useCollectData.ts):

  • useCollectData是任意平台都需要调用的"入口钩子",需要按规范入参
  • 基于事件操作的动态数据纳入sequentialTasks池,静态数据纳入concurrentTasks池
  • 通过computeLimit计算出最优并发数并兜底最大最小并发,合理控制资源占用
ts 复制代码
import pLimit from "p-limit";
import { PlatformHandler, Result, Settings, SettingsHandler, TaskType } from "~/types";

export function useCollectData(
  handler: PlatformHandler,
  settingsHandlers: SettingsHandler<any>[],
  url: string
) {
  // 顺序任务、并发任务分发及执行
  const executeTasks = async (
    settingsHandlers: SettingsHandler<any>[],
    settings: Settings,
    result: Result,
    limit: (fn: () => Promise<void>) => Promise<void>
  ): Promise<void> => {
    // 任务队列
    const sequentialTasks: (() => Promise<void>)[] = [];
    const concurrentTasks: Promise<void>[] = [];

    // 动态添加任务
    settingsHandlers.forEach(({ key, handler, resultKey, defaultValue, taskType }) => {
      if (settings[key].value) {
        const task = async () => {
          const value = await handler();
          result[resultKey] = value ?? defaultValue;
        };

        if (taskType === TaskType.SEQUENTIAL) {
          sequentialTasks.push(task); // 顺序任务
        } else {
          concurrentTasks.push(limit(task)); // 并发任务,限制并发数
        }
      }
    });

    // 执行任务
    try {
      // 按顺序执行顺序任务
      for (const task of sequentialTasks) {
        await task();
      }

      // 并发执行并发任务
      await Promise.all(concurrentTasks);
    } catch (error) {
      console.error('任务执行过程中发生错误:', error);
      throw new Error('任务执行失败');
    }
  };


  // 计算出最合适的并发数,充分发挥系统性能
  const computeLimit = (min = 1, max = 5): number => {
    let concurrentCount = 0;
    let hasSequentialTask = false;

    if (settingsHandlers.length <= 0) return min; // 兜底最小值为 1

    settingsHandlers.forEach(handler => {
      if (handler.taskType === TaskType.CONCURRENT) {
        concurrentCount++;
      } else if (handler.taskType === TaskType.SEQUENTIAL) {
        hasSequentialTask = true; // 标记是否存在顺序任务
      }
    });

    // 兜底最小值为 1,避免返回 0
    const limit = concurrentCount + (hasSequentialTask ? 1 : 0);
    return Math.min(Math.max(limit, min), max);
  };

  // 数据汇总
  const processData = async (settings: Settings): Promise<Result> => {
    const result: Result = { url }; // 初始化 result 并添加 url
    const n = computeLimit() // 计算并发数
    const limit = pLimit(n); // 设置最大并发数

    // 处理标题
    result.title = await handler.handleTitle() ?? '';

    // 执行任务
    await executeTasks(settingsHandlers, settings, result, limit);

    // 处理 isReadMe
    result.isReadMe = settings.isReadMe.value;

    return result;
  };

  return {
    processData,
  };
}

6.5.4 采集器具体实现(以某猫为例)

  • 某猫商品详情页遇到过一些小麻烦,作为案例展开说明(详见src\composables\useCollectTmallData.ts

💥隔离样式 :页面DOM元素的类名是这样的:class="thumbnailPic--QasTmWDm",好家伙,隔离的挺好,每次发版可能还不一样?

✅解决办法:document.querySelectorAll('img[class*="thumbnailPic"]')支持模糊查询,用稳定性换效率值得


💥标题会跟随滚动行为显示或者隐藏:分析页面的时候想当然以为找到了最佳答案(class="mainTitle--O1XCl8e2 f-els-2"),结果在主动触发异常向下滚动重试的时候必现商品标题找不到的情况,难道需要提前缓存或者scroll回去重新获取?这也太...

✅解决办法::换个位置换种思路,F12后用标题名称去整个Elements搜索,发现head title中就有标题,而且是静态的并且主流平台通用


💥SKU图片局部懒加载无法准确获得图片的src资源:不在视口src对应的资源是默认的未加载图标地址

✅解决办法::找出满足未加载图标条件的元素,逐一scrollIntoView解析获取资源


💥主图视频受网络及操作影响时隐时现:有时候能成功获取有时候不能

✅解决办法: :多重逻辑兜底,首先判断有没有视频,没有视频直接返回;如果有视频,先触发视频tab点击并监听视频标签是否出现,一旦出现解析获取并返回;对视频标签出现的监听并非不设限,当等待超过3秒时会自动退出并释放资源,此处使用await Promise.race([videoSrcPromise, timeoutPromise])实现线程良性竞争,避免程序夯实


💥详情页图片懒加载:未scroll到具体元素或者具体元素在视口不可见时,只能解析到默认的未加载图标资源。难道要模拟用户滚动到底行为(实际无法滚动到底,因为底部有非常多广告位,可以无限滚动)?或者循环遍历逐一scrollIntoView解析获取资源(商详图片居多,不太合适)?

✅解决办法::经过测试发现只需定位到最后一张图片直接scrollIntoView即可,为避免空转,使用递归最多重试三次如未获取结果直接返回空


💥详情页图片资源部分商品获取不到

✅解决办法::某猫提供多类上传商详图片的方法,可以上传到富文本,可以上传到图片集,这个没有别的办法,只能组合积累尽可能地筛选器,目前收集了两种,多数场景适配

ts 复制代码
import { Sku, PlatformHandler, SettingsHandler, TaskType } from '../types'
import { completeUrlProtocol, delay, normalizeText, removeDashAndAfter } from '../utils/common'
import { useCollectData } from './useCollectData';


// 天猫视图筛选器统一配置
export const TMALL_SELECTORS = {
  titleContent: '#tbpc-detail-item-title',
  headTitleContent: 'head title',
  skuFilter: '[class*="valueItemImg"][placeholder]',
  mainImage: 'img[class*="thumbnailPic"]',
  mainVideo: 'video#videox-video-el',
  mainSwitchTab: 'div[class*="switchTabsItem"]',
  detailImageFilter1: 'img[data-name="singleImage"].descV8-singleImage-image.lazyload',
  detailImageFilter2: 'img[align="absmiddle"].lazyload',
  invalidDetailImageKeyInfo: '6000000008132-2-tps-750-880.png'
};



const tmallHandlers: PlatformHandler = {
  // 获取标题信息
  // 天猫标题scroll后会消失,改为从head.title中获取标题,head标题需要解析处理
  handleTitle: async (): Promise<string> => {
    const headTitleElement = document.querySelector(TMALL_SELECTORS.headTitleContent);
    const headTitleTextContent = headTitleElement?.textContent;
    if (!headTitleElement || !headTitleTextContent) {
      return ''; // 如果没有找到 head 标题元素或标题内容为空,直接返回空字符串
    }

    // 获取 head 标题内容并处理
    const processedTitle = normalizeText(removeDashAndAfter(headTitleTextContent, '-'))

    return processedTitle || '';
  },

  // 获取sku名称及图片信息
  handleSkus: async (): Promise<Sku[]> => {
    const rst: Sku[] = [];

    const loadImages = async (elements: NodeListOf<Element>) => {
      const elementArray = Array.from(elements);
      for (const element of elementArray) {
        const baseUrl = element.getAttribute('src');
        if (baseUrl && baseUrl.endsWith('.png')) {
          element.scrollIntoView();
          await delay(100);
        }
      }
    };

    const extractSkuInfo = (elements: NodeListOf<Element>) => {
      elements.forEach((element) => {
        const imgSrc = element.getAttribute('src');
        const labelElement = element.nextElementSibling;

        if (labelElement) {
          const title = labelElement.getAttribute('title');
          if (title && imgSrc) {
            rst.push({
              label: normalizeText(title),
              url: completeUrlProtocol(imgSrc),
            });
          }
        }
      });
    };

    let elements = document.querySelectorAll(TMALL_SELECTORS.skuFilter);
    if (elements.length > 0) {
      await loadImages(elements);
    }

    elements = document.querySelectorAll(TMALL_SELECTORS.skuFilter);
    if (elements.length > 0) {
      extractSkuInfo(elements);
    }

    return rst;
  },


  // 获取主图
  handleMainImages: async (): Promise<string[]> => {
    const imgElements = document.querySelectorAll(TMALL_SELECTORS.mainImage);
    return Array.from(imgElements)
      .map((element) => element.getAttribute('src'))
      .filter((url): url is string => url !== null)
      .map((url) => (completeUrlProtocol(url)));
  },



  // 获取主图视频
  handleMainVideo: async (): Promise<string> => {
    const TIMEOUT_DURATION = 3000; // 3秒超时

    try {
      const baseElement = document.querySelector('div[class*="switchTabsWrap"][data-appeared="true"]');
      if (!baseElement) {
        return '';
      }

      const getVideoSrc = (): string | null => {
        const videoElement = document.querySelector(TMALL_SELECTORS.mainVideo);
        const src = videoElement?.getAttribute('src')?.trim() || null;
        return src ? completeUrlProtocol(src) : null;
      };

      // 先检查是否已经有视频元素存在
      let videoSrc = getVideoSrc();
      if (videoSrc) {
        return videoSrc;
      }

      // 如果没有找到视频资源,通过点击显示的查找
      const elements = baseElement.querySelectorAll(TMALL_SELECTORS.mainSwitchTab);
      elements.forEach((element) => {
        if (element instanceof HTMLElement && element.textContent?.trim() === '视频') {
          element.click();
        }
      });

      // 等待页面加载视频资源
      await new Promise(resolve => setTimeout(resolve, 100));

      // 使用 MutationObserver 获取视频地址
      let observer: MutationObserver | null = null; // 将 observer 提到外层,方便在超时情况下断开
      const videoSrcPromise = new Promise<string>((resolve) => {
        observer = new MutationObserver(() => {
          const src = getVideoSrc();
          if (src) {
            observer?.disconnect(); // 找到视频地址后断开监听
            resolve(src);
          }
        });

        observer.observe(document.body, {
          childList: true,
          subtree: true,
        });

        // 检查是否有视频资源加载完成
        const src = getVideoSrc();
        if (src) {
          observer.disconnect(); // 如果已经有视频地址,直接断开监听
          resolve(src);
        }
      });

      // 设置超时
      const timeoutPromise = new Promise<string>((resolve) => {
        setTimeout(() => {
          if (observer) {
            observer.disconnect(); // 超时后断开监听
          }
          resolve('');
        }, TIMEOUT_DURATION);
      });

      // 使用 Promise.race 来竞争超时和视频地址获取
      return await Promise.race([videoSrcPromise, timeoutPromise]);

    } catch (error) {
      console.error('错误:', error);
      return ''; // 错误时返回空字符串
    }
  },


  // 获取详情页图片(优化后)
  handleDetailImages: async (): Promise<string[]> => {
    const maxRetries = 3; // 最大重试次数
    const retryCount = 0; // 当前重试次数
    const rst: string[] = [];
    const detailImageFilter1 = '.descV8-richtext img[align="absmiddle"].lazyload,.descV8-richtext img[align="absmiddle"]:not(.lazyload)';
    const detailImageFilter2 = '.descV8-singleImage img[data-name="singleImage"].descV8-singleImage-image.lazyload';


    // 获取图片元素
    const getImageElements = () => {
      const filter1 = Array.from(document.querySelectorAll(detailImageFilter1));
      const filter2 = Array.from(document.querySelectorAll(detailImageFilter2));
      return [...filter1, ...filter2];
    };

    // 滚动到最后一个图片元素
    const scrollToLastImage = (elements: string | any[] | NodeListOf<Element>) => {
      if (elements.length > 0) {
        elements[elements.length - 1].scrollIntoView({ behavior: 'smooth' });
      }
    };

    // 提取图片 URL
    const extractImageUrls = (elements: string | any[] | NodeListOf<Element>) => {
      for (let i = 0; i < elements.length - 1; i++) {
        const ele = elements[i]
        const src = ele.getAttribute('src');
        const dataSrc = ele.getAttribute('data-src');
        const url = src !== '//g.alicdn.com/s.gif' ? src : dataSrc;

        if (url) {
          rst.push(url.startsWith('//') ? window.location.protocol + url : url);
        }
      }
    };

    // 检查是否有未成功获取的 URL
    const checkForInvalidUrls = () => {
      // 6000000001963-2-tps-790-300.png filter1的懒加载图片
      // 6000000008132-2-tps-750-880.png filter2的懒加载图片
      return rst.some((item) => item.includes('6000000001963-2-tps-790-300.png') || item.includes('6000000008132-2-tps-750-880.png'));
    };

    // 主逻辑
    let elements = getImageElements();
    if (elements.length > 0) {
      scrollToLastImage(elements);
      await delay(1000); // 等待 1 秒,确保页面加载完成
      elements = getImageElements();
      extractImageUrls(elements);
    }

    const check = checkForInvalidUrls();
    if (check && retryCount < maxRetries) {
      // 如果存在未成功获取的 URL,并且未达到最大递归次数,则递归调用
      await delay(1000); // 等待 1 秒,确保页面加载完成
      return tmallHandlers.handleDetailImages();
    }

    return rst;
  }
}

// 定义京东的settingsHandlers
const tmallSettingsHandlers: SettingsHandler<any>[] = [
  { key: 'isSkus', handler: tmallHandlers.handleSkus, resultKey: 'skus', defaultValue: [], taskType: TaskType.SEQUENTIAL },
  { key: 'isMainVideo', handler: tmallHandlers.handleMainVideo, resultKey: 'mainVideoUrl', defaultValue: '', taskType: TaskType.SEQUENTIAL },
  { key: 'isMainImages', handler: tmallHandlers.handleMainImages, resultKey: 'mainImages', defaultValue: [], taskType: TaskType.CONCURRENT },
  { key: 'isDetailImages', handler: tmallHandlers.handleDetailImages, resultKey: 'detailImages', defaultValue: [], taskType: TaskType.SEQUENTIAL },
];


export function useCollectTmallData(url: string) {
  return useCollectData(tmallHandlers, tmallSettingsHandlers, url);
}

6.6 资源提交下载钩子

资源下载逻辑简单,content将数据提交给background,background请求资源并打包写入到本地,最后通知content执行结果,一个典型的请求响应,在这里借鉴MVC架构,将content组装数据并向background sendMessage的钩子定义为Controller,将实际执行下载动作并执行回调的定义为Service,chrome自带的提示信息由background触发,Toast则由content触发,异常也由content捕获,实现闭环。

6.6.1 useDownloadController

  • 代码:src\composables\useDownloadController.ts
ts 复制代码
import { Hostname, NotificationMessage } from "~/constants";
import { JsonValue, Result, Settings, ToastOptions, ToastType, Response } from "~/types";
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 = (isSubmit: Ref<boolean>,
  settings: Settings,
  showToast: (notificationMessageValue: NotificationMessage, type?: ToastType, options?: ToastOptions) => void) => {


  // content---->background推送下载打包消息
  const sendToBackground = async (data: Result) => {
    const response = await sendMessage<Response>(
      'downloadResources',
      data as JsonValue,
      'background'
    )

    await showToast(NotificationMessage.PACKAGING);

    if (response && response.code === 200 && response.status === 'success') {
      await showToast(NotificationMessage.DOWNLOAD_COMPLETED, 'success', { duration: 15000, closeButton: true })
    } else {
      await showToast(NotificationMessage.BACKGROUND_ERROR, 'error', { duration: 15000, closeButton: true })
    }
  }


  // downloadSubmitHandle(将下载任务提交到后台)
  const downloadHandle = async () => {
    try {
      isSubmit.value = true
      // 解析消息提示
      await showToast(NotificationMessage.STARTING)

      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
        )
      }

      console.log('result==============>', result)

      await showToast(NotificationMessage.SUBMITTING)
      // 提交后台下载打包消息提示
      await sendToBackground(result)
    } catch (error) {
      console.log("页面解析出现异常:", error)
      await showToast(NotificationMessage.CONTROLLER_ERROR, 'error', { duration: 15000, closeButton: true })
    } finally {
      isSubmit.value = false
    }
  }


  return {
    downloadHandle
  }
}

export default useDownloadController

6.6.2 useDownloadService

  • useDownloadService中获取到的资源显然是相互独立的,这意味着资源下载尤其适合并发执行,可以显著提高下载速度 ,继续使用p-limit来进行并发控制,不赘述(详见代码src\composables\useDownloadService.ts)。
ts 复制代码
import JSZip from 'jszip';
import { formatNumber } from '~/utils/common'
import { Result } from '~/types';
import pLimit from 'p-limit';

const useDownloadService = () => {


  // 并发下载提升下载速度
  const downloadSourceAsZip = async (data: Result) => {
    return new Promise<void>(async (resolve, reject) => {
      const zip = new JSZip();
      const limit = pLimit(5); // 限制并发请求数为 5

      try {

        if (data['isReadMe']) {
          const readmeContent = `访问地址: ${data.url || "无"}`;
          zip.file(`!README_${data['title']}.txt`, readmeContent);
        }

        if (data['detailImages'] && data['detailImages'].length > 0) {
          await Promise.all(data['detailImages'].map((element, index) =>
            limit(async () => {
              const response = await fetch(element);
              const blob = await response.blob();
              const filename = element.endsWith('.png') || element.endsWith('.jpg') ? element.split('.').pop() : 'jpg'
              zip.file(`详情图片_${formatNumber(index + 1, 3)}.${filename}`, blob);
            })
          ));
        }

        if (data['mainImages'] && data['mainImages'].length > 0) {
          await Promise.all(data['mainImages'].map((element, index) =>
            limit(async () => {
              const response = await fetch(element);
              const blob = await response.blob();
              zip.file(`主图图片_${formatNumber(index + 1, 3)}.jpg`, blob);
            })
          ));
        }

        if (data['skus'] && data['skus'].length > 0) {
          let n = 0;
          await Promise.all(data['skus'].map((element) =>
            limit(async () => {
              if (element['label']) {
                if (element['url']) {
                  // 如果 URL 存在,下载图片并添加到 ZIP
                  const response = await fetch(element['url']);
                  const blob = await response.blob();
                  zip.file(`sku_${formatNumber(n + 1, 3)}_${element['label']}.jpg`, blob);
                } else {
                  // 如果 URL 不存在,添加一个空的文本文件
                  zip.file(`sku_${formatNumber(n + 1, 3)}_${element['label']}(无sku图片).txt`, '');
                }
                n += 1;
              }
            })
          ));
        }

        if (data['mainVideoUrl']) {
          const response = await fetch(data['mainVideoUrl']);
          const blob = await response.blob();
          zip.file(`主图视频.mp4`, blob);
        }

        if (data['detailVideoUrl']) {
          const response = await fetch(data['detailVideoUrl']);
          const blob = await response.blob();
          zip.file(`详情视频.mp4`, blob);
        }

        const content = await zip.generateAsync({ type: "blob" });
        const reader = new FileReader();
        reader.onloadend = function () {
          const dataUrl = reader.result;
          if (dataUrl && typeof dataUrl === 'string') { // 确保 dataUrl 是 string 类型
            chrome.downloads.download({
              url: dataUrl,
              filename: `${data['title']}.zip`,
              saveAs: false,
              conflictAction: "overwrite"
            });
            resolve(); // 下载成功,解析 Promise
          } else {
            reject(new Error('生成下载链接失败'));
          }
        };
        reader.readAsDataURL(content);
      } catch (error) {
        reject(error);
      }
    });
  };


  return {
    downloadSourceAsZip,
  };
};

export default useDownloadService;

📌 7. background

background的逻辑非常简单,调用各种封装好的钩子按流程执行即可(详见代码:src\background\main.ts

ts 复制代码
import { onMessage } from 'webext-bridge/background'
import { showNotification } from '~/utils/common'
import { Result, Response, JsonValue } from '~/types';
import useDownloadService from '~/composables/useDownloadService';

// only on dev mode
if (import.meta.hot) {
  // @ts-expect-error for background HMR
  import('/@vite/client')
  // load latest content script
  import('./contentScriptHMR')
}


onMessage('downloadResources', async (data): Promise<Response> => {

  try {
    const { downloadSourceAsZip } = useDownloadService();
    const sourceData = data.data as Result

    await downloadSourceAsZip(sourceData)
    showNotification({ title: '资源下载成功', message: "请移步系统默认下载目录查验" });
    return {
      code: 200,
      message: "下载打包请求处理完成",
      data: sourceData as JsonValue,
      source: "background",
      status: 'success'
    }

  } catch (error) {
    console.log("后端出现异常:", error)
    // 处理 error 的类型问题
    const errorMessage = error instanceof Error ? error.message : '未知错误';
    return {
      code: 500,
      message: "下载打包出现异常",
      data: errorMessage,
      source: "background",
      status: 'failed'
    }
  }

})

📌 8. 调试

运行pnpm dev或者pnpm build后,在浏览器选择加载已解压的拓展程序,选择加载extension即可,同时搭配Extensions Reloader插件使用,调试非常方便

📌 9. 填坑

9.1 npx degit antfu/vitesse-webext [your project-name] 下载失败

  • 一般周知的网络问题,从这里 github.com/521xueweiha... 获取IP后,- 修改C:\Windows\System32\drivers\etc\hosts
    • 搜索记事本,以管理员身份打开记事本,选择打开所有类型文件(默认只能打开.txt文件),选择打开C:\Windows\System32\drivers\etc\hosts进行编辑即可

9.2 Shadow DOM模式及其影响

9.2.1 问题描述

  • dev模式下使用useVue的onClickOutside使用点击设置面板外部后关闭设置面板正常工作,build之后该功能不能正常工作(不能正常工作的表现是点击监听需要忽略的没有忽略,不需要忽略的忽略),甚至点击设置面板自己都会关闭,无法正常交互

9.2.2 原因分析

  • 找到src\contentScripts\index.ts发现dev环境shadow DOM设置的是open,而非dev设置的是closed,如果closed的话event是无法正确捕获到准确的点击元素的,这才恍然大悟。
ts 复制代码
const shadowDOM = container.attachShadow?.({ mode: __DEV__ ? 'open' : 'closed' }) || container

9.2.3 Shadow DOM模式最佳实践

  • dev环境建议open(允许外部 JavaScript 通过 element.shadowRoot 访问 Shadow DOM)
  • 生产环境建议close(禁止外部 JavaScript 访问 Shadow DOM,evnet不能准确获取到交互的具体元素只能获取根节点,同时根节点不能展开访问展开访问返回null)

很显然vitesse-webext框架设置及配置是合理的

9.2.4 别人怎么做的

  • 看看沉浸式翻译是怎么做的,出人意料现网竟然是open???所以说有时候最佳实践是一种玄学,最佳归最佳,实践是实践,具体还是要看业务需求

9.2.5 思考与选型

知道原因无非个方式解决问题

arduino 复制代码
💡 生产环境shadow DOM模式修改为open,以增强安全性及封装性

💡 维持生产环境shadow DOM closed现状解决clickOutside的问题

第一种方式非常简单,找到src\contentScripts\index.ts修改代码即可,第二种相对复杂一些,我们需要进一步了解shadow DOM closed特性。当 shadow DOM 处于 closed 模式时,确实无法通过 element.shadowRoot 获取 shadowRoot,也即外部 JavaScript 代码无法直接访问 shadowRoot 及其内部的元素。但是事件(如 click)仍然会正常冒泡出shadowRoot,最终到达document层级(除非 stopPropagation 被显式调用) 。这就意味着可以做一些自定义开发,经过自定义useStaticClickOutsideuseDynamicClickOutside第二种方式也成功落地,具体如下:

9.2.5.1 自定义clickOutside
9.2.5.1.1 useStaticClickOutside

在 useStaticClickOutside中我们用到了这些知识::

✅尽管shadow DOM处于closed状态,click事件仍然可以正常冒泡可以正常传播,因而不会影响事件(即使是shadow DOM本身的事件,除非显式地阻止冒泡)的正常监听。

✅调用了useVue的钩子useMouseInElement,其通过监听 mouseentermouseleave 事件动态输出isOutside,Vue 可以正确绑定其 mouseentermouseleave 事件,并不会去访问shadowRoot,因此不会受到 closed 模式的影响

动态监听 status,避免不必要的事件绑定,减少性能开销

区分 ignore 处理逻辑,支持静态和动态 ignore,提高灵活性

合理的 onMounted / onUnmounted 逻辑,确保资源释放,避免内存泄漏

ts 复制代码
import { useMouseInElement, useEventListener, Fn } from '@vueuse/core'


/**
 * 如果 ignore 元素是不频繁变动的
 * @param target 
 * @param handler 
 * @param status 
 * @param ignore 
 */
export const useStaticClickOutside = (target: Ref<HTMLElement>,
  handler: () => void,
  status: Ref<boolean>,
  ignore: Ref<HTMLElement>[] = []) => {

  // 初始化点击监听器
  let clickEventListener: Fn = () => { }

  const { isOutside: isTargetOutside } = useMouseInElement(target)

  const ignoreStates = ref(new Map())

  watch(ignore, () => {
    const newMap = new Map()
    ignore?.forEach(item => {
      newMap.set(item, useMouseInElement(item).isOutside)
    })
    ignoreStates.value = newMap
  })

  const isOutsideIgnore = computed(() => {
    if (!ignore || ignore.length === 0) return true
    return Array.from(ignoreStates.value.values()).every(state => state.value)
  })

  const onClick = () => {
    if (isTargetOutside.value && isOutsideIgnore) {
      handler()
    }
  }

  // 监听 status 的变化
  watch(status, (newVal) => {
    if (newVal) {
      clickEventListener = useEventListener(document, 'click', onClick);
    } else {
      clickEventListener() // 移除事件监听
    }
  });

  // 根据条件判断是否要动态绑定
  onMounted(() => {
    if (status.value) {
      clickEventListener = useEventListener(document, 'click', onClick)
    }
  })

  // 及时解绑释放资源
  onUnmounted(() => {
    clickEventListener()
  })
}
9.2.5.1.2 useDynamicClickOutside

useDynamicClickOutside中我们用到了这些知识:

✅尽管shadow DOM处于closed状态,click事件仍然可以正常冒泡可以正常传播,因而不会影响事件(即使是shadow DOM本身的事件,除非显式地阻止冒泡)的正常监听。

✅调用了useVue的钩子useMouseInElement,其通过监听 mouseentermouseleave 事件动态输出isOutside,Vue 可以正确绑定其 mouseentermouseleave 事件,并不会去访问shadowRoot,因此不会受到 closed 模式的影响

elementFromPoint(x, y) 可以返回页面上指定坐标处的元素(真实坐标元素) ,即使其处于 shadow DOM 内部。这是浏览器原生 API ,并不依赖 shadowRoot 是否可访问,因此即使 shadow DOMclosed,仍然有办法可以获取视觉上的点击目标

动态监听 status,避免不必要的事件绑定,减少性能开销

区分 ignore 处理逻辑,支持静态和动态 ignore,提高灵活性

合理的 onMounted / onUnmounted 逻辑,确保资源释放,避免内存泄漏

ts 复制代码
/**
 * 如果 ignore 元素是频繁变动的
 * @param target 
 * @param handler 
 * @param status 
 * @param ignore 
 */
export const useDynamicClickOutside = (target: Ref<HTMLElement>,
  handler: () => void,
  status: Ref<boolean>,
  ignore: Ref<HTMLElement>[]  = []) => {



  // 初始化点击监听器
  let clickEventListener: Fn = () => { }

  const ignoreStates = ref(new Map())

  watch(ignore, () => {
    const newMap = new Map()
    ignore?.forEach(item => {
      newMap.set(item, useMouseInElement(item).isOutside)
    })
    ignoreStates.value = newMap
  })

  const isOutsideIgnore = computed(() => {
    if (!ignore || ignore.length === 0) return true
    return Array.from(ignoreStates.value.values()).every(state => state.value)
  })

  // 保存 target 的 isOutside 状态
  const targetState = ref(false)

  // 创建外部监听器
  const createListeners = () => {
    // 创建 target 的监听器
    const { isOutside: isTargetOutside } = useMouseInElement(target)
    targetState.value = isTargetOutside.value
  }

  // 销毁外部监听器
  const destroyListeners = () => {
    targetState.value = false
  }

  // 点击事件处理函数
  const onClick = (event: MouseEvent) => {
    // 如果点击的目标是 target 自身,直接返回
    // 1. 获取 target 元素所在的根节点(可能是 shadowRoot 或 document)
    const shadowRoot = target.value?.getRootNode() as ShadowRoot | null;

    // 2. 先将点击的元素存入 clickedElement
    let clickedElement = event.target as Node;

    // 3. 如果 `shadowRoot` 存在并且支持 `elementFromPoint` 方法,则用它来获取点击位置的元素
    if (shadowRoot?.elementFromPoint) {
      clickedElement = shadowRoot.elementFromPoint(event.clientX, event.clientY) || clickedElement;
    }

    // 4. 如果点击的元素是 `target` 本身,则直接返回(即不触发 `click outside` 逻辑)
    if (target.value?.contains(clickedElement)) {
      return;
    }

    // 如果鼠标在 target 外部
    if (targetState.value) {
      // 如果鼠标在所有 ignore 元素之外,调用 handler
      if (isOutsideIgnore) {
        handler()
      }
    }
  }

  // 监听 status 的变化
  watch(status, (newVal) => {
    if (newVal) {
      createListeners()
      clickEventListener = useEventListener(document, 'click', onClick)
    } else {
      destroyListeners()
      clickEventListener() // 移除事件监听
    }
  })

  // 组件挂载时,根据 status 的初始值决定是否绑定事件
  onMounted(() => {
    if (status.value) {
      createListeners()
      clickEventListener = useEventListener(document, 'click', onClick)
    }
  })

  // 及时解绑释放资源
  onUnmounted(() => {
    destroyListeners()
    clickEventListener()
  })
}

9.3 部分站点UnoCSS样式失控

  • UnoCSS默认rem单位,特定网站会修改rem值导致样式失控,可以通过修改页面样式、px或者vw/vh替换默认类名如w-[24px]等方式解决,项目尽可能使用的是像素单位这个方案

9.4 某东图片下载无法使用

  • 比较信息的.avif格式,该格式不兼容jpg保存后无法打开,经过测试后发现把图片地址后缀.avif去掉就可以了
ts 复制代码
// 去除url尾缀.avif
const removeAvifSuffix = (imgSrc: string): string => {
  return imgSrc.endsWith('.avif') ? imgSrc.replace('.avif', '') : imgSrc
}

9.5 vue-sonner Toast无法触发或者样式丢失

  • 挂载问题 :需要正确地挂载Toaster,是挂载到body而不是shadow DOM上(详见src\contentScripts\index.ts配置)

  • 样式丢失问题 :暂时在content中通过显示定义样式的方式解决了样式丢失的问题(详见src\contentScripts\views\App.vue中地const defaultToastStyle = 'min-h-[36px] min-width-[60px] px-[24px] py-[18px]' ,注意如果使用UnoCSS样式类定义vue-sonner toast样式,必须显式地import 'uno.css'否则无法生效

ts 复制代码
/* 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)
})()

9.6 获取到了地址却下载不了资源

  • 部分网站src地址是以//开头如//img.alicdn.com/abcdefgxyz.jpg需要补全路径否则可能报错
ts 复制代码
// URL补全URL协议头
export const completeUrlProtocol = (url: string): string => {
  return url.startsWith('//') ? `${window.location.protocol}${url}` : url
}

9.7 标题如果带/符号打包下载时默认会基于/创建目录导致资源错乱

  • 格式化数据替换为其他符号如_
ts 复制代码
// 文本信息去除"/",如果存在会影响文件保存:如果存在会当成文件夹处理
export const normalizeText = (text: string, reg: RegExp = /\//g): string => {
  return text.replace(reg, '_').trim()
}

9.8 同步还是异步的问题

  • 有些handle是同步的,有些handle是异步的,或者不同平台的采集器(钩子)是同步的,有些是异步的,调用有的要async+await比较混乱容易出错,建议统一异步,不会影响性能,保持代码的一致性方便管理及测试

📌 10. 效果演示

以上就是使用Vue3借助vitesse-webext做chrome小插件开发的整个过程,插件能在Chrome浏览器、火狐浏览器以及Edge中正常运行,也算是一处代码多处运行吧,整个过程还是挺顺畅的,没有碰到什么大的问题,感兴趣的小伙伴不妨一试。插件还有一些值得优化的地方,后续有时间会继续以系列的形式更新,也欢迎交流。

相关推荐
东东5164 分钟前
果园预售系统的设计与实现spingboot+vue
前端·javascript·vue.js·spring boot·个人开发
怪兽毕设38 分钟前
基于SpringBoot的选课调查系统
java·vue.js·spring boot·后端·node.js·选课调查系统
Amumu121381 小时前
Vue Router(一)
前端·javascript·vue.js
切糕师学AI1 小时前
VSCode 下如何检查 Vue 项目中未使用的依赖?
vue.js·vscode
vortex51 小时前
深度字典攻击(实操笔记·红笔思考)
前端·chrome·笔记
我是伪码农1 小时前
Vue 1.30
前端·javascript·vue.js
利刃大大2 小时前
【Vue】默认插槽 && 具名插槽 && 作用域插槽
前端·javascript·vue.js
风之舞_yjf2 小时前
Vue基础(27)_脚手架安装
vue.js
life码农2 小时前
Linux系统清空文件内容的几种方法
linux·运维·chrome
BYSJMG2 小时前
计算机毕设选题推荐:基于大数据的癌症数据分析与可视化系统
大数据·vue.js·python·数据挖掘·数据分析·课程设计