灵魂拷问-前端到底能做些什么?--chrome插件篇
阿里妹导读
一、概览
本文会从浏览器插件应用场景切入,穿插插件基础能力和常见入口的介绍,核心回答如下三个问题:插件可以被使用在哪些场景?不同的使用场景我们的主要代码实现思路是怎样的?我们可以从哪些角度入手自己开发一款可以落地实用的浏览器插件?
二、插件应用场景
首先先从:插件作为产品能力一环、作为数据采集分析工具、作为产品载体三个方面展开讲述插件的应用场景。
2.1 产品能力一环
2.1.1 PTS流量录制插件
简介:PTS是一款用来做性能压测的云产品,一般做压测最绕不过的就是 API 编排。对于用户来说,API 编排是一个不小的门槛,于是这款插件应运而生;
价值:用户只需要打开插件的录制功能,然后在需要进行压测的站点上,按需进行操作,该插件就会记录下操作过程中的接口请求序列,并支持一键生成压测场景进行压测,全程白屏化操作,大大提升了产品的易用性;
2.1.2 UI云测操作录制插件
简介:UI云测是一个前端UI自动化测试平台,面临着和 PTS 大致一样的问题——使用门槛高,自动化测试脚本的编写所需要的专业门槛也偏高; 价值:相对于流量录制插件是录制API,UI云测这个插件是在用户操作页面过程中录制相关的操作序列,降低平台使用门槛;
2.1.3 AEM热力图插件
简介:AEM热力图插件是AEM提供的一个查看当前站点点击/曝光热力图的插件;
价值:是一种附加的产品能力,热力图能力在AEM站点本身便已存在,插件模式可以让用户在目标站点实时分析,更加方便;
2.1.4 稀土掘金插件
简介:是掘金打造的浏览器插件,以背景页形式存在,可以同步掘金的登录态,通过各种能力给站点引流;
价值:由于背景页这种常驻页面的特性,每一个插件安装都代表着多了一个粘性较高的用户;
2.2 行为分析工具
2.2.1 行为分析插件
简介:面向用户的跨站点访问行为分析插件,采集数据包括设备性能信息 / 用户行为访问路径; 价值:采集用户的行为数据,为产品能力的演进提供参考;
2.2.2 PTS和UI云测的插件本质上也是做数据采集的,但是用途不一样,这两个主要是作为产品能力的一环
2.3 作为产品的载体
2.3.1 Sider
简介:通过浏览器原生siderPanel的容器模式实现ai助手; 优点:原生实现成本低,插件作为一个独立进程运行资源消耗低; 缺点:兼容性不是太好;
2.3.2 Monica
简介:通过content script向相关目标站点注入一个侧边栏实现ai助手; 优点:兼容性好; 缺点:需要额外实现dom注入效果,并且每个Tab都渲染一个侧边栏会占用系统资源,同时交互上相对原生sider略差(比如切换Tab后会丢失),但是可以借助service_worker实现单websocket;
2.3.3 笔记类
简介:一般也是以dom注入的方式实现,向不同的网页注入一个可以写笔记的dom;
2.4 日常辅助工具
2.4.1 网页翻译
简介:获取当前网页dom,便利TextNode,进行翻译,翻译完成后再将翻译结果跟随写入TextNode;
2.4.2 取色器
简介:通过点击事件获取目标dom,通过一系列api getComputedStyle 获取到目标dom的css的color属性;
2.4.3 OneTab
简介:管理tab / 导航聚合,可以使用 chrome.tabs / chrome.topSites 等 api,获取用户的 tab 相关信息;
2.4.2 聊天通信
简介:也是开发完后完整应用,以插件作为载体,同时可以利用插件的能力在任意Tab页甚至于桌面弹出通知;
2.5 开发者工具
2.5.1 XSwitch
简介:前端开发使用的代理插件,用于劫持特定请求代理到本地,主要使用了chrome.webRequest;
chrome.webRequest.onBeforeRequest.addListener(function (details) {if (forward[constants["s" /* DISABLED */]] !== enums["b" /* Enabled */].NO) {if (clearCacheEnabled) {clearCache();}return forward.onBeforeRequestCallback(details);}return {};}, {urls: [constants["c" /* ALL_URLS */]]}, [constants["e" /* BLOCKING */]]); // Breaking the CORS Limitationchrome.webRequest.onHeadersReceived.addListener(headersReceivedListener, {urls: [constants["c" /* ALL_URLS */]]}, [constants["e" /* BLOCKING */], constants["O" /* RESPONSE_HEADERS */]]);chrome.webRequest.onBeforeSendHeaders.addListener(function (details) {return forward.onBeforeSendHeadersCallback(details);}, {urls: [constants["c" /* ALL_URLS */]]}, [constants["e" /* BLOCKING */], constants["N" /* REQUEST_HEADERS */]]);
2.5.2 React Developer Tools
简介:React开发工具,和大部分工具不一样的是使用到了devtool panels,是一种面向和开发者的常驻面板,用于 react 组件状态分析;
2.5.3 Formily DevTools
简介:Formily开发工具,和大部分工具不一样的是使用到了devtool panels,用于formily状态分析;
2.6 其他
2.6.1 Cookie劫持-U know
简介:理论上说chrome插件可以拿到你访问到的任何网页的cookie,未知来源插件安装需谨慎;
2.6.2 请求劫持-电商返利
简介:一些灰产插件会在你访问电商网站购物时往你的请求里加上类似于推荐人的信息,获取返利;
2.6.3 chrome主题皮肤
简介:可以用来管理chrome的主题皮肤,这也是vscode中这类插件的一些常用能力;
三、插件实战案例
介绍完应用场景后,再针对几个典型场景进行代码实现思路介绍。
3.1 PTS流量录制插件
3.1.1 插件业务背景
PTS是一款用来做性能压测的云产品,一般做压测最绕不过的就是 API 编排。对于用户来说,API 编排有着不低的门槛,于是这款插件应运而生。用户只需要打开插件的录制功能,然后在需要进行压测的站点上,按需进行操作,该插件就会记录下操作过程中的接口请求序列,并支持一键生成压测场景进行压测,全程白屏化操作,大大提升了产品的易用性。
3.1.2 插件核心实现代码
登录态,token同步; popup面板监测到未登录,会显示一个未登录按钮;点击该按钮打开阿里云控制台官网,用户登录完后会通过注入的脚本拿到token;
版本升级检测; 配置中心,轮询版本信息通过版本算法进行对比;
setInterval(() => {try {const manifest = chrome.extension.getURL('manifest.json');Promise.all([fetch(manifest),fetch('https://xx.com/pts-record-chrome-plugin/package.json'),]).then(res => {return [ res[0].json(), res[1].json() ];}).then(result => {return Promise.all([ result[0], result[1] ]);}).then(res => {return [ res[0].version, res[1].version ];}).then(versions => {const [ currentVersion, latestVersion ] = versions;if (compareVersion(latestVersion, currentVersion) > 0) {storage.set('hasNew', latestVersion);} else {storage.set('hasNew', false);}}).catch(() => {storage.set('hasNew', false);});} catch (error) {storage.set('hasNew', false);}}, 1000 * 60);
流量录制
不同请求类型的可选录制,如GET/POST,filter一下 请求头等信息的录制,chrome.webRequest
// 不同的hook获取不同的信息,通过requestId关联,从而拼接出完整的请求&响应function openListener() {chrome.webRequest.onBeforeRequest.addListener(handleBeforeRequest,// filters{urls: [ '<all_urls>' ],},// extraInfoSpec[ 'requestBody', 'blocking', 'extraHeaders' ],);chrome.webRequest.onBeforeSendHeaders.addListener(handleBeforeSendHeaders,// filters{urls: [ '<all_urls>' ],},// extraInfoSpec[ 'requestHeaders', 'blocking', 'extraHeaders' ],);chrome.webRequest.onBeforeRedirect.addListener(handleOnBeforeRedirect,{urls: [ '<all_urls>' ],},);chrome.webRequest.onCompleted.addListener(handleOnCompleted,{urls: [ '<all_urls>' ],},[ 'responseHeaders', 'extraHeaders' ],);chrome.runtime.onMessage.addListener(handleRuntimeMessage);return true;}
响应体的录制,劫持fetch和xhr
// 劫持xhrconst send = XMLHttpRequest.prototype.send;XMLHttpRequest.prototype.send = function() {this.addEventListener('readystatechange',function() {if (this.readyState === 4) {const self = this;window.top.postMessage({direction: 'from-page-monkey-patch-script',response: 'response',value: {body: handleResponse(self),responseUrl: self.responseURL,},},'*',);}},false,);send.apply(this, arguments as any);};const open = XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open = function() {this.addEventListener('readystatechange',function() {if (this.readyState === 4) {const self = this;window.top.postMessage({direction: 'from-page-monkey-patch-script',response: 'request',value: {body: handleResponse(self),responseUrl: self.responseURL,},},'*',);}},false,);open.apply(this, arguments as any);};// 劫持fetchconst constantMock = window.fetch;window.fetch = function() {return new Promise((resolve, reject) => {constantMock.apply(this, arguments as any).then(response => {const responseForText = response.clone();responseForText.text().then(text => {window.top.postMessage({direction: 'from-page-monkey-patch-script',response: 'fetch',value: {body: text,responseUrl: response.url,},},'*',);resolve(response);});}).catch(response => {reject(response);});});};
3.2 行为分析插件
3.2.1 插件业务背景
采集用户操作过程中的页面访问/点击等行为数据,使用数据分析手段对数据进行分析帮助产品演进;
3.2.1 插件核心实现代码
用户身份信息同步; 通过接收站点的消息实现身份信息同步; 因为只需要发送埋点时明确当前用户身份,不需要cookie,所以简单点;
import { MessageTarget, MessageType } from '../../contstants/message';export const ticketEventhandler = (e) => {if (e.target === MessageTarget.IntelligentAssistantBackgroundScript &&e.origin === MessageTarget.IntelligentAssistantContentScript) {if (e.type === MessageType.UidEvent) {// 如果uid存在,设置aesConfigif (e.data?.uid !== (window as any)?.aes?.getConfig('uid')) {(window as any)?.aes?.setConfig?.({uid: e.data?.uid,});}}}};
版本升级检测
由于保证用户使用最新即可,直接判定和远程是否同一个版本,不是就要升级
const useVersions = () => {const [versions, setVersions] = useState<{currentVersion?: string;newVersion?: string;hasNew?: boolean;}>({});useEffect(() => {chrome.storage.local.get(['currentVersion'], ({ currentVersion }) => {updateConfigInfo().then((res) => {setVersions({currentVersion,hasNew: res?.version !== currentVersion,});});});}, []);return versions;};
用户行为埋点上报; 由于chrome插件后台window对象存在,所以aplus的umd可以直接用;aes-tracker需要改造适配; 先初始化aplus,将aplus umd直接下载到本地,然后入口文件处import即可;
然后初始化aes,aes初始化要放在第二步,因为后续行为上报都需要依赖aes;
// index.tsimport { initAES } from './aes/index';initAes();// aes/index.ts// 改造了一下,适配chrome插件下origin_url和title的取值逻辑import AES from '@alife/aes-tracker-chrome-plugin';import AESPluginEvent from '@ali/aes-tracker-plugin-event';// 改造了一下,适配chrome插件下origin_url和title的取值逻辑import AESPluginPV from '@ali/aes-tracker-plugin-pv-chrome-plugin';import AESPluginAutolog from '@ali/aes-tracker-plugin-autolog';export const initAES = () => {// 初始化SDKconst aes = new AES({pid: 'xx',user_type: 'xx',app_name: 'xx',// 版本号默认值,实际代码中会通过manifest.json设置app_version: 'xx',requiredFields: ["uid"],maxUrlLength: 20000});(window as any).aes = aes;(window as any).AESPluginEvent = aes.use(AESPluginEvent);(window as any).AESPluginPV = aes.use(AESPluginPV, {autoPV: false,autoLeave: false});(window as any).AESPluginAutolog = aes.use(AESPluginAutolog);};
然后初始化版本信息,注册定时器和监听器,同时注册过程要函数化,因为每次更新配置信息都会经历一次清除再注册的过程,达到可以动态配置定时器执行时间间隔的效果; 其中比较关键的是用户页面访问行为采集方案,需要推敲一下,考虑好各种边界; 点击/曝光事件的采集直接用aes自带的就好;
远程配置中心; 存在如下几个需要远程配置的信息; 页面访问上报过程中,我们要动态控制上报哪些站点的访问信息(最小化插件权限);
定时上报过程中,我们要动态控制定时上报的时间间隔; 针对插件popup实时性能分析能力,我们需要根据实际情况灵活调整采集频率 / 最大存储数据量; 以及还需要根据实际情况调整插件配置信息更新时间;
结合远程配置诉求和用户可以手动关闭采集过程的诉求,针对入口文件代码进行了改造; 核心是 启动逻辑收敛到start函数中,监听器清除逻辑收敛到close函数中(避免内存泄露);
针对开启/关闭行为分析操作,重复一下 close => start 的流程; 针对远程配置信息采用监听方案,特定配置信息变化后才会更新特定监听器,减少不必要性能开销;
// 必须最先初始化aplus umd,aes依赖于aplusimport './aplus/aplus_mini';import { initAES } from './aes/index';import { initVersionByManifest } from './timer/config';import { autologEventhandler } from './listener/autolog';import { ticketEventhandler } from './listener/ticket';import {tabsUpdatedEventhandler,tabsActivatedEventHandler,tabsRemovedEventHandler,windowsFocusedEventHandler,windowsRemovedEventHandler,} from './listener/behavior';import { collectNetworkInfo } from './timer/network';import { collectDeviceInfo } from './timer/device';import { updateConfigInfo } from './timer/config';import { collectPagesCount } from './timer/pagesCount';import type { IConfigInfo } from './timer/config';// 必须第二初始化 aes,接下来的数据采集都依赖于 aesinitAES();// 利用manifest初始化版本信息initVersionByManifest();// 初始化默认打开开关chrome.storage.local.set({isOn: true,});const closeListener = () => {// 移除ticket相关事件监听器chrome.runtime.onMessage.removeListener(ticketEventhandler);// 移除曝光事件监听器chrome.runtime.onMessage.removeListener(autologEventhandler);// 移除tab激活事件监听器chrome.tabs.onActivated.removeListener(tabsActivatedEventHandler);// 移除tab更新事件监听器chrome.tabs.onUpdated.removeListener(tabsUpdatedEventhandler);// 移除tab关闭事件监听器chrome.tabs.onRemoved.removeListener(tabsRemovedEventHandler);// 移除window获得焦点事件监听器chrome.windows.onFocusChanged.removeListener(windowsFocusedEventHandler);// 移除window关闭事件监听器chrome.windows.onRemoved.removeListener(windowsRemovedEventHandler);};const startListener = () => {closeListener();// 优先监听ticket页面事件if (!chrome.runtime.onMessage.hasListener(ticketEventhandler)) {chrome.runtime.onMessage.addListener(ticketEventhandler);}// 初始化点击曝光事件监听器if (!chrome.runtime.onMessage.hasListener(autologEventhandler)) {chrome.runtime.onMessage.addListener(autologEventhandler);}// 初始化行为监听器// 监听tab激活事件if (!chrome.tabs.onActivated.hasListener(tabsActivatedEventHandler)) {chrome.tabs.onActivated.addListener(tabsActivatedEventHandler);}// 监听tab更新事件if (!chrome.tabs.onUpdated.hasListener(tabsUpdatedEventhandler)) {chrome.tabs.onUpdated.addListener(tabsUpdatedEventhandler);}// 监听tab关闭事件if (!chrome.tabs.onRemoved.hasListener(tabsRemovedEventHandler)) {chrome.tabs.onRemoved.addListener(tabsRemovedEventHandler);}// 监听window获得焦点事件if (!chrome.windows.onFocusChanged.hasListener(windowsFocusedEventHandler)) {chrome.windows.onFocusChanged.addListener(windowsFocusedEventHandler);}// 监听window关闭if (!chrome.windows.onRemoved.hasListener(windowsRemovedEventHandler)) {chrome.windows.onRemoved.addListener(windowsRemovedEventHandler);}};let collectDeviceInfoIntervalId: NodeJS.Timeout | undefined;let collectNetworkInfoIntervalId: NodeJS.Timeout | undefined;let updateConfigInfoIntervalId: NodeJS.Timeout | undefined;let collectPagesCountIntervalId: NodeJS.Timeout | undefined;const closeTimer = () => {clearInterval(collectDeviceInfoIntervalId);collectDeviceInfoIntervalId = undefined;clearInterval(collectNetworkInfoIntervalId);collectNetworkInfoIntervalId = undefined;clearInterval(updateConfigInfoIntervalId);updateConfigInfoIntervalId = undefined;clearInterval(collectPagesCountIntervalId);collectPagesCountIntervalId = undefined;};const startTimer = () => {closeTimer();updateConfigInfo().then((configInfo: IConfigInfo | void) => {const {collectDeviceInfoInterval = 10000,collectNetworkInfoInterval = 10000,collectPagesCountInterval = 30000,updateConfigInfoInterval = 600000,} = configInfo || {};// 初始化设备性能信息定时器if (!collectDeviceInfoIntervalId) {collectDeviceInfoIntervalId = setInterval(() => {collectDeviceInfo();},collectDeviceInfoInterval < 1000 ? 1000 : collectDeviceInfoInterval,);}// 初始化网络延迟信息定时器if (!collectNetworkInfoIntervalId) {collectNetworkInfoIntervalId = setInterval(() => {collectNetworkInfo();},collectNetworkInfoInterval < 1000 ? 1000 : collectNetworkInfoInterval,);}// 初始化页面数量信息定时器if (!collectPagesCountIntervalId) {collectPagesCountIntervalId = setInterval(() => {collectPagesCount();},collectPagesCountInterval < 5000 ? 5000 : collectPagesCountInterval,);}// 初始化配置信息定时更新器,同时重新设置timerif (!updateConfigInfoIntervalId) {updateConfigInfoIntervalId = setInterval(() => {startTimer();},updateConfigInfoInterval < 60000 ? 60000 : updateConfigInfoInterval,);}}).catch((error) => {// 处理错误console.error('Error:', error);});};const start = () => {// 插件刚安装等时机,先读取一次远程配置,再初始化定时监听器startTimer();startListener();};const close = () => {closeTimer();closeListener();};// start之前会先close,来保证重复启动下不会有问题start();chrome.storage.onChanged.addListener((changes, areaName) => {if (areaName === 'local' && changes?.isOn) {if (changes?.isOn?.newValue) {// popup页面访问行为分析开关start();} else {// popup页面访问行为分析开关close();}}});
3.3 XSwitch代理插件
3.3.1 插件业务背景
前端开发代理资源到本地使用
3.3.2 插件核心实现代码
chrome.webRequest.onBeforeRequest.addListener(function (details) {if (forward[constants["s" /* DISABLED */]] !== enums["b" /* Enabled */].NO) {if (clearCacheEnabled) {clearCache();}return forward.onBeforeRequestCallback(details);}return {};}, {urls: [constants["c" /* ALL_URLS */]]}, [constants["e" /* BLOCKING */]]); // Breaking the CORS Limitationchrome.webRequest.onHeadersReceived.addListener(headersReceivedListener, {urls: [constants["c" /* ALL_URLS */]]}, [constants["e" /* BLOCKING */], constants["O" /* RESPONSE_HEADERS */]]);chrome.webRequest.onBeforeSendHeaders.addListener(function (details) {return forward.onBeforeSendHeadersCallback(details);}, {urls: [constants["c" /* ALL_URLS */]]}, [constants["e" /* BLOCKING */], constants["N" /* REQUEST_HEADERS */]]);
四、总结
最后,再从业务视角、效能视角以及个人开发者视角几个角度的可落地方向进行一下总结。回答一下我们可以从哪些角度入手自己开发一款可以落地实用的浏览器插件这个问题。
业务视角
作为产品能力的一环,流量录制器 / 操作录制器; 作为产品能力的增强,aem热力分析图插件 /email; 作为产品的一种载体,sider ai/monica/im;
效能视角
降低研发/问题排查成本,react developer tools/formily devtools/xswitch等; 网络孤岛环境错误信息收集;
个人开发者视角
换肤 网站聚合 / top访问 网页翻译 等其他工具
本文仅代表作者个人经验与观点