概述

最近在边学边开发一个 chrome 插件,帮人家爬取网站的内容,所以总结下开发时遇到的问题,怎么从零开始开发一个 chrome 插件,实现爬取网站内容、脚本间数据通信、脚本权限和 Tab 的管制等性能。

其实 chrome 的文档有很具体的疾速开始的教程,有趣味的能够看官网教程:Getting started。本篇总结的内容,次要记录我在开发插件时遇到的问题,会比官网的疾速教程波及的内容更多点。

筹备

咱们要做一个 chrome插件,性能是依据网站的列表,点击对应的详情,获取外面的具体内容,最初在一个自定义页面查看爬取的内容。

我这里曾经把上面的内容整合成一个我的项目了,如果嫌麻烦,能够间接 clone 这个仓库:chrome 插件 demo

首先须要新建一个我的项目,增加对应的文件,文件构造如下:

.├── data.json # list.html 和 detail.html 应用到的数据├── demo│   ├── background.js│   ├── common.js│   ├── getDetail.js│   ├── getList.js│   ├── jquery.min.js│   ├── manifest.json│   ├── popup.html│   └── popup.js├── detail.html # 要爬取的详情页└── list.html # 要爬取的列表页

整个插件的开发,下面的文件都会用到,前面会一一对其补充内容,用到哪个文件,再解释其作用。

筹备爬取的网页内容

我这里截取了一部分的数据,但也足够应用,批改 data.json

const DATA = {    "array": [        {            "name": "Amy Rodriguez",            "id": "63000019860511796X"        },        {            "name": "Jose Harris",            "id": "14000019891212344X"        },        {            "name": "Kenneth Jones",            "id": "150000200908039343"        },        {            "name": "Elizabeth Wilson",            "id": "630000197205295754"        },        {            "name": "Elizabeth Hall",            "id": "32000020040726080X"        },        {            "name": "Frank Garcia",            "id": "410000199606123660"        },        {            "name": "George White",            "id": "320000198909170249"        },        {            "name": "Susan Walker",            "id": "610000198001276878"        },        {            "name": "Deborah Brown",            "id": "810000200504028347"        },        {            "name": "Angela Hernandez",            "id": "630000197207149056"        },        {            "name": "Jason Hall",            "id": "620000198209225078"        },        {            "name": "Robert Clark",            "id": "350000197212127591"        },        {            "name": "Shirley Thomas",            "id": "50000020100911686X"        },        {            "name": "Mark Miller",            "id": "650000200506212412"        },        {            "name": "Dorothy Jones",            "id": "610000200708203138"        },        {            "name": "Gary Lopez",            "id": "140000198703280844"        },        {            "name": "Margaret Davis",            "id": "520000200206107072"        },        {            "name": "Steven Allen",            "id": "330000201501316327"        },        {            "name": "Brenda Lewis",            "id": "520000199709270757"        }    ]}

list.html 有一些我本人测试的逻辑,不必关怀这个文件,批改 list.html

<!DOCTYPE html><html lang="en"><head>  <meta charset="UTF-8">  <meta http-equiv="X-UA-Compatible" content="IE=edge">  <meta name="viewport" content="width=device-width, initial-scale=1.0">  <title>Document</title>  <style>    .item {      display: flex;      width: 500px;    }    .page {      display: flex;    }    .prev, .next {      width: 50px;      height: 50px;      display: flex;      align-items: center;      justify-content: center;      margin: 20px;    }  </style>  <script src="./data.json"></script></head><body>  <div class="wrap">    <div class="content"></div>    <div class="page">      <button class="prev">上</button>      <div class="pageNum">1</div>      <button class="next">下</button>    </div>  </div>  <script>    const oContent = document.querySelector('.content')    const oNext = document.querySelector('.next')    const page = 10    // const total = Math.ceil(DATA.array / page)    const total = 10    let pageNum = 1    let isPending = false    function insertItem(pageNum) {      const time = (300 - 100) * Math.random() + 100      console.log(time)      isPending = true      setTimeout(() => {        const dom = DATA.array.filter((_, i) => i >= ((pageNum - 1) * page) && i < (pageNum * page)).map((v) => {          return `<div class="item">            <div class="item-title">${v.name}</div>            <a class="item-id" href="./detail.html?id=${v.id}" target="_blank">${v.id}</a>          </div>`        }).join('')        oContent.innerHTML = dom        updateDom()        isPending = false      }, time);    }    function updateDom() {      document.querySelector('.pageNum').innerHTML = pageNum    }    insertItem(pageNum)    document.querySelector('.prev').addEventListener('click', () => {      if (isPending) return      oNext.removeAttribute('disabled')      pageNum -= 1      if (pageNum < 1) pageNum = 1      insertItem(pageNum)    })    oNext.addEventListener('click', () => {      if (isPending || oNext.getAttribute('disabled')) return      pageNum += 1      oNext.removeAttribute('disabled')      if (pageNum > total) {        pageNum = total        oNext.setAttribute('disabled', 'disabled')      }      insertItem(pageNum)    })  </script></body></html>

批改 detail.html

<!DOCTYPE html><html lang="en"><head>  <meta charset="UTF-8">  <meta http-equiv="X-UA-Compatible" content="IE=edge">  <meta name="viewport" content="width=device-width, initial-scale=1.0">  <title>Document</title>  <script src="./data.json"></script></head><body>  <div class="wrap">  </div>  <script>    const { search } = location    const id = search.replace('?id=', '')    function getData() {      return DATA.array.find(v => v.id === id)    }    setTimeout(() => {      const data = getData()      const oWrap = document.querySelector('.wrap')      oWrap.innerHTML = `<div class="name">${data.name}</div><div class="id">${data.id}</div>`    }, (1000 - 500) * Math.random() + 1000);  </script></body></html>

list.htmldetail.html 为了模仿更实在的状况,我采纳异步渲染数据的模式。

mainfest.json 配置

首先咱们批改 manifest.json

{  "name": "Test",  "description": "chrome 插件",  "version": "1.0",  "manifest_version": 3,  "background": {    "service_worker": "background.js"  },  "permissions": [    "storage",    "tabs"  ],  "action": {    "default_popup": "popup.html"  },  "content_scripts": [    {      "matches": ["file:///*/chrome-extension-demo/detail.html*"],      "js": [ "getDetail.js"]    },    {      "matches": ["file:///*/chrome-extension-demo/list.html"],      "js": ["getList.js"]    }  ]}

namedescriptionversion 这些字段,就是字面意思,对该插件的一些信息申明。

background:相当于整个插件的外围,治理事件。

permissions: 通知浏览器你须要用到什么权限。

popup:弹窗页面,其实都是 html,只不过它的关上模式是通过点击在浏览器右上方的 icon,一个弹窗的模式。

content_scripts:接管一个数组,依据你的 matches 注入对应的 js 到页面内执行。

导入到 chrome

咱们当初能够把插件导入到 chrome,在浏览器输出 chrome://extensions/ 并关上,会看到下图:

咱们关上开发者模式,并点击加载扩大程序,抉择咱们的插件根目录

会看到插件导入胜利,当初是启动状态,而后在浏览器盯住显示:

当初插件导入胜利了。

根本运行原理

咱们的筹备工作实现了,在开发前要明确咱们 chrome 插件根本的运行逻辑:

简略来说:

当咱们将插件导入到 chrome 并启动后,background.js 就会执行。

当咱们点击右上角的 icon 时,就会渲染 popup.html,当弹窗敞开后,popup.html 就会销毁。

当咱们拜访网页时,如果命中 manifest.jsoncontent_scripts 时,就会执行对应的脚本,每当刷新页面,都会从新执行脚本,这个要留神。

来自于 chorme 官网文档:https://developer.chrome.com/...

这图是来自于官网文档的,是对于不同的脚本之间是如何通信的,能够看到整个通信的流程,外围是 background.js

总结一下,popup 是帮忙咱们更不便地应用插件,不是必须;contentscript 是咱们的解决网页内容的外围,background.js 是用于治理整个插件的事件通信和做一些全局性的操作。

开发

应用 content_scripts 获取列表和详情的内容

第一步,咱们要实现上面的三个性能:

  1. 通过 getList.js 查看列表页的内容
  2. 找到详情链接点击
  3. 在详情页应用 getDetail.js 获取详情内容并存储到插件缓存
  4. 详情页发送后,敞开以后页面

列表页的脚本

批改 getList.js

function checkData(executeNum, callback, maxNum = 10) {  if (executeNum >= maxNum) {    console.log('100秒过来了,但没有查看到内容')    return  }  setTimeout(() => {    if (!!document.querySelector('.item')) {      callback()    } else {      checkData(executeNum + 1, callback)    }  }, 1000);}function getData() {  const items = document.querySelectorAll('.item')  console.log(items)  items.forEach(item => {    item.querySelector('.item-id').click()  })}checkData(0, getData)

咱们逐行剖析,为什么要这样写。

首先,整个 js 文件的代码是没有被嵌套的,但这些生命的函数是不会被注入到页面的全局对象,chrome 会提供相似沙箱的成果进行隔离的。但如果你想在页面的全局对象增加属性或办法,能够间接 widnow.a = 1 进行增加。

该文件有两个个函数,checkDatagetData

先看下 checkData,它是用于检测页面的内容是否合乎咱们须要,因为咱们解决的网页大部分都是异步的,当浏览器执行你的脚本时,你的数据可能还没渲染进去,所以这时候须要一个轮询的逻辑,一直地检测页面内容,是否符合要求

function checkData(executeNum, callback, maxNum = 10) {  if (executeNum >= maxNum) {    console.log('100秒过来了,但没有查看到内容')    return  }  setTimeout(() => {    if (!!document.querySelector('.item')) {      callback()    } else {      checkData(executeNum + 1, callback)    }  }, 1000);}

每次期待 1s ,再次进行检测。咱们检测的根据,就是这个 dom 是否存在,当发现存在时,则只需回调。这里的回调,咱们用的是 getData 函数:

function getData() {  document.querySelectorAll('.item').forEach(item => {    item.querySelector('.item-id').click()  })}

咱们这里是简略解决,间接关上所有的内容详情页,但我集体比拟偏向于设计一个工作队列,按量去执行工作。

到这里,getList.js 工作曾经实现了,咱们再看下 getDetail.js

详情页的脚本

function checkData(executeNum, callback, maxNum = 10) {  if (executeNum >= maxNum) {    console.log('100秒过来了,但没有查看到内容')    return  }  setTimeout(() => {    if (!!document.querySelector('.id')) {      callback()    } else {      checkData(executeNum + 1, callback)    }  }, 1000);}function getData() {  const name = document.querySelector('.name').innerHTML  const id = document.querySelector('.id').innerHTML  chrome.runtime.sendMessage({ data: { name, id }, close: true })}checkData(0, getData)

和列表有点相似,同样是一个检测函数和一个获取数据函数。咱们次要看下这个:

chrome.runtime.sendMessage({ data: { name, id }, close: true })

chrome 这属性,如果没有注入对应的脚本进去,是不会找到的,这行代码的性能是将数据发送给 background

详情页只做一个获取数据并发送给 background 的操作,这里发送了爬取的数据和 closeclose 是通知 background 这次的发送事件,要把对应的 tab 敞开。

存储数据和敞开 Tab

发送事件写好了,当初要增加一个监听事件,这能力获取详情页发送的数据,批改 background.js

chrome.runtime.onMessage.addListener(async (msg, sender) => {  if (msg.close) {    chrome.tabs.remove(sender.tab.id)  }  if (msg.data) {    chrome.storage.sync.get(['data'], function(result) {      chrome.storage.sync.set({ data: [...result.data, msg.data] }, function() {        console.log('设置数据胜利')      });    });  }})

这里增加了一个监听事件,当任何一个页面应用了 sendMessage 都会在这里接管到。

msg.close 存在时,就会依据 sender.tab.id ,应用 chrome.tabs.remove 敞开对应的 tab。

msg.data 存在时,应用 chrome.storage.sync API,进行数据缓存。因为咱们是增量增加,所以每次存储前都须要先获取之前的值。

咱们能够关上 list.html 试试成果。在关上前,要先对插件进行刷新操作,只有你批改了代码,就须要进行刷新。

能够看到当初曾经实现了下面所说的性能,进入列表页,会主动关上详情页,而详情页会爬取内容发送给 backgroundbackground 将数据缓存并敞开对应的 tab。

查看爬取的内容

当初曾经将内容爬取完了,须要查看爬取数据,咱们应用 popup.html 把爬取内容展现进去。

批改 popup.html

<!DOCTYPE html><html lang="en"><head>  <meta charset="UTF-8">  <meta http-equiv="X-UA-Compatible" content="IE=edge">  <meta name="viewport" content="width=device-width, initial-scale=1.0">  <title>Document</title>  <style>    .wrap {      width: 300px;      padding: 24px;    }  </style></head><body>  <div class="wrap">    <button id="show">显示数据</button>    <div class="log"></div>  </div>  <script src="./popup.js"></script></body></html>

批改 popup.js

const btn = document.querySelector('#show')function log(txt) {  document.querySelector('.log')      .appendChild(document.createElement('div'))      .innerText = "> " + txt;}btn.addEventListener('click', () => {  document.querySelector('.log').innerHTML = ''  chrome.storage.sync.get(['data'], function(result) {    (result.data || []).forEach(({ name }) => {      log(name)    })  })  })

这里的逻辑很简略,popup.html 就是弹窗渲染的内容,popup.js 是对应执行的脚本,点击按钮,把 chrome.storage.sync 的数据展现进去。

要留神一点,在结尾有说过,咱们所用的 chrome API 基本上都要在 manifest.json 进行申明:

{  "permissions": [    "storage",    "tabs"  ]}

至此,咱们这个小 demo 曾经开发完了,性能很简略,但曾经笼罩了很多罕用的性能,我这里没有对每个应用到的 API 进行具体解释,只是简略阐明而已,具体的 API 阐明还是要去官网文档查阅。

注意事项

在整个开发插件的过程中,还是遇到不少的问题。

加载第三方库

如果咱们要开发一个略微简单的插件,可能须要引入一些第三方库,这里咱们来演示下,引入 jquery 并在 getDetail.js 应用。首先批改 background.jscontent_scripts

{  "content_scripts": [    {      "matches": ["file:///*/chrome-extension-demo/detail.html*"],      "js": ["jquery.min.js", "getDetail.js"]    },    {      "matches": ["file:///*/chrome-extension-demo/list.html"],      "js": ["getList.js"]    }  ]}

增加了之后,批改 getDetail.jsgetData

function getData() {  const name = $('.name').text()  const id = $('.id').text()  chrome.runtime.sendMessage({ data: { name, id }, close: true })}

刷新插件,运行一下,能够看到数据是失常上报的。

"js": ["jquery.min.js", "getDetail.js"]

这段的配置,当命中对应的配置后,会按程序加载你的 js 文件。

优化通用代码

在开发时,多个文件大部分状况都会呈现通用代码或配置的,但 chrome 插件不反对原生的 import,我这里想到的解决办法有两个:

  1. 应用 webpack 等工具,每次批改完代码,先编译,再刷新应用
  2. 通过 contents_script 可加载多个脚本的特点,先加载通用代码文件,这样后加载的文件都能够应用通用代码文件申明的对象、函数。

第一种这里就不多说了,有点麻烦,但这个是最完满的,完满解决通用代码的问题。

第二种,咱们别离批改 common.jsgetList.jsgetDetail.jsmanifest.json 文件。

批改 common.js

function checkData(checkDomTxt, executeNum, callback, maxNum = 10) {  if (executeNum >= maxNum) {    console.log('100秒过来了,但没有查看到内容')    return  }  setTimeout(() => {    if (!!document.querySelector(checkDomTxt)) {      callback()    } else {      checkData(checkDomTxt, executeNum + 1, callback, maxNum)    }  }, 1000);}

getList.js 全笼罩:

function getData() {  const items = document.querySelectorAll('.item')  items.forEach(item => {    item.querySelector('.item-id').click()  })}checkData('.item', 0, getData)

getDetail.js 全笼罩:

function getData() {  const name = $('.name').text()  const id = $('.id').text()  chrome.runtime.sendMessage({ data: { name, id }, close: true })}checkData('.id', 0, getData)

manifest.json 批改 content_scripts

{  "content_scripts": [    {      "matches": ["file:///*/chrome-extension-demo/detail.html*"],      "js": ["common.js", "jquery.min.js", "getDetail.js"]    },    {      "matches": ["file:///*/chrome-extension-demo/list.html"],      "js": ["common.js", "getList.js"]    }  ]}

咱们刷新下插件和 list.html 页面,能够看到插件还是失常运行的,证实了这种加载通用代码的办法是可行的。

当然这种办法尽管相对来说不便,但也有不少问题:

  1. 当代码量多起来后,不晓得哪个变量是来自哪个文件
  2. 命名抵触问题
  3. background.jspopup.js 不能通过这个办法解决

权限与通信

咱们关注三个对象:backgroundpopup 和页面的 content_scripts,这三者之间是如何通信的。

backgroundpopup 通信

backgroundpopup 发送:

chrome.runtime.sendMessage(...)

backgroundpopup 监听:

chrome.runtime.onMessage.addListener(async (msg, sender) => {})

backgroundpopup 的通信是最简略的,间接应用 chrome.runtimesendMessageonMessage 就行了。

backgroundpopupcontent_scripts 通信

backgroundpopup 发送:

// 这里改成了 tabs,不是 runtimechrome.tabs.sendMessage(tabId, data)

content_scripts 监听:

chrome.runtime.onMessage.addListener(async (msg, sender) => {})

backgroundpopup 不能相似播送那样发送给 content_scripts,只能依据你要发送的那个网页的 tabId ,调用 chrome.tabs.sendMessage

你能够在 background.jspopup 增加对应的代码:

chrome.tabs.query({}, function(tabs){  const tab = tabs.find(v => v.url.includes('list.html'))  if (!tab) return  chrome.tabs.sendMessage(tab.id, 'ok')});

找到对应的要发送的 tab,进行指定发送。

backgroundpopup 监听:

chrome.runtime.onMessage.addListener(async (msg, sender) => {})

content_scripts 发送:

chrome.runtime.sendMessage(...)

content_scripts 之间通信

这里就略微麻烦点,须要通过 background.js 进行转发,咱们在 getList.js 增加发送的事件,在 getDetail.js 进行监听。

getList.js

chrome.runtime.sendMessage('list send')

background.js

chrome.runtime.onMessage.addListener(async (msg, sender) => {  if (msg === 'list send') {    chrome.tabs.query({}, function(tabs){      const tab = tabs.find(v => v.url.includes('detail.html'))      if (!tab) return      chrome.tabs.sendMessage(tab.id, 'to detail')    });  }})

detail.js

chrome.runtime.onMessage.addListener(async (msg, sender) => {  console.log(msg)})

这样就能够了,当 getList.js 发送了 list send 信息后,background.js 会承受到,转发给 detail.js,实现了 content_scripts 之间的通信。

总结

这篇文章,次要总结我在开发时遇到的一些问题,是如何解决的,没有具体的解释为什么这样做,心愿能够给到大家一个参考,解决在开发 chrome 插件时遇到的问题。