chrome插件开发 - tab选项卡管理器

1. 前言

继上周第一次开发Chrome插件github-star-trend之后,我就一直寻思有什么现实问题可以用插件来解决呢?正当我在浏览器中搜索寻找灵感时,打开的众多tab选项卡令我灵光一闪。

咦,为什么不做一个插件用来管理tab呢?每次同时打开过多的tab选项卡时,被挤压的标题总是让我分不清哪个是哪个,查看起来十分不便。于是乎,经过一个周末下午的折腾,我倒腾出这么个东西(gif图可能有点大,请耐心等待...):

preview

2. 准备工作

国际惯例,正式进入主题之前让我们来先了解点预备知识。默默打开Chrome插件的官方文档,直奔我们的Tabs。可以看到它为我们提供了很多方法,而且竟然还有executeScript,这个可以说权限非常大了,不过跟我们这次的需求没啥关系。。。

2.1 query

由于我们的需求是管理tab选项卡,所以首先肯定得获取所有的tab信息。扫了一遍Methods,最相关的就是方法query

Gets all tabs that have the specified properties, or all tabs if no properties are specified.

正如官方介绍,该方法可以根据指定条件返回相应的tabs;且当不指定属性时,可以获得所有的tabs。这恰好满足我们的需求,按照API指示,我在callback中尝试打印出了拿到的tabs对象:

chrome.tabs.query({}, tabs => console.log(tabs));
[
  {
    "active": true,
    "audible": false,
    "autoDiscardable": true,
    "discarded": false,
    "favIconUrl": "https://static.clewm.net/static/images/favicon.ico",
    "height": 916,
    "highlighted": true,
    "id": 25,
    "incognito": false,
    "index": 0,
    "mutedInfo": {"muted":false},
    "pinned": true,
    "selected": true,
    "status": "complete",
    "title": "草料文本二维码生成器",
    "url": "https://cli.im/text?bb032d49e2b5fec215701da8be6326bb",
    "width": 1629,
    "windowId": 23
  },
  ...
  {
    "active": true,
    "audible": false,
    "autoDiscardable": true,
    "discarded": false,
    "favIconUrl": "https://www.google.com/images/icons/product/chrome-32.png",
    "height": 948,
    "highlighted": true,
    "id": 417,
    "incognito": false,
    "index": 0,
    "mutedInfo": {"muted": false},
    "pinned": false,
    "selected": true,
    "status": "complete",
    "title": "chrome.tabs - Google Chrome",
    "url": "https://developers.chrome.com/extensions/tabs#method-query",
    "width": 1629,
    "windowId": 812
  }
]

仔细观察不难发现,两个tab的windowId不同。这是由于我在本地同时打开了两个Chrome窗口,而这两个tab恰好在两个不同的窗口内,所以正好符合预期。

另外idindex, highlightedfavIconUrltitle等字段信息在后文中也起到非常重要的作用,相关的释义都可以在这里查看。

在构思Chrome插件UI时,为了突出当前窗口中的当前tab,我们就必须从上述数据中找出这个tab。由于每个窗口中都有一个tab是highlighted的,所以我们无法直接确定哪个tab是当前窗口的。不过,我们可以这样:

chrome.tabs.query(
  {active: true, currentWindow: true},
  tabs => console.log(tabs[0])
);

根据文档,通过指定activecurrentWindow这两个属性为true,我们就能顺利拿到当前窗口的当前tab。然后再根据tab的windowIdhighlighted进行匹配,我们就能从tabs数组中定位出哪个才是真正的当前tab了。

2.2 highlight

根据上面所述,我们已经可以拿到所有的tabs信息以及确定出哪个tab是当前窗口的当前tab,所以我们可以根据这些数据构建出一个列表。而接下来要做的就是,当用户点击其中某一项时,浏览器就能切换到所对应的tab选项卡。带着这个需求,再次翻阅文档找到了highlight

Highlights the given tabs and focuses on the first of group. Will appear to do nothing if the specified tab is currently active.

chrome.tabs.highlight({windowId, tabs});

根据该API的指示,它需要的是windowId和tab的index,而这些信息都在每个tab实体中可以拿到。不过这里有一个坑需要注意:那就是如果在当前窗口切换到另一个窗口的tab时,虽然另一个窗口的tab得以切换,但是Chrome窗口仍聚焦于当前窗口。所以需要用以下的方法,令另外的那个窗口得到聚焦:

chrome.windows.update(windowId, {focused: true});

2.3 remove

为了增强插件的实用性,我们可以在tabs列表中加入删除指定tab选项卡的功能。而在翻阅文档之后,可以确定remove可以实现我们的需求。

Closes one or more tabs.

chrome.tabs.remove(tabId);

tabId即tab数据中的id属性,因此关闭选项卡的功能实现起来也没有问题。

3. 开工

不同于插件github-star-trend,这次复杂度更高,涉及到更多的交互操作。为此,我们引入reactantdwebpack,不过整体开发起来还是比较容易的,更多的可能还是在于Chrome插件提供的API熟练度。

3.1 manifest.json

{
  "permissions": [
      "tabs"
  ],
  "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
  "browser_action": {
    "default_icon": {
      "16": "./icons/logo_16.png",
      "32": "./icons/logo_32.png",
      "48": "./icons/logo_48.png"
    },
    "default_title": "Tab Killer",
    "default_popup": "./popup.html"
  }
}
  1. 由于这次开发的插件跟tabs相关,所以我们需要在permissions字段中申请tabs权限。
  2. 由于webpack在dev模式下打包会用到eval,Chrome浏览器出于安全策略会报错,因此需要设置content_security_policy使其忽略(如果是prod模式打的包,就不需要设置)。
  3. 本次插件的交互是点击按钮弹出一个浮层,所以需要设置browser_action属性,而其default_popup字段正是我们接下来要开发的页面。

3.2 App.js

该文件是我们的核心文件之一,主要负责tabs数据的获取和处理等维护工作。

根据API文档所示,获取tabs数据是一个异步操作,我们在其回调函数中才能拿到。这也意味着我们的应用一开始应该是处于一个LOADING的状态,拿到数据之后成为OK状态,另外再考虑到异常情况(例如无数据或出错),我们�可以将其定义为EXCEPTION状态。

class App extends React.PureComponent {

  state = {
    tabsData: [],
    status: STATUS.LOADING
  }

  componentDidMount() {
    this.getTabsData();
  }

  getTabsData() {
    Promise.all([
      this.getAllTabs(),
      this.getCurrentTab(),
      Helper.waitFor(300),
    ]).then(([allTabs, currentTab]) => {
      const tabsData = Helper.convertTabsData(allTabs, currentTab);
      if(tabsData.length > 0) {
        this.setState({tabsData, status: STATUS.OK});
      } else {
        this.setState({tabsData: [], status: STATUS.EXCEPTION});
      }
    }).catch(err => {
      this.setState({tabsData: [], status: STATUS.EXCEPTION});
      console.log('get tabs data failed, the error is:', err.message);
    });
  }

  getAllTabs = () => new Promise(resolve => chrome.tabs.query({}, tabs => resolve(tabs)))

  getCurrentTab = () => new Promise(resolve => chrome.tabs.query({active: true, currentWindow: true}, tabs => resolve(tabs[0])))

  render() {
    const {status, tabsData} = this.state;
    return (
      <div className="app-container">
        <TabsList data={tabsData} status={status}/>
      </div>
    );
  }
}

const Helper = {
  waitFor(timeout) {
    return new Promise(resolve => {
      setTimeout(resolve, timeout);
    });
  },
  convertTabsData() {}
}

思路很简单,就是在didMount的时候获取tabs数据,不过我们在这里用到Promise.all来控制异步操作。

由于获取tabs数据这一操作是异步的,不同电脑,不同状态,不同tab数量时该操作的耗时都可能不同,所以为了更好的用户体验,我们可以在一开始用antd的Spin组件来充当占位符。需要注意的是,如果获取tabs数据非常快,Loading动画会有一闪而过的感觉,并不十分友好。因此我们用个300ms的promise搭配Promise.all使用,可以保证至少300ms的Loading动画。

接下来就是拿到tabs数据之后的convert工作。

Chrome提供的API获取到的数据是一个扁平的数组,不同窗口内的tab也被混在同一个数组内。我们更希望能按窗口进行分组,这样在浏览和查找时对用户更直观,操作更方便,用户体验更好。所以我们需要对tabsData进行一次转换:

data convert
convertTabsData(allTabs = [], currentTab = {}) {

  // 过滤非法数据
  if(!(allTabs.length > 0 && currentTab.windowId !== undefined)) {
    return [];
  }

  // 按windowId进行分组归类
  const hash = Object.create(null);
  for(const tab of allTabs) {
    if(!hash[tab.windowId]) {
      hash[tab.windowId] = [];
    }
    hash[tab.windowId].push(tab);
  }

  // 将obj转成array
  const data = [];
  Object.keys(hash).forEach(key => data.push({
    tabs: hash[key],
    windowId: Number(key),
    isCurWindow: Number(key) === currentTab.windowId
  }));

  // 进行排序,将当前窗口的顺序往上提,保证更好的体验
  data.sort((winA, winB) => {
    if(winA.isCurWindow) {
      return -1;
    } else if(winB.isCurWindow) {
      return 1;
    } else {
      return 0;
    }
  });

  return data;
}

3.3 TabList.js

根据App.js中的设计,我们可以先搭起代码的骨架:

export class TabsList extends React.PureComponent {

  renderLoading() {
    return (
      <div className={'loading-container'}>
        <Spin size="large"/>
      </div>
    );
  }

  renderOK() {
    // TODO...
  }

  renderException() {
    return (
      <div className={'no-result-container'}>
        <Empty description={'没有数据哎~'}/>
      </div>
    );
  }

  render() {
    const {status} = this.props;
    switch(status) {
      case STATUS.LOADING:
        return this.renderLoading();
      case STATUS.OK:
        return this.renderOK();
      case STATUS.EXCEPTION:
      default:
        return this.renderException();
    }
  }
}

接下来就是renderOK的实现,由于没有固定的设计稿,我们可以尽情发挥自己的想象。这里借助antd粗略地实现了一版交互(加入了切换tab、搜索和删除等操作),具体代码考虑到篇幅就不贴了,感兴趣的可以进这里查看。

4. 完结

整个插件的制作过程,到这儿就已经完了。如果你有更好的idea或设计,可以提PR哦~通过这次学习,熟悉了对Tabs的操作,同时对Chrome插件的制作流程也算是有了更深的感悟。

5. 参考

本文所有代码托管在这儿,喜欢的可以关注我。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容

  • chrome扩展开发入门教程 最近在开发chrome插件,看到一篇非常适合入门的教程,特记录一下 注:转载 本文首...
    谢大见阅读 6,360评论 1 25
  • Chrome扩展开发 标签(空格分隔): Chrome扩展 1、写在前面 Chrome插件是一个用Web技术开发...
    记忆的时间差阅读 5,983评论 0 15
  • selenium+python配置chrome浏览器的选项 1. 背景 在使用selenium浏览器渲染技术,爬取...
    朝畫夕拾阅读 8,695评论 0 5
  • 亲子日记第三十一天一年级六班李欣怡妈妈 今天是大年三十了,全家人都忙着过新年 早上起来就开始打扫卫生,婆婆跺...
    欣怡妈妈阅读 144评论 0 0
  • 20分钟,俯卧撑150,深蹲200, 今天还好,有点疲倦,但没有之前的懒散劲,发现汗水主要来源于头部,带了条毛巾,...
    selfhelp阅读 167评论 0 0