likes
comments
collection
share

Chrome插件实战开发

作者站长头像
站长
· 阅读数 17

在上一篇文章中,我们介绍了Chrome插件的页面如何写,以及各个组件之间是如何来通信的,得到了不少朋友的积极反馈,大家对Chrome插件的相关内容也都比较感兴趣,也存在着相当大的应用市场;本文就结合项目开发中遇到的的一些实际问题,分享一些开发经验。

从V2升级到V3

  上一篇文章写的时间比较早,使用的还是V2版本的插件,而现在Chrome最新的插件版本也来到的V3,而且V2插件也不能继续在Chrome商店里面发布上架了;因此很多朋友吐槽得比较多的就是,上一篇文章中介绍的插件版本太老了;因此本文我们先来看下如何从V2升级到V3,以及两个版本存在着哪些区别。

  首先Chrome浏览器是从88版本开始支持V3,因此开发之前,首先确定一下自己的浏览器版本是否高于这个版本;第一步,就是修改manifest.json文件,将我们的插件版本号从2改到3。

{
    // "manifest_version": 2,
    "manifest_version": 3,
    // ...
}

注意:这里改的是manifest_version,而不是version字段。

权限配置升级

  在V2版本中,host权限和其他的权限配置一般都统一的放在permissions字段中,而其他一些可选权限则在optional_permissions

// V2
{
  ...
  "permissions": [
    "tabs",
    "bookmarks",
    "https://www.xieyufei.com/",
  ],
  "optional_permissions": [
    "unlimitedStorage",
    "*://*/*",
  ]
  // ...
}

  permissions列出的权限是插件被安装前所需要的;而optional_permissions列出的一些权限,是插件在安装时不需要的,在安装之后可能会要求的权限。

  在V3版本中,权限配置更加精细化,我们需要把主机权限独立到单独的host_permissionsoptional_host_permissions字段中:

// V3
{
  ...
  "permissions": [
    "tabs",
    "bookmarks"
  ],
  "optional_permissions": [
    "unlimitedStorage"
  ],
  "host_permissions": [
    "https://www.xieyufei.com/",
  ],
  "optional_host_permissions": [
    "*://*/*",
  ]
  // ...
}

web_accessible_resources

  web_accessible_resources字段用来控制外部访问插件中的资源,比如content-script脚本或者popup页面中需要展示展示图片资源;在V2版本中,直接定义一个资源列表,那么所有网站都能访问这些资源了:

// V2
{
  // ...
  "web_accessible_resources": [
    "images/*",
    "style/extension.css",
    "script/extension.js"
  ],
  // ...
}

  而来到V3版本,我们需要配置一个对象数组,对象中通过resources和matches更加精细化的配置了哪些外部网站可以访问哪些资源文件。

// V3
{
  // ...
    "web_accessible_resources": [
    {
      "resources": [
        "style/extension.css",
        "script/extension.js"
      ],
      "matches": [
        "https://*.xieyufei.com/*"
      ]
    }
  ],
  // ...
}

  假设我们有一张图片资源在以下插件目录下:

extension-files/
    manifest.json
    content-script.js
    images/
        banner.png

  我们想让content-script.js来在页面呈现图片的地址,需要在manifest.json声明可以被访问到:

{
  "web_accessible_resources": [
    {
      "resources": [ "images/banner.png" ],
      "matches": [ "*" ]
    }
  ],
}

  然后在content-script.js中调用Chrome插件的chrome.runtime.getURL函数来获取图片的地址,图片的地址看起来可能是这样的:

chrome-extension://<extension-UUID>/images/banner.png

这里的extension-UUID并不是插件的ID,而是一个随机生成的唯一id。

  我们在匹配资源文件的路径时,面对多个文件匹配,也可以使用通配符:

{
  "web_accessible_resources": [
    {
      "resources": [ "images/*.png" ],
      "matches": [ "*" ]
    }
  ],
}

background后台

  background后台的升级也是Chrome插件更新的重要特性之一,使用了Service Worker替代了原来的Background page。在V2版本中,我们使用background.scripts可以配置多个js,或者使用background.page配置一个后台页面:

// V2
{
  "background": {
    "scripts": ["js/script1.js", "js/script2.js"],
    // or  "page": "background.html"
    "persistent": true
  },
}

  persistent: true指定了脚本一直在后台运行,直到插件被禁用或者卸载,这样就导致占用了大量的内存;因此V3废弃了scripts和page;如果我们还是指定这两者,Chrome就会报下面错误,直接就不让我们运行插件了,

错误
The "background.scripts" key cannot be used with manifest_version 3. Use the "background.service_worker" key instead. 无法载入清单。

  V3版本升级改用了service_worker字段代替原来scripts和page,确保插件不会一直占用浏览器的资源,仅在需要时才运行,从而节省资源:

//V3
{
  "background": {
    "service_worker": "js/background.js"
    // 移除了 "persistent": true
  },
}

service_worker字段不是一个数组,只支持字符串格式。

  同时V3版本升级也让background.js支持了模块化开发,我们可以在里面直接import本地的方法,让我们能够不用依赖打包的方式进行模块化开发,使用方式也很简单,在background添加type属性即可:

// manifest.json
{
  "background": {
    "service_worker": "js/background.js",
    "type": "module"
  },
}

  我们在background.js中就可以使用import导入本地模块:

// background.js
import { add } from "./utils.js";
chrome.runtime.onInstalled.addListener(() => {
  console.log("测试插件已经安装", add(2, 4));
});

// utils.js
export function add(a, b) {
  return a + b;
}

  同时,由于background不再支持page页面配置background.html,因此也无法调用window对象上的XMLHttpRequest来构建ajax请求;也就是说我们不能像V2版本一样,在background.html中使用jQuery的$.ajax来发送请求了,而是需要使用fetch函数来获取接口数据。

  由于service workers是短暂的,在不使用时会终止,这意味着它们在整个浏览器插件运行期间会不断的启动、运行和终止,也就是不稳定的;因此我们可能需要对V2中background.js的代码逻辑进行一些改造,以往我们会习惯将一些数据直接存储到全局变量,比如像下面这样:

// V2 background.js
let saveUserName = "";

// 其他页面,比如content-script或者popup中存储数据
chrome.runtime.onMessage.addListener(({ type, name }) => {
  if (type === "set-name") {
    saveUserName = name;
  }
});

// 点击popup时展示数据
chrome.action.onClicked.addListener((tab) => {
  // 这里saveUserName可能为空字符串
  console.log(saveUserName, "saveUserName");
});

  当我们运行项目时发现,全局变量saveUserName在某些情况下获取到的数据变成空字符串,存储的数据直接消失了;笔者在项目调试中刚开始经常会遇到这种神奇的问题,调试的值跟实际的值不一样,随之消失的还有笔者的信心。

Chrome插件实战开发

  因此在V3中,需要对这种全局存储的变量数据进行改造,改造的方式也很简单,就是将数据持久化保存到storage中,需要用到的地方随用随取:

// V3 service worker
chrome.runtime.onMessage.addListener(({ type, name }) => {
  if (type === "set-name") {
    chrome.storage.local.set({ name });
  }
});

chrome.action.onClicked.addListener(async (tab) => {
  const { name } = await chrome.storage.local.get(["name"]);
  chrome.tabs.sendMessage(tab.id, { name });
});

actions升级

  有小伙伴也许发现了,我们上面使用了chrome.action.onClicked来注册点击事件,而不是原来的chrome.browserAction.onClicked

  由于历史原因,之前将插件的图标分为pageActionbrowserAction,两者的区别在于browserAction始终都显示,更像我们现在的插件图标逻辑;而pageAction则比较特殊,只有当某些特定的页面打开时才会显示图标。

Chrome插件实战开发

  而V2版本两者的区分界限已经较为模糊了,区别不是很大;但是在manifest.json中配置还是有区分,常用的就是browser_action:

// V2
{
  "page_action": { ... },
  "browser_action": {
    "default_popup": "popup.html"
  }
}

  升级到V3版本,直接统一为同一个action,不需要再区分:

// V3
{
  "action": {
    "default_title": "插件标题",
    "default_popup": "popup.html",
    "default_icon": {
      "16": "/images/get_started16.png",
      "32": "/images/get_started32.png",
    },
    "icons": {
      "16": "/images/get_started16.png",
      "32": "/images/get_started32.png",
    }
  },
}

需要注意的是:如果注册了popup.html的页面,则chrome.action.onClicked点击事件注册后并不会被执行。

  我们在绑定chrome.action事件的地方也需要进行统一:

// V2 
chrome.browserAction.onClicked.addListener(tab => { ... });
chrome.pageAction.onClicked.addListener(tab => { ... });

// V3
chrome.action.onClicked.addListener(tab => { ... });

CSP

  内容安全策略(Content Security Policy,简称CSP),是在manifest.json中配置的,用于限制扩展可以从哪些源加载代码,比如script标签可以从哪些域名地址加载CDN,或者禁止eval()等可能不安全的函数;在V2版本中,默认是一个字符串配置:

// V2
{
  "content_security_policy": "default-src 'self'"
}

  升级到V3版本,content_security_policy字段依然被保留,支持另外两个属性:extension_pages和sandbox:

// V3
{
  "content_security_policy": {
    "extension_pages": "default-src 'self'",
    "sandbox": "..."
  }
}

  default-src 'self'表示默认所有类型的引用文件(js文件、html文件)都是应该在插件包内的;如果我们想要支持从某个域名地址引入js文件,在V2中我们会看到下面的写法:

// V2
{
  "content_security_policy": "script-src 'self' https://xieyufei.com; object-src 'self'"
}
// 或者支持子域名
{
  "content_security_policy": "script-src 'self' https://*.xieyufei.com; object-src 'self'"
}

  但V3中不支持这样的写法,不允许从某个域名地址引入文件。

API调用升级

  我们在调用chrome API的地方,也有一些需要进行升级改造的,比如上面的chrome.action:

// V2 
chrome.browserAction.onClicked.addListener(tab => { ... });
chrome.pageAction.onClicked.addListener(tab => { ... });

// V3
chrome.action.onClicked.addListener(tab => { ... });

  在获取资源地址的时候,也需要将chrome.extension.getURL替换成chrome.runtime.getURL

// V2
chrome.extension.getURL("images/img.png");

// V3
chrome.runtime.getURL("images/img.png");

  V3中,执行script-content的api函数executeScript也从tabs,升级到了scripting;因此我们还需要在manifest.json中添加scripting权限才能调用;同时,执行的脚本也从原来的单个文件,变成可以接收多个文件:

// V2
chrome.tabs.executeScript(
  tab.id,
  {
    file: 'content-script.js'
  }
);

// V3
chrome.scripting.executeScript({
  target: {tabId: tab.id},
  files: ['content-script.js']
});

  insertCSS()removeCSS()也从tabs升级到了scripting

// V2
chrome.tabs.insertCSS(tab.id, injectDetails, () => {
  // callback code
});

// V3
const insertPromise = await chrome.scripting.insertCSS({
  files: ["style.css"],
  target: { tabId: tab.id }
});

service worker异步返回数据

  我们在实际项目中,有时候会需要service worker异步返回一些数据,比如请求接口后返回一些接口数据等:

// content-script.js
chrome.runtime
  .sendMessage({
    type: 'get-status',
  })
  .then((res) => {
    // 对res处理
  })

// background.js
chrome.runtime.onMessage
  .addListener(async ({ type }, sender, sendResponse) => {
    if (type === 'get-status') {
      fetch('XXX/list.json').then(res=>{
        sendResponse(res)
      });
    }
  })

  上面的代码中在content-script.js发送消息到background中,虽然这里我们虽然是在then中返回了res,或者使用async/await;但是很遗憾,在content-script.js接收到的res还是undefined,我们需要对background代码进行改造

// background.js
chrome.runtime.onMessage
  .addListener(async ({ type }, sender, sendResponse) => {
    if (type === 'get-status') {
      fetch('XXX/list.json').then(res=>{
        sendResponse(res)
      });
      // 这里添加了返回true
      return true;
    }
  })

  在onMessage回调函数里返回true,告诉Chrome我们想要异步发送响应。

插件和原生页面通信问题

  我们有时候会遇到需要插件去和原生Web页面进行通信情况,这里的原生Web页面页面指的并不是content-script.js或者popup.html页面,一般也是我们开发的网站页面;比如在原生Web页面页面中,需要判断是否安装了插件,没有安装插件的话显示下载插件的跳转链接;或者点击原生页面上的某一个按钮,将数据保存到插件中来等等,就需要涉及插件和原生Web页面页面的通信问题。

  这里有几种实现通信的方式,第一种最简单的方式就是通过隐藏的dom节点,比如安装插件后,通过content-script.js在页面上放置一个隐藏的dom,将插件信息放到放到dom节点上,这样的缺点也很明显,只能传输一些简单的数据,且不能进行双向通信。

  第二种方式,通过插件的id,从原生Web页面想插件发送消息,首先需要配置在manifest.json中配置externally_connectable字段,来声明哪些Web页面可以通过这种方式,和插件建立链接:

// manifest.json
{
  "externally_connectable": {
    "matches": ["https://*.fill-you-web-url.com/*"]
  },
}

  externally_connectable还可以指定ids字段,用来指定需要通信的其他Chrome插件;配置完成然后就可以在我们的Web页面里添加发送消息的代码了:

// 插件ID
const extensionId = "iodjapnffldobobfdaoobinimjofgejm";

// 向Chrome扩展发送请求
chrome?.runtime?.sendMessage(
  extensionId,
  {
    type: "pageMsg",
    msg: "hello i am from origin",
  },
  (response) => {
    console.log("res data", response);
  }
);

  这里如果我们没有配置上面的externally_connectable字段,浏览器是不会在我们的页面上注入chrome.runtime.sendMessage方法的,因此我们需要对这个函数进行异常判断,否则页面就会报错。

// background.js 接收原生Web页面消息
chrome.runtime.onMessageExternal.addListener(
  (request, sender, sendResponse) => {
    if (request.type === "pageMsg") {
      sendResponse('res msg');
    } else {
      sendResponse("received");
    }
  }
);

  第三种方式,我们可以通过window.postMessage进行通信,window.postMessage一般用在多个页面之间通信,当然,我们的content-script.js和原生Web界面是同源的,更能直接通信了;两者的发送方式和接收方式在代码上都是一样的,这里也不再进行区分:

// 页面初始化话进行监听
window.addEventListener('message', (ev) => {
  if (ev.source != window) {
    return;
  }
  if (ev.data) {
    const { type, saveData } = ev.data;
  }
})

// 点击发送消息
const clickSend = ()=>{
  window.postMessage(
    {
      type: 'myTestPostMsg',
      saveData: {
        title: 'XXX',
        version: 'QQQ'
      },
    },
    '*'
  );
}

  这样我们不需要获取插件的ID也能通信了,不过我们在监听message消息时会看到各种各样插件或者页面之间传递的消息,因此我们对传输数据的命名方式上差异化,可以定义一些独特的前缀,避免和其他页面产生不必要的冲突。