为了更好地完成掘金下午茶,我特地整了一个浏览器插件

发布于:2024-05-11 ⋅ 阅读:(23) ⋅ 点赞:(0)

自从 2022 年年底加入了船长组织的掘金下午茶小分队之后,每天我们几个小伙伴都会分别查找前后端及移动端三个分类下的优秀文章来分享给各位掘友。

当初为了分析各个大分类下不同文章在掘友之间的受欢迎程度等不同的维度信息,需要将选中的文章进行一次短链接转换,然后重新整理成下午茶消息,并记录到飞书文档中。

image.png

当时短链生成工具还支持 “批量生成”,只需要找好文章将地址和文章标题记录下来统一复制到生成工具中批量生成即可,所以操作复杂程度并不高,那么就一直是手动完成的。

但是后来觉得一篇一篇的复制,始终也不是个办法,还是得复制十次左右,那么能不能直接一次把所有符合条件的文章的信息全部提取出来一次性直接复制完成呢?

小米心想 —— 这应该是可以的吧?

第一版小插件 - 掘金文章标签页查询

基于这个想法,就有了最初的一个小插件 —— 查询所有掘金文章标签页,提取标签页 titleurl,来组合成一个完整信息。

由于只需要标签页的两个属性,并没有其他操作,所以这一版只有三个文件:manifest.json, popup.html, popup.js

其中 manifest.json 为 Chrome 扩展程序的必要文件,用来描述该扩展程序的功能和配置;popup.htmlpopup.js 则是作为扩展程序弹出式窗口中的显示内容和脚本文件。

因为该版本的功能过于单一和简单,所以相关的内容也特别少:

// manifest.json
{
  "manifest_version": 3,
  "name": "Juejin Daily Tea Creator",
  "version": "1.0",
  "description": "掘金下午茶助手",
  "icons": {
    "16": "icon.png",
    "48": "icon.png",
    "128": "icon.png"
  },
  "permissions": [ "tabs" ],
  "action": {
    "default_icon": {
      "16": "icon.png",
      "48": "icon.png",
      "128": "icon.png"
    },
    "default_title": "查询标签页信息",
    "default_popup": "popup.html"
  }
}

其中 permissions 权限配置部分,需要加入 tabs 标签页的读取权限,action 则是浏览器右侧的扩展程序 icon 对应的点击显示弹出式窗口、以及该窗口的标题和内容来源。

<!-- popup.html -->
<!DOCTYPE html>
<html lang="zh">
  <head>
    <title>查询标签页信息</title>
    <meta charset="utf-8">
    <style type="text/css">
      body {
        width: 280px;
        height: 400px;
      }
      ol {
        margin: 0;
        padding: 0 0 0 24px;
      }
      li {
        line-height: 24px;
      }
    </style>
  </head>
  <body>
    <p>
      <button id="btn">查询标签页信息</button>
    </p>
    <div id="output">
      <ol id="urls"></ol>
      <hr />
      <ol id="titles"></ol>
    </div>
  </body>
  <script src="popup.js"></script>
</html>
<!-- popup.js -->
// 记录数组
let selectedTabs = [];

function formatTabs(tabs) {
  let innerUrls = ''
  let innerTitles = ''
  tabs.forEach(function(tab) {
    const url = tab.url.split('#')[0]
    const title = tab.title.replace(' - 掘金', '')

    innerUrls += `<li>${url}</li>`
    innerTitles += `<li>${title}</li>`

    selectedTabs.push({ url, title });
  });

  return { innerUrls, innerTitles }
}
// 当按钮被点击时查询标签页信息
document.getElementById('btn').addEventListener('click', function() {
  chrome.tabs.query(
    {
      url: ['https://juejin.cn/post/*']
    },
    function(tabs) {
      // 在输出区域显示标签页信息
      var outputUrls = document.getElementById('urls');
      outputUrls.innerHTML = '';
      var outputTitles = document.getElementById('titles');
      outputTitles.innerHTML = '';

      // 处理标签页
      if (tabs && tabs.length) {
        const { innerUrls, innerTitles } = formatTabs(tabs)
        outputUrls.innerHTML = innerUrls
        outputTitles.innerHTML = innerTitles
      } else {
        outputUrls.innerHTML = '<p style="text-align: center;">暂无匹配数据</p>';
        selectedTabs = []
      }
    }
  );
});

这部分的逻辑其实很简单:点击扩展程序按钮的时候会弹出这个 popup.html,里面有一个 “查询标签页信息” 的按钮,点击这个按钮时会查询所有标签页,匹配以 https://juejin.cn/post/ 作为前缀的标签页,并将匹配结果组合成两个列表(因为短链生成工具要求是这样的,需要将名称和地址分开),然后插入到弹出页的指定区域。

这样,我就可以直接复制到生成工具中批量生成短链啦~

image.png

于是我又可以愉快地搞茶了。。。

但是,好景不长。。。

没过多久,由于短链生成工具那边的业务调整,我们下午茶团队的批量生成配额不够用啦!!!

这个时候就只能通过船长的密钥借助 apifox 这类工具来手动一个一个的创建短链,这对于一个程序员来说简直不能忍!能多点几次按钮就已经是我最大的让步啦!!!

所以,现在急需完善这个扩展程序,自动给我把每个文章生成对应短链接并且组合成一个下午茶消息。

ps: 之前第一版每天的下午茶消息也依然是手动编辑的,真是累坏了😏

先看看效果

在进行了一系列完善之后,我得到了下午茶生成工具的第二个版本,先给大家看看效果吧~

image.png

image.png

从操作过程来讲,将之前的弹出式菜单改成了页面插入。

点击扩展程序按钮时,会在当前页右侧弹出一个侧边栏,支持选择下午茶的分类,并提供查询所有掘金文章标签页的功能。

标签页信息查出来之后,可以重新点击 “查询文章标签页” 按钮来更新文章列表内容,或者点击 “生成短链” 按钮来为所有已选文章生成短链接,并显示在文章列表下方。

然后会提供 “生成下午茶消息” 的按钮,用来打开下午茶消息的预览弹窗。消息中支持插入上期每日掘金文章以及近期活动(当然、文章只有一个,但是活动可以有多条),并且非短链平台的地址前缀还会调用短链生成工具的接口来生成对应的短链地址,然后再插入到下午茶消息中。

后续可以直接复制生成的文本信息用来发送下午茶消息,或者复制为表格信息到飞书文档中。

整个过程涉及到 本地缓存、网络请求、标签页查询、元素插入 等内容。

由于谷歌扩展程序的安全设计,部分模块无法直接发送非同源的网络请求,所以这个版本将内容分成了 contentbackground 两个部分。

background 用来查询标签信息,发送请求生成短链接等工作;而 content 则纯粹实现用户交互部分的内容。

为了方便理解,我们先回顾一下 Chrome 插件的相关概念和通信方式。

回顾一下 Chrome 插件

manifest.json

该文件在 中被称为 “清单文件”,每个扩展程序的根目录中都必须包含一个 manifest.json 文件,其中会列出有关该扩展程序的结构和行为的重要信息。

其中必要内容有:

  • manifest_version 清单文件格式版本,目前唯一指定值是 3
  • name:一个字符串,用于在 、安装对话框和用户的 Chrome 扩展程序页面 (chrome://extensions) 中标识扩展程序。长度上限为 75 个字符。
  • version 用于标识扩展程序版本号的字符串。
  • description 一个字符串,用于描述 Chrome 应用商店和用户扩展程序管理页面上的扩展程序。长度上限为 132 个字符。
  • icons 代表扩展程序的一个或多个图标。

常用内容:

  • author 指定用于创建扩展程序的帐号的电子邮件地址。

  • action 定义扩展程序图标在 Chrome 工具栏中的 外观和行为

  • background 指定包含扩展程序 Service Worker 的 JavaScript 文件,该 Service Worker 充当 事件处理脚本

  • content_scripts 指定在用户打开 某些 网页时要使用的 JavaScript 或 CSS 文件。

  • devtools_page 定义使用 API 的页面。

  • side_panel 标识要在 中显示的 HTML 文件。

  • chrome_url_overrides 定义默认 Chrome 网页的替换页面。

  • web_accessible_resources 定义扩展程序中可由网页或其他扩展程序访问的文件。

  • permissions 允许使用特定的扩展程序 API。

  • host_permissions 列出允许您的扩展程序与之互动的网页,使用网址匹配模式定义。系统会在安装应用时请求这些网站的用户权限。

  • commands 定义扩展程序中的键盘快捷键。

其中,background 主要作为整个扩展程序的事件处理中心和逻辑执行中心,content_scripts 用于插入网页内容区进行 dom 操作,devtools_page 用来扩展开发者界面 tab 页,side_panel 则是控制 Chrome 的侧边栏的显示内容。

需要注意的是,side_panel 还需要配合 permissions 中的 "sidePanel" 权限。

由于当前的需求我们只需要显示标签页信息以及发送短链生成请求,所以该插件目前只使用了 content_scriptsbackground 两个核心部分;当然,为了保持插入内容的样式,还需要设置 web_accessible_resources 来确保样式内容能够引入。

主要组成

image-20240507145116580.png

新标签页初始内容等通过上述的指定配置完成。

其中,actions icons 部分为所有 已安装并且固定 的扩展程序中 manifest 指定的 action 配置下 default_icon 配置的图标组成的列表,可以 打开 popup 弹出页面或者触发 chrome.action.onClicked 所绑定的事件(两者存在冲突,不能共存)。

devtools page 则是指定的开发者工具页面内容,可以通过 manifest 中的 devtools_page 项配置,可单独新增工具栏标签页,也可以插入已有(例如 Elements 元素标签)标签中。每个面板也都是一个 html 页面,可以包含其他资源文件。

popup 弹出页面与 side panel 侧边栏页面都是完整的 html 页面,但是 popup 需要在清单文件的 action 中指定 default_popup 默认展开页面文件的路径,而 side panel 可以通过在清单文件中的 side_panel 配置项中指定默认侧边栏内容页面路径(default_path 配置),也可以通过在 background 中监听标签页改变来为指定网站打开侧边栏,具体配置见:

至于 content scripts 内容脚本,则是 注入到网站主体中执行的脚本内容,可以包含 JavaScript 文件以及 CSS 文件,可以在清单文件中的 content_scripts 中静态声明,也可以通过 chrome.scripting.registerContentScripts 或者 chrome.scripting.updateContentScripts ,或者 chrome.scripting.executeScript 动态注入。

🚨 请注意,注入的函数是 chrome.scripting.executeScript() 调用中引用的函数的副本,而不是原始函数本身。因此,函数的正文必须是自包含;如果引用函数外部的变量,会导致内容脚本抛出 。

消息传递(通信方式)

由于 context scripts 中的内容会受同源策略的影响,并且 popup 与注入脚本也是相互独立的,所以基本上所有的网络请求和相互通信都会通过 background 来处理。

Chrome 扩展程序中的通信方式,主要分为两种:

  1. 一次性请求
  2. 长连接

一次性请求

一次性请求 与我们常说的 发布订阅 模式的概念一致。

通过 addListener 注册事件监听处理程序,通过 sendMessage 发布事件消息。

注册监听程序 常用的方式为:

chrome.runtime.onMessage.addListener(
  callback: (message: any, sender: MessageSender, sendResponse: function) => boolean ┃ undefined,
)

发送事件消息,则有两种常见方式:

// message: any = {}

// 方式 1
// declare
chrome.runtime.sendMessage(
  extensionId?: string,
  message: any,
  options?: object,
  callback?: function,
)
// example
(async () => {
  const response = await chrome.runtime.sendMessage(message);
  // do something with response here, not outside the function
  console.log(response);
})();

// 方式 2
// declare
chrome.tabs.sendMessage(
  tabId: number,
  message: any,
  options?: object,
  callback?: function,
)
// example
(async () => {
  const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
  const response = await chrome.tabs.sendMessage(tab.id, message);
  // do something with response here, not outside the function
  console.log(response);
})();

其中,方式 1 常用于 content scripts 对外发送消息,而向 content scripts 发送消息,则通常需要指定这个消息请求 应用于哪个页面,所以需要通过 tabs.query({ active: true, lastFocusedWindow: true }) 查询当前激活标签页,然后发送消息。

🚨 另外需要注意的是,以上两种方式的 示例 都是采用的同步调用的方式,如果使用 callback 回调函数,则表示异步使用,需要将 return true 添加到 sendMessage 的回调函数中。

// 方式 1
(() => {
  chrome.runtime.sendMessage(message, (response) => {
    // do something with response here, not outside the function
    console.log(response);
    return true
  });
})();
// 方式 2
(() => {
  chrome.tabs.query({ active: true, lastFocusedWindow: true }, ([tab]) => {
    chrome.tabs.sendMessage(tab.id, message, (response) => {
      // do something with response here, not outside the function
      console.log(response);
      return true
    }) 
  })
})();

长连接

一次性请求通过手动执行 sendMessage 来独立发送一次消息,触发监听函数执行,而长连接则是建立一个长期存在、可重复使用的消息传递通道。

可以通过 chrome.runtime.connect({ name: string }): Port 或者 chrome.tabs.connect({ name: string }): Port 来创建一个指定频道名称的消息通道实例。

然后通过该通道实例的 onMessage.addListener 方法注册消息监听函数,通过 postMessage 发送通道消息。

例如 在注入脚本 中:

// content-script.js
var port = chrome.runtime.connect({ name: "knockknock" });
port.postMessage({ joke: "Knock knock" });
port.onMessage.addListener(function(msg) {
  if (msg.question === "Who's there?")
    port.postMessage({ answer: "Madame" });
  else if (msg.question === "Madame who?")
    port.postMessage({ answer: "Madame... Bovary" });
});

而如果我们要在 扩展程序(例如 popup 部分) 中创建消息通道,则需要替换为 chrome.tabs.connect({ name: string }): Port,其余部分不变。

当消息通道创建完毕之后,如果我们要在 非消息通道创建部分 的其他地方使用这个消息通道内的消息,则可以通过 chrome.runtime.onConnect.addListener((port: Port) => void) 来注册 消息通道建立事件 的监听方法。

然后在这个监听方法中,判断 portname 属性来找到需要的消息通道,然后就可以按照上述的方式一样使用 port 通道实例了。

chrome.runtime.onConnect.addListener(function (port) {
  console.assert(port.name === "knockknock");
  port.onMessage.addListener(function (msg) {
    if (msg.joke === "Knock knock")
      port.postMessage({ question: "Who's there?" });
    else if (msg.answer === "Madame")
      port.postMessage({ question: "Madame who?" });
    else if (msg.answer === "Madame... Bovary")
      port.postMessage({ question: "I don't get it." });
  });
});

在我们这个插件中,主要使用的还是一次性请求。

着手插件开发

在开发之前,首先需要确定的自然就是技术选型了。

由于涉及到稍微复杂的页面样式和交互(说白了就是不想自己写。。。),所以使用 vite + Vue 3 作为基础,使用 arco-design 作为 UI 库。

而开发过程中,为了 实现动态编译和刷新插件状态,这里使用了一个插件 —— 。

这个插件的使用方式这里就不做赘述了,主要作用就是 根据 manifest.json 中的内容配置,编译对应的文件,并建立开发服务器与浏览器之间的 WS 通信来动态更新插件

最终的目录结构如下:

./
┣━ src
┃  ┣━ app
┃  ┃	┣━ components
┃  ┃  ┗━ App.vue
┃  ┣━ assets
┃  ┣━ background
┃  ┃	┗━ worker.ts
┃  ┣━ content-scripts
┃  ┃  ┗━ content.ts
┃  ┣━ core
┃  ┃  ┣━ requests
┃  ┃  ┣━ store
┃  ┃  ┗━ utils
┃  ┣━ types
┃  ┗━ manifest.json
┣━ LICENSE
┣━ package.json
┣━ pnpm-lock.yaml
┣━ README.md
┣━ tsconfig.json
┣━ tsconfig.node.json
┗━ vite.config.ts

其中,app 为主要的 Vue 组件部分,assets 为静态样式资源与图标文件,core 为核心的请求方法、工具函数等主要执行逻辑;backgroundcontext-scripts 对应 manifest 清单文件中的 backgroundcontent-scripts 指定的入口文件。

然后,我们需要编写 manifest.json 文件,配置插件对应的权限与内容:

{
  "name": "每日掘金小助手",
  "description": "A Chrome extension for daily-juejin-tea helper.",
  "version": "1.0.0",
  "icons": {
    "16": "assets/icons/icon16.png",
    "48": "assets/icons/icon48.png",
    "128": "assets/icons/icon128.png"
  },
  "action": {},
  "background": {
    "service_worker": "./background/worker.ts",
    "type": "module"
  },
  "content_scripts": [{
    "matches": ["https://juejin.cn/**"],
    "js": [ "content-scripts/content.ts"]
  }],
  "web_accessible_resources": [{
    "matches": ["https://juejin.cn/*"],
    "resources": ["assets/*"]
  }],
  "permissions": ["tabs", "webRequest", "clipboardWrite", "storage"],
  "host_permissions": [
    "https://api.xiaomark.com/*"
  ],
  "manifest_version": 3
}

在配置中,需要在 permissions 中开放 "tabs", "webRequest", "clipboardWrite", "storage" 四个权限,分别用于 读取标签页信息、发起短链转换请求、写入剪切板内容、读取/写入下午茶默认分类

由于短链请求有别于掘金来源,所以需要在 host_permissions 中配置 https://api.xiaomark.com/* 允许发起短链转换请求。

web_accessible_resources 则是标识需要注入的样式等静态资源目录。

当然这只是开发时编写的内容,后续会经过插件转换为实际使用的清单文件,例如:

{
  "name": "每日掘金小助手",
  "description": "A Chrome extension for daily-juejin-tea helper.",
  "version": "1.0.0",
  "icons": {
    "16": "assets/icons/icon16.png",
    "48": "assets/icons/icon48.png",
    "128": "assets/icons/icon128.png"
  },
  "action": {},
  "background": {
    "service_worker": "assets/worker-40451f67.js",
    "type": "module"
  },
  "content_scripts": [
    {
      "matches": [
        "https://juejin.cn/**"
      ],
      "js": [
        "contentscript-loader-content-6c2f6608.js"
      ],
      "css": [
        "assets/content-2f830ff4.css"
      ]
    }
  ],
  "web_accessible_resources": [
    {
      "matches": [
        "https://juejin.cn/*"
      ],
      "resources": [
        "assets/*"
      ]
    },
    {
      "matches": [
        "<all_urls>"
      ],
      "resources": [
        "http://www.w3.org/2000/svg",
        "assets/content-6c2f6608.js"
      ],
      "use_dynamic_url": true
    }
  ],
  "permissions": [
    "tabs",
    "webRequest",
    "clipboardWrite",
    "storage"
  ],
  "host_permissions": [
    "https://api.xiaomark.com/*"
  ],
  "manifest_version": 3
}

完善消息通信

在上文消息传递中,我们确定了该插件中主要使用 一次性请求 的通信方式。

但是在开发过程中我发现,Chrome 中的发布订阅与我们常用的发布订阅模式依然存在区别,即 没有明显的订阅收集过程

例如:

// background.js
chrome.runtime?.onMessage.addListener((message, sender, sendResponse) => console.log(1));
chrome.runtime?.onMessage.addListener((message, sender, sendResponse) => console.log(2));
chrome.runtime?.onMessage.addListener((message, sender, sendResponse) => console.log(3));
chrome.runtime?.onMessage.addListener((message, sender, sendResponse) => console.log(4));

// Service worker console:
// 1
// 2
// 3
// 4

这样,我们可以重复且频繁的注册监听函数,并且所有函数在接收(消息被发送)到消息时都会执行,所以我们 只能在监听函数内部根据消息参数 message 的内容来进行判断,以执行不同的处理逻辑

并且这种方式在某些监听函数设置不当(例如异步操作没有进行同步转换)时还会抛出异常,影响插件的正常运行。

所以这种方式对开发者来说是非常不友好的!

本身传统的发布订阅模式已经难以管理了,这种使用方式肯定更加难以发现问题。

所以,这里对其进行了一部分改造,向便于管理且可控制的方向靠拢。

我们新建一个 messageBus 文件:

// src/core/utils/messageBus.ts
export type MsgListener = (
  message: RuntimeMsg,
  sender: chrome.runtime.MessageSender,
  sendResponse: (response?: any) => void,
) => void;

export const runtimeListenersMap: Record<string, MsgListener> = {};
export type RuntimeMsg = {
  action: keyof typeof runtimeListenersMap;
  body?: unknown;
};

export const addRuntimeMsgListener = (
  action: string,
  listener: MsgListener,
  override?: boolean,
) => {
  if (!runtimeListenersMap[action]) {
    runtimeListenersMap[action] = listener;
    return;
  }
  override && (runtimeListenersMap[action] = listener);
};
export const fireRuntimeMsgListener = async <T>(
  action: RuntimeMsg["action"],
  body: RuntimeMsg["body"],
  callback?: (res: T) => void,
) => {
  if (!chrome?.runtime?.sendMessage) return;
  const response = await chrome.runtime.sendMessage({ action, body });
  callback?.(response);
};
export const removeRuntimeMsgListener = (action: string) => {
  if (!runtimeListenersMap[action]) return;
  delete runtimeListenersMap[action];
};

export type ActionClickListener = (
  tab: chrome.tabs.Tab,
) => Promise<unknown> | undefined;
export const ActionClickListeners: ActionClickListener[] = [];
export const addActionClickListener = (listener: ActionClickListener) => {
  ActionClickListeners.push(listener);
};

export const initMessageBus = () => {
  chrome.runtime?.onMessage.addListener(
    (message: RuntimeMsg, sender, sendResponse) => {
      if (runtimeListenersMap[message.action]) {
        runtimeListenersMap[message.action]?.(message, sender, sendResponse);
      } else {
        sendResponse(null);
      }
      return true;
    },
  );
  chrome.action?.onClicked.addListener((tab) => {
    ActionClickListeners.forEach(async (fc) => await fc(tab));
    return true;
  });
};

根据常用的一次性请求的使用方式,这里按照 RuntimeTabsAction 几个部分进行了分类(tabs 的监听类似,但是由于没怎么用,这里就没有写)。

通过 initMessageBus 来初始化一个监听处理函数,在这个函数内部进行判断,来执行已注册的不同的处理逻辑。

在使用时,只需要 在各个需要使用消息传递的插件组成部分中引用并执行 initMessageBus 方法,即可完成所有的消息事件订阅处理

由于不同的插件组成,所能访问的权限也不一样,所以在 initMessageBus 这个方法中可以直接通过可选链的方式来统一写入所有的 addListener 实现。

而监听函数注册,只需要通过 addRuntimeMsgListeneraddActionClickListener 这类方法即可完成订阅函数的注册。

当然,由于两者性质有些区别,chrome.action?.onClicked.addListener 的监听回调只有当前 tab 这个对象,所以采用的是数组的方式,后续依然可以修改为对象形式,方便管理和移除监听函数。

经过这样的改动之后,我们可以直接通过 action 操作名称来查找已注册的监听函数,并且减少了监听函数的实际执行个数。

编写交互组件

由于当时没有太多时间来整理项目结构,所以实际上的业务全部都在 App.vuecontent-scripts/content.ts, background/worker.ts 中。

省略部分非必要代码后,大致结构如下:

App.vue:

<template>
  <div class="crx-jj-mask" @click="closePanel">
    <div class="crx-jj-container" @click.stop>
      <spin :loading="onLoading" dot style="width: 100%">
        <page-header
          title="每日掘金"
          subtitle="掘金酱的下午茶"
          @back="closePanel"
        />
        <form :model="jjForm" class="jj-tea-form">
          <form-item label="分类">
            <radio-group v-model="jjForm.type" type="button" @update="changeStorage">
              <radio v-for="op in typeOps" :key="op.value" :value="op.value">{{ op.label }}</radio>
            </radio-group>
          </form-item>
          <form-item label="链接">
            <list style="width: 100%" size="small">
              <list-item v-for="(link, idx) in jjForm.links" :key="idx">
                <list-item-meta :title="link.title" :description="link.link" />
                <template #actions>
                  <icon-delete @click="removeLinkItem(idx)" />
                </template>
              </list-item>
              <template #footer>
                <div class="a-button a-button-long" @click="getTabsInfo">
                  查询文章标签页
                </div>
                <div v-show="jjForm.links.length" class="a-button a-button-long" @click="generateShortLink" >
                  生成短链
                </div>
              </template>
            </list>
          </form-item>
          <form-item v-if="hasSuccess" label="短链">
            <list style="width: 100%" size="small">
              <list-item v-for="(link, idx) in jjForm.links" :key="idx">
                <list-item-meta :title="link.title" :description="link.shortLink" />
              </list-item>
              <template #footer>
                <div v-if="hasSuccess" class="a-button a-button-long" @click="generatorTeaContent">
                  生成下午茶消息
                </div>
              </template>
            </list>
          </form-item>
        </form>
      </spin>

      <modal
        v-model:visible="modelVisible"
        title="下午茶"
        :footer="false"
        :render-to-body="false"
      >
        <pre>{{ afternoonTeaContent.header }}</pre>
        <pre v-if="afternoonTeaContent.post">{{`【每日掘金】\n${afternoonTeaContent.post}`}}</pre>
        <div v-else>
          <a-button type="text" @click="openFormModel('post')">插入每日掘金文章</a-button>
        </div>
        <pre v-if="afternoonTeaContent.activities && afternoonTeaContent.activities.length">{{
            `【近期活动】\n${(afternoonTeaContent.activities || []).join("\n")}`
          }}</pre
        >
        <div>
          <a-button type="text" @click="openFormModel('activity')">插入活动消息</a-button>
        </div>
        <pre>{{ afternoonTeaContent.body }}</pre>
        <div class="form-footer align-right">
          <div class="a-button" @click="copyToClipboard('text')">复制文本信息</div>
          <div class="a-button" @click="copyToClipboard('table')">复制到飞书</div>
        </div>
      </modal>

      <modal
        v-model:visible="modelFormVisible"
        title="插入信息"
        :render-to-body="false"
        :footer="false"
      >
        <spin :loading="onFormLoading" dot style="width: 100%">
          <form :model="modelForm" class="jj-tea-form">
            <form-item label="内容">
              <a-textarea v-model="modelForm.content" allow-clear />
            </form-item>
            <form-item label="链接">
              <a-textarea v-model="modelForm.link" allow-clear />
            </form-item>
          </form>
          <div class="form-footer align-right">
            <div class="a-button" @click="submitForm">确认</div>
          </div>
        </spin>
      </modal>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { toggle } from "@/content-scripts/content";
import { fireRuntimeMsgListener } from "@/core/utils/messageBus";
import {
  messageBody,
  messageGenerator,
  messageHeader,
  tableContentGenerator,
} from "@/core/utils/template";
import { setClipboardText } from "@/core/utils/tools";

const typeOps = [
  { label: "前端", value: "frontend" },
  { label: "后端", value: "backend" },
  { label: "移动端", value: "mobileend" },
  { label: "人工智能", value: "ai" },
];

const jjForm = ref<JJForm>({
  type: "frontend",
  links: [],
});
const onLoading = ref(false);
const hasSuccess = ref(false);
const modelVisible = ref(false);
const modelFormVisible = ref(false);
const onFormLoading = ref(false);
const afternoonTeaContent = ref<JJTeaContent>({
  header: "",
  body: "",
  activities: [],
  post: "",
});
const modelForm = ref<Record<"link" | "content" | "type", string>>({
  type: "post",
  link: "",
  content: "",
});

const getTabsInfo = () => {
  hasSuccess.value = false;
  onLoading.value = true;
  fireRuntimeMsgListener("getTabsInfo", "", (res: ProcessTabsResult) => {
    jjForm.value.links = res.links;
    onLoading.value = false;
  });
};

const removeLinkItem = (idx: number) => {
  Modal.warning({
    title: "删除该链接?",
    content: "删除后无法恢复,可以通过“查询标签页”重置该列表",
    onOk: () => jjForm.value.links.splice(idx, 1),
  });
};

const changeStorage = (value: string) => {
  fireRuntimeMsgListener("setStorage", { "message-type": value });
};

fireRuntimeMsgListener("getStorage", { key: "message-type" }, (res: any) => {
  jjForm.value.type = res || "frontend";
});

const generateShortLink = async () => {
  try {
    onLoading.value = true;
    fireRuntimeMsgListener(
      "generateShortLink",
      jjForm.value.links,
      (res: any[]) => {
        onLoading.value = false;
        if (!res || !res.length) {
          return Notification.error("请求异常");
        }
        for (let i = 0; i < res.length; i++) {
          const data = res[i];
          if (data && data.code === 0) {
            jjForm.value.links[i].shortLink = data.data?.link?.url ?? "";
          }
        }
        hasSuccess.value = true;
      },
    );
  } catch (e) {
    console.error(e);
    hasSuccess.value = false;
    onLoading.value = false;
  }
};

const generatorTeaContent = () => {
  const { type, links } = jjForm.value;
  afternoonTeaContent.value.header = messageHeader(type);
  afternoonTeaContent.value.body = messageBody(links as Required<LinkItem>[]);
  modelVisible.value = true;
};

const openFormModel = (type: string) => {
  modelForm.value = { link: "", content: "", type };
  modelFormVisible.value = true;
};
const submitForm = () => {
  if (!modelForm.value.content || !modelForm.value.link) {
    return Notification.error("信息不完整");
  }
  onFormLoading.value = true;
  if (!modelForm.value.link.includes("sourl.co")) {
    fireRuntimeMsgListener(
      "generateShortLink",
      [{ link: modelForm.value.link }],
      (res: any[]) => {
        onLoading.value = false;
        if (!res || !res.length) {
          return Notification.error("请求异常");
        }
        modelForm.value.link = res[0].data?.link?.url ?? modelForm.value.link;
        setMessagePart();
      },
    );
  } else {
    setMessagePart();
  }
};
const setMessagePart = () => {
  if (modelForm.value.type === "post") {
    afternoonTeaContent.value.post = `${modelForm.value.content}\n${modelForm.value.link}`;
  } else {
    if (!afternoonTeaContent.value.activities) {
      afternoonTeaContent.value.activities = [];
    }
    afternoonTeaContent.value.activities.push(
      `${modelForm.value.content}\n${modelForm.value.link}`,
    );
  }
  onFormLoading.value = false;
  modelFormVisible.value = false;
};

const copyToClipboard = async (type: "text" | "table") => {
  const { header, activities, post, body } = afternoonTeaContent.value;
  let text = "";
  if (type === "table") {
    text = tableContentGenerator(jjForm.value.links as Required<LinkItem>[]);
  }
  if (type === "text") {
    text = `${header}\n`;
    post && (text += `【每日掘金】\n${post}\n`);
    activities &&
      activities.length &&
      (text += `【近期活动】\n${(activities || []).join("\n")}\n`);
    text += body;
  }

  await setClipboardText(text);

  if (type === "table") {
    window.open("https://bytedance.feishu.cn/sheets/****", "__blank");
  }
};

const closePanel = () => {
  toggle(false);
};
</script>

content.ts:

import { createApp, App } from "vue";
import appVue from "../app/App.vue";
import { setPanelStatus } from "@/core/store";
import "@/assets/styles/app.scss";
import { addRuntimeMsgListener, initMessageBus } from "@/core/utils/messageBus";

let app: null | App = null;

export const toggle = (visible: boolean) => {
  if (!visible) {
    app && app.unmount();
    app = null;
    document.body.style.overflowY = "scroll";
  } else {
    const parent = document.querySelector("#__crx-app");
    if (!parent) {
      const juejinContent = document.querySelector("#__nuxt");
      juejinContent?.insertAdjacentHTML(
        "beforebegin",
        '<div id="__crx-app" class="__crx-app"></div>',
      );
    }
    app = createApp(appVue);
    app.mount("#__crx-app");
    document.body.style.overflowY = "hidden";
  }
  setPanelStatus(visible);
};

const toggleListener = (request: any) => {
  // if (request.action === "toggle") {
  //   toggle(request.body);
  // }
  toggle(request.body);
};

addRuntimeMsgListener("toggle", toggleListener);

initMessageBus();

worker.ts:

import {
  addActionClickListener,
  addRuntimeMsgListener,
  initMessageBus,
} from "@/core/utils/messageBus";
import {
  getStorage,
  setStorage,
  processJuejinPostTabs,
  processShortLink,
} from "@/core/utils/functions";
import { getPanelStatus, setPanelStatus } from "@/core/store";

// /////////// 侧边栏显示控制
const toggleTab = async (tab?: chrome.tabs.Tab) => {
  const newStatus = !getPanelStatus();
  await chrome.tabs?.sendMessage(tab!.id!, {
    action: "toggle",
    body: newStatus,
  });
  setPanelStatus(newStatus);
};
addActionClickListener(toggleTab);

// ///////////////////////// 消息事件
addRuntimeMsgListener("getTabsInfo", (request, sender, sendResponse) => {
  processJuejinPostTabs(sendResponse);
});

addRuntimeMsgListener("generateShortLink", (request, sender, sendResponse) => {
  processShortLink(request, sendResponse);
});

addRuntimeMsgListener("getStorage", (request, sender, sendResponse) => {
  getStorage(request, sendResponse);
});

addRuntimeMsgListener("setStorage", (request, sender, sendResponse) => {
  setStorage(request, sendResponse);
});

// 注册 worker 相关的事件监听器
initMessageBus();

其中,App.vue 中几个按钮的作用就是 对外发送指定的消息,触发指定的逻辑处理函数

然后将消息传递给 background/worker 中,执行实际的网络请求以及数据处理等。

完整代码见:

完结撒花~

至此,这个插件的大概内容就差不多了。

可能部分同学会觉得实际上的插件部分并没有讲太多内容,但该插件的核心其实就是 消息传递 部分,剩下的属于基础的 Vue 组件编写和网络请求处理、以及字符串操作等。

整个插件,通过 action 点击事件,触发 content-scripts 中的脚本执行,来切换注入内容的显示与隐藏。

content 注入内容中,则值提供数据显示与数据录入,通过消息将前台内容传递给 background 进行处理,然后拿到处理结果进行后续逻辑。

理解了插件的消息传递之后,再开发这类插件就非常简单了(当然像 Vue Devtools 这种复杂插件就当我没说,,,)

后续随着掘金下午茶的继续发扬光大,这个插件也可能会再次进行迭代,为每日掘金的各个小伙伴提供更加方便的功能。

最后

本文到这里就结束啦~如果本文对你有帮助,希望你能点赞收藏;如果文中有不当之处,也希望你能在评论里及时指出,大家的支持也是我前进的动力~~

如果想要关注更多的前端内容,可以关注我的公众号: ,或者在 中提出疑问或者参与讨论~

十分感谢大家的阅读!


网站公告

今日签到

点亮在社区的每一天
去签到