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 恰好在两个不同的窗口内, 所以正好符合预期.
另外 id,index, highlighted,favIconUrl,title 等字段信息在后文中也起到非常重要的作用, 相关的释义都可以在这里查看.
在构思 Chrome 插件 UI 时, 为了突出当前窗口中的当前 tab, 我们就必须从上述数据中找出这个 tab. 由于每个窗口中都有一个 tab 是 highlighted 的, 所以我们无法直接确定哪个 tab 是当前窗口的. 不过, 我们可以这样:
- Chrome.tabs.query(
- {active: true, currentWindow: true},
- tabs => console.log(tabs[0])
- );
根据文档, 通过指定 active 和 currentWindow 这两个属性为 true, 我们就能顺利拿到当前窗口的当前 tab. 然后再根据 tab 的 windowId 和 highlighted 进行匹配, 我们就能从 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, 这次复杂度更高, 涉及到更多的交互操作. 为此, 我们引入 react,antd 和 webpack, 不过整体开发起来还是比较容易的, 更多的可能还是在于 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"
- }
- }
由于这次开发的插件跟 tabs 相关, 所以我们需要在 permissions 字段中申请 tabs 权限.
由于 webpack 在 dev 模式下打包会用到 eval,Chrome 浏览器出于安全策略会报错, 因此需要设置
content_security_policy
使其忽略 (如果是 prod 模式打的包, 就不需要设置).
本次插件的交互是点击按钮弹出一个浮层, 所以需要设置 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. 参考
- Chrome extensions dev guide
- Chrome extensions browserAction
- Chrome extensions tabs
本文所有代码托管在这儿, 喜欢的可以关注我.
来源: http://www.jianshu.com/p/2f5f61f3705b