扩展一: HAR Debugger 一个可以帮助开发者快速定位客户网络或者产品问题的工具插件
产品内部自测通过,交付用户后在用户的电脑💻上奔溃了,客户投诉产品垃圾怎么办?
什么是HAR包?
定义:HTTP存档格式(HTTP Archive format,简称HAR)是一种JSON格式的存档文件格式,多用于记录网页浏览器与网站的交互过程。文件扩展名通常为.har
“HAR格式的规范定义了一个HTTP事务的存档格式,可用于网页浏览器导出加载网页时的详细性能数据 ” ——Wikipedia
如下所示:通过改扩展可以帮住你方便地记录客户电脑上一段时间发生的网络时间,通过生成的HAR包文件,方便在自己本地电脑复现!🥸(好吧,我承认这场景有点无聊🥱)
后记
关于这个HAR扩展其实一开始我是打算纯手工拼接的方式👋 把这个har给攒出来的,头铁试了一段时间发现 臣妾做不到啊~~😭
里面涉及到的字段很多,具体可以查看这个链接🔗: http://www.softwareishard.com/blog/har-12-spec/
而且这些字段都来自页面加载过程中的不同事件,且不少有依赖关系,我粗略统计了一下至少需要监听以下这些事件👇
[
'Page.loadEventFired',
'Page.domContentEventFired',
'Page.frameStartedLoading',
'Page.frameAttached',
'Network.requestWillBeSent',
'Network.requestServedFromCache',
'Network.dataReceived',
'Network.responseReceived',
'Network.resourceChangedPriority',
'Network.loadingFinished',
'Network.loadingFailed',
...
]
有些数据的组织过程需要跨事件来攒,虽然说每个请求都有一个requestId来串起来,但是其中的拼接规则 我还是拿不准😭
于是就放弃了纯手工攒数据的方向,开始求助社区,果然在万能的npm里被我发现了这个👉:Chrome-har
看该package的简介:
“Create HAR files based on Chrome DevTools Protocol data.
Code originally extracted from Browsertime,
initial implementation inspired by Chromedriver_har.”
简介中的第一句话正是我要的,后面提到了另一个工具 Browsertime 我去看了下,很强大不过我用不到~,感兴趣的小伙伴可以去了解一下 Browsertime
npm install chrome-har 直接开搞 ⌨️
/*background.js*/
// 首先我在后台脚本的全局环境下初始化了几个变量,分别用来表示当前是否正在记录网络事件、
// 当前通信的tab页签、网页名称(这个后面给生成的har包命名用),
// 以及一个用来保存收集到的事件对象的数组,这个用来“喂” 给chrome-har 生成我需要的文件
let recording = false;
let tabId = void 0;
let tabTitle = '';
let networkEvents = []; // 收集所有的事件对象
// 定义chrome扩展的debugger事件监听函数
const debuggerEventListener = (debuggeeId, method, params) => {
networkEvents.push({method, params}); // 把监听到的事件一股脑儿全放 networkEvents 里面
}
// 监听开始的函数,需要调用chrome.debugger.attach方法将要监听的网页tabId绑定
// 并开启对 'Network.enable' 和 'Page.enable' 系列事件的监听
const startRecording = () => {
// https://developer.chrome.com/docs/extensions/reference/api/debugger?hl=zh-cn#method-attach
chrome.debugger.attach({ tabId }, '1.2', function () {
chrome.debugger.sendCommand({ tabId }, 'Network.enable', {});
chrome.debugger.sendCommand({tabId}, 'Page.enable', {}); // page事件包含时间信息,需要添加监听
});
recording = true;
}
// 停止监听函数,调用chrome.debugger.detach 方法解除对监听的网页的绑定
const stopRecording = () => {
chrome.debugger.detach({tabId}, function () {
recording = false;
})
}
// 初始化:background脚本刚加载也就是扩展刚进入工作时执行的一个给扩展icon添加一个OFF字段,
// 表示未工作
chrome.runtime.onInstalled.addListener(() => {
chrome.action.setBadgeText({
text: 'OFF' // 默认off
});
});
// 当用户点击扩展icon时候的监听函数
chrome.action.onClicked.addListener((tab) => {
tabId = tab.id; // 设置tabId
tabTitle = tab.title; // 设置tabTitle
chrome.action.getBadgeText({ tabId: tab.id }, function (preState) {
const nextState = preState === 'ON' ? 'OFF' : 'ON'; // 切换当前的工作状态的显示
chrome.action.setBadgeText({
tabId: tab.id,
text: nextState
});
if (nextState === 'ON') {
startRecording(); // 开启监听 -> attach
chrome.debugger.onEvent.addListener(debuggerEventListener); // 监听回调
} else if (nextState === 'OFF') {
stopRecording(); // 停止监听 -> detach
chrome.debugger.onEvent.removeListener(debuggerEventListener); // 移除监听回调
// 这里我们把收集到的事件做了一个过滤,去除里面的Page.frameResized事件,这个是记录开始时顶部会有一个提示扩展正在调试该页面的
// 提示信息导致页面尺寸发生变动,这个事件对于我们最终收集数据没用徒占空间,因此我们直接把它过滤出去
networkEvents = networkEvents.filter(item => item.method !== 'Page.frameResized');
// 在把收集到的事件信息发送给前台之前,做一个检查✊
if (networkEvents.length) {
// 利用chrome.tabs.sendMessage消息通信将后台收集到的数据发送给前台
chrome.tabs.sendMessage(tabId, {
type: 'Har',
harData: networkEvents,
name: tabTitle
}, null, (response) => {
if (response && response.action === 'clear') {
networkEvents = [];
}
})
} else {
alert('没有监听到任何有效数据,请重新操作。');
}
}
})
});
/*content.js 前台脚本*/
// 引入我们安装的chrome-har (注意:这是一个node包,所以我们使用commonjs的方式require引入)
const { harFromMessages } = require('chrome-har');
// 监听函数逻辑
const backgroundListener = (message, sender, sendResponse) => {
// sender.id 是扩展的id "njjdcblpajfbeljfhfgiielnhgkdmhkn"
if (message.type === 'Har') {
// 调用chrome-har包提供的 harFromMessages 方法生成 har文件
const har = harFromMessages(message.harData, {includeTextFromResponseBody: true, includeResourcesFromDiskCache: true});
if (!har.log.pages.length || !har.log.entries.length) {
alert('没有监听到任何有效数据,请重新操作。');
}
// 下面👇的操作就是生成完har包后自动下载⏬
const blob = new Blob([JSON.stringify(har)], {
type: "application/json",
});
const downloadLink = document.createElement('a');
downloadLink.href = URL.createObjectURL(blob);
downloadLink.download = `${message.name}.har`;
downloadLink.textContent = 'Download HAR';
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
// 完成之后记得通知后台脚本做【清空】处理,否则下次记录网络事件会带上上一次的数据
sendResponse({
action: 'clear'
})
}
}
// 监听从后台传递过来的消息
chrome.runtime.onMessage.addListener(backgroundListener);
由于 chrome-har 是一个commonjs格式的node包,因此我们不能愉快的用vite进行构建打包只能重拾webpack,简单写个配置文件 npm run build 执行 webpack 走起!
/*webpack.config.js*/
const path = require('path');
module.exports = {
entry: '/src/content.js',
output: {
filename: 'content.js',
path: path.resolve(__dirname, 'dist'),
},
};
本以为这样就结束了,但是安装上扩展到浏览器进行测试发现,导出的har包中请求头、如参、url、时间信息...这些都有,唯独没有响应实体信息😺。。。
这个不尴尬了嘛😅,没有响应数据我看个毛啊,白折腾啊,于是我跑到了作者的github主页,去issues中试图寻找有没有类似问题,果然被我找到
这个issue下面有很多讨论,后面作者在一系列的回复中表示,不会单独在chrome-har中实现该功能,chrome-har 将只会作为使用已有数据组装har文件的简单工具
略带些许沮丧的我无奈打开了chrome-har的源码进行查看,在其中看到这样一行注释👀:
意思很明确response.body 需要我自己提供,并且给我甩出来了一个链接 🔗https://chromedevtools.github.io/devtools-protocol/tot/Network#type-Response
链接打开直接定位到了对于 Network.Response 的字段解析,从中得要想拿到response.body我需要自己通过 requestId 以异步请求的方式获取对应的 Network.Response 并从中解析出 response.body
所以我的后台脚本中需做修改
/*background.js*/
// 主要就是调整这个事件监听函数的处理逻辑,之前是把所有监听到的事件不做任何处理一股脑儿
// 直接放networkEvents里面了,现在需要做的就是对关联响应结果的事件单独做异步的请求
// 在返回的Network.Response 中解析出 response.body
const debuggerEventListener = (debuggeeId, method, params) => {
// networkEvents.push({method, params});
if (recording && debuggeeId.tabId === tabId) {
if (method !== 'Network.responseReceived' && method !== 'Network.dataReceived') {
networkEvents.push({method, params});
} else {
const requestId = params.requestId;
if (params.response) {
chrome.debugger.sendCommand({ tabId: tabId }, "Network.getResponseBody", { requestId: requestId }, function (responseBody) {
if (responseBody) {
if (responseBody.body) {
params.response.body = responseBody.body;
networkEvents.push({method, params});
} else {
networkEvents.push({method, params});
}
} else {
console.log('获取responseBody失败');
networkEvents.push({method, params});
}
});
} else {
networkEvents.push({method, params});
}
}
} else {
return;
}
}
经过一波三折的一番折腾后扩展终于可以正常工作了,撒花🎉 扩展已经发布到Chrome扩展商店,搜索 HAR Debugger 即可找到欢迎下载使用👏
扩展二:AI智能助手 + 网页划词
这个东西最近可是太火🔥了,各种AI助手...满天飞,自己也做了一个,效果如下:
聊天写作✏️能力
网页划词翻译能力
✨支持直接在PDF中划词哦~✨ 申请了专利技术😉
这里是用了 Shadowdom 封装了元素插入到文档中实现的
// 扩展的content.js
const injectShadowDom = () => {
const rootElement = document.createElement('div');
rootElement.id = 'aiAssistRoot';
// 防止重复注入
const rootDom = document.getElementById('aiAssistRoot');
rootDom && rootDom.remove();
// attachShadow to shadowHost
const shadowRoot = rootElement.attachShadow({mode: 'open'});
let reactContainer = document.createElement('div');
reactContainer.id = 'reactContainer';
shadowRoot.appendChild(reactContainer);
document.body.appendChild(rootElement);
// ⚠️注意我们的样式需要通过这种方式插入到shadowdom中
const styleNode = document.createElement('style');
const styleUrl = chrome.runtime.getURL('content.css');
fetch(styleUrl).then(res => res.text()).then(styleData => {
styleNode.textContent = styleData;
shadowRoot.appendChild(styleNode);
// 使用Antd 的 StyleProvider 将antd的样式也插入到shadowdom中
// <Scribe/> 就是我们页面上看到的划词和对话组件了
createRoot(reactContainer).render(
<StyleProvider container={shadowRoot}>
<Scribe/>
</StyleProvider>
)
})
}
injectShadowDom();
这里需要注意控制 组件拖拽的时候不要“溢出”屏幕边缘
// 拖拽限位
let x = 0;
let y = 0;
const mouseMoveHandler = (e: any) => {
const clientWidth = document.documentElement.clientWidth || document.body.clientWidth;
const clientHeight = document.documentElement.clientHeight || document.body.clientHeight;
const dx = e.clientX - x;
const dy = e.clientY - y;
// 限位,边缘不超出屏幕
if (freeBoxRef.current) {
if (freeBoxRef.current.offsetLeft + dx < 0) {
freeBoxRef.current.style.left = '0px'; // 左侧限位
} else if (freeBoxRef.current.offsetLeft + dx > clientWidth - 626) {
freeBoxRef.current.style.left = `${clientWidth - 626}px`; // 右侧限位
} else {
freeBoxRef.current.style.left = `${freeBoxRef.current.offsetLeft + dx}px`; // 左右移动范围
}
if (freeBoxRef.current.offsetTop + dy < 0) {
freeBoxRef.current.style.top = '0px'; // 顶部限位
} else if (freeBoxRef.current.offsetTop + dy > clientHeight - freeBoxRef.current.offsetHeight) {
freeBoxRef.current.style.top = clientHeight - freeBoxRef.current.offsetHeight > 0 ? `${clientHeight - freeBoxRef.current.offsetHeight}px` : '0px'; // 底部限位
} else {
freeBoxRef.current.style.top = `${freeBoxRef.current.offsetTop + dy}px`; // 上下活动范围
}
}
x = e.clientX;
y = e.clientY;
}
改版UI加入历史对话
后记
网页划词这部门的功能,因为我们在 content.js 中加入了 React 组件的创建 (createRoot) 所以,content.js需要单独的编译打包 单独编写 vite-content.js
/*vite-content.js*/
export default defineConfig({
root: 'src',
plugins: [
react(),
babel({
babelHelpers: 'bundled',
exclude: 'node_modules/**',
})
],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
// ⚠️注意build的配置
build: {
outDir: '../dist',
assetsInlineLimit: 8392,
rollupOptions: {
input: {
'content': '/ext/content.js' // 打包入口设置
},
output: {
entryFileNames: '[name].js', // 打包的结果需要保持文件名一致
// ⚠️ 不可以使用 iife 的方式,iife 会把 js 和 css 都打包到一起
// format: 'iife',
// 配置打包出的其他资源,这里其实就是我们的样式代码,命名为 content.css
// 这样就可以通过 const styleUrl = chrome.runtime.getURL('content.css');
// 然后 fetch(styleUrl).then...的方式拿到我们的样式代码了
assetFileNames: 'content.[ext]'
}
},
}
})
另外我的请求也放到后台脚本 background.js 中去了,这样用户在页面的 devtools 的网络请求中就不会看到扩展的请求了 实现更好的封装和隐藏🫥,此时前台通过事件触发后台发起请求,再从后台接受返回的响应就需要一套良好的通信机制,在Chrome扩展 中主要用两种通信的方式:
1、chrome.runtime.sendMessage/chrome.tabs.sendMessage 短链接的方式
2、chrome.runtime.connect/chrome.tabs.connect 长链接的方式
短连接的话就是挤牙膏一样,我发送一下,你收到了再回复一下,如果对方不回复,你只能重新发,而长连接类似WebSocket会一直建立连接,双方可以随时互发消息。
这里开始我使用的是短链接的方式,但是发现服务端SSE推送的数据流过来的时候,需要不断的触发 sendMessage 和 onMessage 经常会出现消息错乱,同一个消息内容重复多次的bug,改成长链接的方式就好了👌。
/*content.js向background.js 发起长链接*/
const port = chrome.runtime.connect({name: "exampleName"}); // 需要传递一个带name的对象,返回一个port
...
// 之后就能利用这个port进行消息的发送和响应了
// 发送请求
port.postMessage({
type: 'gptRequest',
payload: {
params
}
});
// 接受响应
port.onMessage.addListener(msg => {
...
})
/*background.js中监听来自前台的链接请求和消息*/
chrome.runtime.onConnect.addListener(port => {
// 区分消息通道
if (port.name === 'exampleName') {
port.onMessage.addListener(msg => {
if (msg.type === 'aiRequest') {
// 可以在这里发起请求了,因为服务端返回的是流式数据,所以我采用的fetch来处理
const {params} = msg.payload;
controller = new AbortController(); // 终止信号
const {signal} = controller;
requestAi(params, signal).then(res => {
const reader = res.body?.getReader();
const decoder = new TextDecoder('utf-8');
return new ReadableStream({
start(controller) {
function push() {
reader?.read().then(({done, value}) => {
if (done) {
port.postMessage({type: 'gptResponseDown'}); // 响应结束
return;
}
let str = decoder.decode(value);
if (str.includes('error') && str.includes('code') && !str.startsWith('data: ') && !str.includes('choices')) {
// 报错
port.postMessage({type: 'gptResponseError'}); // 响应报错
controller.close();
return;
}
str = str.split('\n')
.filter((line) => line.startsWith('data: ') && line.includes('choices'))
.map((line) => JSON.parse(line.replace('data: ', '')).choices[0].delta.content).join('');
port.postMessage({
type: 'gptResponseValue',
payload: {
value: str
}
});
controller.enqueue(value);
push();
})
}
push();
}
})
})
} else if (msg.type === 'abortRequest') {
controller.abort(); // 会报一个DOMException的异常暂时无法捕获到不影响业务🤷♀️
}
})
}
})
PDF 划词实现
PDF中划词能力的实现是借助了客户端封装的方法,通过在扩展中监听来自客户端的内容选中事件,并解析事件提供的参数来实现的




