用Vue实现一个谷歌浏览器搜索扩展

1,910 阅读8分钟

前言

在平时的工作机中,一般安装了两个浏览器,一个最新版的 Chrome 专用于前端开发,一个 360极速浏览器 用于日常事务处理。但由于某些原因,工作机不再允许安装 360浏览器 ,但是 Chrome浏览器 中缺少了一个非常便捷的功能:工具栏和右键中的自定义引擎搜索,缺少了这个功能非常难受,感觉搜索的效率直线下降。

Chrome 扩展市场中倒是有一些类似功能的插件,但是用起来总是不如意。恰逢疫情突然变重,过年没办法回老家,窝在武汉手写一个浏览器扩展 Rummage 来实现这些功能。

最近刚刚谷歌扩展市场审核通过,所以可以直接安装:Rummage,访问不了的话可以下载 crx 拖到浏览器中:magee.lanzous.com/iVZ89moyubg 密码:2021,总结下开发的过程。

产品设计

分析下需求,核心需求主要是三个:

  1. 可自定义配置搜索引擎
  2. 在工具栏中可以输入内容并选择引擎后搜索
  3. 页面中选中文本后,右键菜单中选择引擎搜索指定文本

锦上添花的需求如下:

  1. 自定义配置搜索引擎时,可多配置一些功能:新页面打开,无痕模式打开,默认搜索引擎可以被动态修改
  2. 显示搜索引擎的 Favicon,便于识别
  3. 工具栏中能保存并回显搜索记录
  4. 导入导出配置
  5. 实现国际化

调研

需求分析完了,但是现在有一个问题,我从来没开发过浏览器扩展,两眼一抹黑。搜了很多资料并看了官方文档后总结如下:

扩展文档

如果英文水平不错,可以直接官网文档:developer.chrome.com/docs/extens…

另外,有大佬之前详细的总结过的一篇博客——【干货】Chrome 插件(扩展)开发全攻略

UI 框架

大致学会了扩展的开发流程,核心就是 manifest.json,通过这个配置文件指向需要的文件,另外还有很多专用的 API 用于和浏览器交互。

所以在开发中,只要能在打包时生成对应文件,就可以开发和打包分开,也就可以引入体积较大的 UI框架 来敏捷开发。但是另一个问题来了,我对 Webpack 玩的不是很溜,自己配置一个太费功夫。

功夫不负有心人,多番搜索发现了 Vue 的一个小众 UI框架 —— Quasar,这个框架不仅样式精美,它的 CLI 中自带一个浏览器扩展开发模式 Quasar Bex,可以自动生成一个与 src 层级并列的 src-bex 文件夹,不管是 manifest.json 还是其他文件都已经配置妥当,只需要按照自己的需要来开发就可以了。

开发

首先给插件起个名叫“翻查”,英文名 “Rummage”,其次扩展还需要个图标,在 Iconfont 上搜了一个挺好看的图标。开发过程中其实只需要三个部分:

  1. 点击扩展图标后的 Popup 弹出页(需先配置 manifest.json 中的 browser_action.default_popup
  2. 内置页面:右键选扩展图标后点击选项,新打开的扩展配置和说明页面(需先配置 manifest.json 中的 options_page
  3. 页面中点击右键后的菜单配置(需先配置 manifest.json 中的 backgroundpermissions

Quasarbex 模式已经配置好,只需要将路由与 manifest.json 文件匹配即可。

下面是 vue-router 的配置:

const routes = [
  {
    path: '/',
    component: () => import('layouts/MainLayout.vue'),
    children: [
      { path: '', component: () => import('pages/Index.vue') },
      { path: 'options', component: () => import('pages/Options.vue') },
      { path: 'about', component: () => import('pages/about.vue') },
    ],
  },
  {
    path: '/popup',
    component: () => import('pages/Popup.vue'),
    children: [],
  },
  {
    path: '*',
    component: () => import('pages/Error404.vue'),
  },
];

export default routes;

对应的 manifest.json 配置:

{
  // ...
  "options_page": "www/index.html#/options",
  "browser_action": {
    "default_title": "__MSG_ext_title__",
    "default_popup": "www/index.html#/popup"
  }
  // ...
}

浏览器端保存 Favicon

前两种页面的开发就是很普通的 Vue 页面的开发方式,唯一多费了脑筋的地方是保存搜索引擎的 favicon 上。

最后的实现步骤是如下方式:

  1. 先请求给定的链接,再用浏览器自带的 DOMParser API 来解析 DOM
  2. 判断页面的 <head /> 中是否有 rel 属性为 "shortcut icon""icon" <link/> 标签,假如有则保存 faviconurl
  3. 如果没有则将 URL 设为协议:域名/favicon
  4. 统一规范化 URL
  5. 用 Image 对象请求 URL 后,使用 Canvas 加载图片元素。
  6. 将图片导出成为 Base64 格式。

具体的代码写的有点乱,如下所示:

/**
 * @description: 获取指定url的favicon链接
 * @param {String} url
 * @return {String} url
 */
const getFaviconUrl = async (url) => {
  if (!isValidHttpOrHttpsUrl(url)) {
    return null;
  }

  try {
    url = new URL(url).origin;
    let href = await getHref(url);
    let pathFormated = formatHref(href, url);
    return pathFormated;
  } catch (_) {
    return null;
  }
};

/**
 * @description: 校验url
 * @param {string} string
 * @return {*}
 */
const isValidHttpOrHttpsUrl = (string) => {
  let url;
  try {
    url = new URL(string);
  } catch (_) {
    return false;
  }

  return url.protocol === 'http:' || url.protocol === 'https:';
};

/**
 * @description: 从 DOM 的 head 中 获取 link 指向的url
 * @param {String} url
 * @return {String} url
 */
const getHref = async (url) => {
  try {
    let res = await fetch(url);
    let resText = await res.text();
    let resHtml = new DOMParser().parseFromString(resText, 'text/html');
    let linkHtml =
      resHtml.querySelector('link[rel="icon"]') ??
      resHtml.querySelector('link[rel="shortcut icon"]');
    let href = linkHtml.getAttribute('href');
    return href;
  } catch (_) {
    return null;
  }
};

/**
 * @description: 规范化 favicon 的 URL 链接
 * @param {String} rawHref
 * @param {String} url
 * @return {String} 返回 favicon 的完整链接
 */
const formatHref = (rawHref, url) => {
  try {
    let urlObj = new URL(url);
    if (rawHref == null) {
      return `${urlObj.origin}/favicon.ico`;
    }
    // start with http or https
    if (rawHref.startsWith('http')) {
      return rawHref;
    }
    // start with //
    if (rawHref.startsWith('//')) {
      return `${urlObj.protocol}${rawHref}`;
    }
    // start with /
    if (rawHref.startsWith('/')) {
      return `${urlObj.origin}${rawHref}`;
    }

    // default, root path + /favicon.ico
  } catch (_) {
    return null;
  }
};

/**
 * @description: 将 IMG元素 转为 base64
 * @param {IMGElement} imgElement
 * @param {Number} width 宽
 * @param {Number} height 高
 * @return {String} base64
 */
const getBase64Image = (imgElement, width, height) => {
  try {
    //width、height调用时传入具体像素值,控制大小 ,不传则默认图像大小
    let canvas = document.createElement('canvas');
    canvas.width = width
      ? width
      : imgElement.width <= 20
      ? imgElement.width
      : 20;
    canvas.height = height
      ? height
      : imgElement.height <= 20
      ? imgElement.height
      : 20;

    let ctx = canvas.getContext('2d');
    ctx.drawImage(imgElement, 0, 0, canvas.width, canvas.height);
    let dataURL = canvas.toDataURL('image/gif', 0.8);
    return dataURL;
  } catch (_) {
    return null;
  }
};

/**
 * @description: 将 图片URL 转为 base64
 * @param {String} imgUrl
 * @return {String} base64
 */
const getBase64FromFaviconUrl = async (imgUrl) => {
  if (imgUrl == null) {
    return null;
  }
  try {
    let imgElement = new Image();
    imgElement.crossOrigin = '';
    imgElement.src = imgUrl;
    let imgPromise = new Promise((resolve, reject) => {
      if (imgUrl) {
        imgElement.onload = function () {
          resolve(getBase64Image(imgElement));
        };
        imgElement.onerror = function () {
          reject();
        };
      }
    });
    return await imgPromise;
  } catch (_) {
    return null;
  }
};

/**
 * @description: 传入链接,返回对应网站的base64
 * @param {String} url
 * @return {String} base64
 */
const getBase64FromUrl = async (url) => {
  // debugger;
  try {
    let faviconUrl = await getFaviconUrl(url);
    let faviconBase64 = await getBase64FromFaviconUrl(faviconUrl);
    return faviconBase64;
  } catch (_) {
    return null;
  }
};

background.js 的模块化

最后一种 background.js 的开发主要是为了能够配置右键菜单。都是对照着谷歌官方文档开发就可以,但是有一个地方比较特殊——模块化。

如果想 importexport 一些公用方法,普通的 import 'XXXX' from 'XXXX.js';的模式是不生效的,得用如下的特殊方法:

模块 js 导出:

// func.js
const funcA = () => {
  console.log('funcA函数执行');
};
const funcB = () => {
  console.log('funcB函数执行');
};
export { funcA, funcB };

background.js 导入:

// background.js
(async () => {
  const funcURL = chrome.runtime.getURL('js/func.js');
  const funcMain = await import(funcURL);

  funcMain.funcA(); // output: funcA函数执行
  funcMain.funcB(); // output: funcB函数执行
})();

存储

对于扩展来说,只是单纯的展示页面,可以用普通页面存储用到的 cookieslocalStorage。但是如果需要与浏览器交互,比如给右键配置菜单,就需要用扩展专用的负责存储的 APIchrome.storage.localchrome.storage.sync

存储chrome.storage.localchrome.storage.syncwindow.localStorage
总最大限制可无限大100KB5MB
单条最大限制可无限大8KB5MB
修改频率限制可无限大8KB1800 次/小时
存储格式可直接存储对象可直接存储对象只可存储字符串
数据同步方式手动导入导出自动跨设备同步手动导入导出
事件支持支持其他页面修改才会触发
可用位置可用于 content 和 background可用于 content 和 background只能用于插件自身页面

写了一些工具方法,把的异步存储操作的回调变为了 async:

const setStorageLocal = async (items) => {
  let result = await new Promise((resolve) => {
    chrome.storage.local.set(items, () => {
      // 通知保存完成。
      console.log('保存成功', items);
      resolve(items);
    });
  });
  return result;
};

const getStorageLocal = async (keys) => {
  return await new Promise((resolve) => {
    chrome.storage.local.get(keys, (items) => {
      console.log('获取成功', items);
      resolve(items);
    });
  });
};

const removeStorageLocal = async (keys) => {
  return await new Promise((resolve) => {
    chrome.storage.local.remove(keys, () => {
      console.log('删除成功', keys);
      resolve(keys);
    });
  });
};

const clearStorageLocal = async () => {
  return await new Promise((resolve) => {
    chrome.storage.local.clear(() => {
      console.log('清空成功');
      resolve(true);
    });
  });
};

发布

Quasar cli 中自带的 bex 打包模式,将插件打包到 dist 中,有 Chrome 版,FireFox 版和未压缩版。

发布到 Google 应用市场需要做一些准备工作,主要是需要以下准备内容:

  1. 如果没有谷歌 Web 的开发者账户,需要准备可支付 5 美元的信用卡来注册开发者。如果开发移动端 APP,注册谷歌 Play 开发者需要 25 美元,相比下页面端还是便宜。
  2. 扩展图标 128x128 像素,png 格式。
  3. 插件名字,根据你扩展的 i18n 支持的数量准备不同语言下的名字。
  4. 插件介绍,与上条类似。
  5. 屏幕截图,1280x800640x400 JPEG24PNG(无 alpha 透明层),每种语言不得多于 5 张,不得少于一张。
  6. 全球通用的屏幕截图:不区分语言,条件与上一条相同。
  7. 小型宣传图块,不区分语言,440x280JPEG24PNG(无 alpha 透明层)。
  8. 大型宣传图块,不区分语言,920x680JPEG24PNG(无 alpha 透明层)。
  9. 顶部宣传图块,不区分语言,1400x560JPEG24PNG(无 alpha 透明层)。
  10. manifest.json 中需要启用的权限,每种权限都需要说明用途。

全都填完后就可以提交审核,大概需要一周的时间。发布到 EDGE 或者 FireFox 也是类似的步骤。


前端记事本,不定期更新,欢迎关注!