写在前面
博主最近半年的时间都在投入 concis
react组件库的开发,最近阶段也是想要做一些市面组件库所没有的东西,concis
主要为业务平台开发提供了一系列组件,而埋点在业务中的实用性是很高的,搭配业务端埋点和后台监控,可以收集到很多信息,如性能参数、错误捕捉、请求响应过慢等一系列问题,因此本文记录了开发一个埋点SDK组件的全过程。
效果
先看使用方式吧,这是一个普通的React
项目中的 App.jsx
组件:
import React from 'react'
import { Route, Routes } from "react-router-dom";
import A from './pages/A';
import B from './pages/B'
import { Track } from 'concis'
function App() {
const trackRef = React.useRef();
// 用在项目根目录,定时上报,如每隔一小时上报一次
setInterval(() => {
getTrackData();
}, 60 * 60 * 1000)
function getTrackData() {
const res = trackRef.current.callbackTrackData();
//接口上报...
}
return (
<div>
<Routes>
<Route path="/" element={<A />} />
<Route path="/a" element={<A />} />
<Route path="/b" element={<B />} />
</Routes>
<Track ref={trackRef} />
</div>
)
}
Track
组件运行在项目根目录,做到每个页面信息收集的作用,并且向外暴露了 callbackTrackData
api,可以结合业务中的场景,在指定时刻收集信息,并上报到后端。
思路
Track
组件本身并不复杂,其实就是将一系列数据采集、信息捕捉、请求拦截汇集在了组件内部,并记录在状态中,在需要的时候向外暴露。
因此在组件中定义这些状态:
const Track = (props, ref) => {
const { children } = props;
const [performanceData, setPerformanceData] = useState({});
const xhrRequestResList = useRef([]);
const fetchRequestResList = useRef([]);
const resourceList = useRef({});
const userInfo = useRef({});
const errorList = useRef([]);
const clickEventList = useRef([]);
//...
return (
//...
)
}
-
performanceData
用于收集页面性能相关参数,如FP、FCP、FMP、LCP、DOM Load、white time等一系列参数。 -
xhrRequestResList
用于捕获页面中所有xhr
请求,收集请求方式、响应完成时间。 -
fetchRequestResList
用于捕获页面中所有fetch
请求,收集请求方式、响应完成时间。 -
resourceList
用于收集页面中所有文件、静态资源的请求数据,如js
、css
、img
。 -
userInfo
用于收集用户相关信息,如浏览器参数、用户IP、城市、语言等。 -
errorList
用于收集发生在生产环境下错误的捕获,包括error
和rejectError
。 -
clickEventList
用于收集用户在页面上的点击行为。
performanceData
页面加载相关的性能参数代码如下:
const collectPerformance = async () => {
const fp = await collectFP();
const fcp = await collectFCP();
const lcp = await collectLCP();
const loadTime = await collectLoadTime();
const dnsQueryTime = collectDNSQueryTime();
const tcpConnectTime = collectTCPConnectTime();
const requestTime = collectRequestTime();
const parseDOMTreeTime = collectParseDOMTree();
const whiteTime = collectWhiteTime();
setPerformanceData({
fp,
fcp,
lcp,
dnsQueryTime,
tcpConnectTime,
requestTime,
parseDOMTreeTime,
whiteTime,
loadTime,
});
};
这里以fp
、fcp
举例,主要用到了PerformanceObserver
api,收集这些参数,代码如下:
const collectFP = () => {
return new Promise((resolve) => {
const entryHandler = (list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-paint') {
resolve(entry);
observer.disconnect();
}
}
};
const observer = new PerformanceObserver(entryHandler);
observer.observe({ type: 'paint', buffered: true });
});
};
const collectFCP = () => {
return new Promise((resolve) => {
const entryHandler = (list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
resolve(entry);
observer.disconnect();
}
}
};
const observer = new PerformanceObserver(entryHandler);
observer.observe({ type: 'paint', buffered: true });
});
};
而其他参数则是直接使用了 window.performance.timing
计算得来。
xhrRequestResList
捕获xhr
请求其实很简单,在原有的XMLHttpRequest.prototype
上的open
、send
方法上记录我们所需要的参数,如url
、method
,同时在send
方法中介入loadend
方法,当请求完成,整理参数。
// 统计每个xhr网络请求的信息
const monitorXHRRequest = (callback) => {
const originOpen = XMLHttpRequest.prototype.open;
const originSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function newOpen(...args) {
this.url = args[1];
this.method = args[0];
originOpen.apply(this, args);
};
XMLHttpRequest.prototype.send = function newSend(...args) {
this.startTime = Date.now();
const onLoadend = () => {
this.endTime = Date.now();
this.duration = this.endTime - this.startTime;
const { status, duration, startTime, endTime, url, method } = this;
const reportData: xhrRequestType = {
status,
duration,
startTime,
endTime,
url,
method: (method || 'GET').toUpperCase(),
success: status >= 200 && status < 300,
subType: 'xhr',
type: 'performance',
};
callback(reportData);
this.removeEventListener('loadend', onLoadend, true);
};
this.addEventListener('loadend', onLoadend, true);
originSend.apply(this, args);
};
};
当状态码在200~300之间,则判定为success,最后通过异步回调函数的机制,回传到组件中,加入状态。
fetchRequestResList
捕获 fetch
思路和 xhr
类似,只不过fetch
本身基于 promise
实现,在重写 fetch
api的时候通过promise
的形式去写就可以。
// 统计每个fetch请求的信息
const monitorFetchRequest = (callback) => {
const originalFetch = window.fetch;
function overwriteFetch() {
window.fetch = function newFetch(url, config) {
const startTime = Date.now();
const reportData: fetchRequestType = {
startTime,
endTime: 0,
duration: 0,
success: false,
status: 0,
url,
method: (config?.method || 'GET').toUpperCase(),
subType: 'fetch',
type: 'performance',
};
return originalFetch(url, config)
.then((res) => {
reportData.endTime = Date.now();
reportData.duration = reportData.endTime - reportData.startTime;
const data = res.clone();
reportData.status = data.status;
reportData.success = data.ok;
callback(reportData);
return res;
})
.catch((err) => {
reportData.endTime = Date.now();
reportData.duration = reportData.endTime - reportData.startTime;
reportData.status = 0;
reportData.success = false;
callback(reportData);
throw err;
});
};
}
overwriteFetch();
};
和xhr
一样,最后通过异步回调函数的形式回传到组件中。
resourceList
获取页面中网络请求以外的其他资源,通过 window.performance.getEntriesByType
api,整理出指定资源的信息,最后组装成一个resource
列表。
const getResources = () => {
if (!window.performance) return;
const data = window.performance.getEntriesByType('resource');
const resource = {
xmlhttprequest: [],
css: [],
other: [],
script: [],
img: [],
link: [],
fetch: [],
// 获取资源信息时当前时间
time: new Date().getTime(),
};
data.forEach((item: resourceItemType<number> & PerformanceEntry) => {
const arry = resource[item.initiatorType];
arry &&
arry.push({
name: item.name, // 资源名称
type: 'resource',
sourceType: item.initiatorType, // 资源类型
duration: +item.duration.toFixed(2), // 资源加载耗时
dns: item.domainLookupEnd - item.domainLookupStart, // DNS 耗时
tcp: item.connectEnd - item.connectStart, // 建立 tcp 连接耗时
redirect: item.redirectEnd - item.redirectStart, // 重定向耗时
ttfb: +item.responseStart.toFixed(2), // 首字节时间
protocol: item.nextHopProtocol, // 请求协议
responseBodySize: item.encodedBodySize, // 响应内容大小
resourceSize: item.decodedBodySize, // 资源解压后的大小
isCache: isCache(item), // 是否命中缓存
startTime: performance.now(),
});
});
function isCache(entry) {
// 直接从缓存读取或 304
return entry.transferSize === 0 || (entry.transferSize !== 0 && entry.encodedBodySize === 0);
}
return resource;
};
userInfo
用户信息分为两类:
- 浏览器(navigator)的信息;
- 用户本身的信息,如IP、所在位置;
这里获取第二类的方式通过第三方接口接入:
const getUserIp = () => {
return new Promise((resolve, reject) => {
const scriptElement = document.createElement('script');
scriptElement.src = `https://pv.sohu.com/cityjson?ie=utf-8`;
document.body.appendChild(scriptElement);
scriptElement.onload = () => {
try {
document.body.removeChild(scriptElement);
// @ts-ignore
resolve(window.returnCitySN);
} catch (e) {
reject(e);
}
};
});
};
获取浏览器相关的参数代码如下:
const getNativeBrowserInfo = () => {
const res: nativeBrowserInfoType = {};
if (document) {
res.domain = document.domain || ''; // 获取域名
// res.url = String(document.URL) || ''; //当前Url地址
res.title = document.title || '';
// res.referrer = String(document.referrer) || ''; //上一跳路径
}
// Window对象数据
if (window && window.screen) {
res.screenHeight = window.screen.height || 0; // 获取显示屏信息
res.screenWidth = window.screen.width || 0;
res.color = window.screen.colorDepth || 0;
}
// navigator对象数据
if (navigator) {
res.lang = navigator.language || ''; // 获取所用语言种类
res.ua = navigator.userAgent.toLowerCase(); // 运行环境
}
return res;
};
总体列表如图:
errorList
捕捉错误分为了同步错误(console.aaa(123))和异步错误(promise所遗漏未捕捉到的reject)
因此在全局挂载两个通用事件,当捕获到错误时推入错误列表中即可。
const getJavaScriptError = (callback) => {
window.addEventListener('error', ({ message, filename, type }) => {
callback({
msg: message,
url: filename,
type,
time: new Date().getTime(),
});
});
};
const getJavaScriptAsyncError = (callback) => {
window.addEventListener('unhandledrejection', (e) => {
callback({
type: 'promise',
msg: (e.reason && e.reason.msg) || e.reason || '',
time: new Date().getTime(),
});
});
};
clickEventList
收集点击行为信息原理是全局挂载mouseDown
、touchstart
事件,在触发事件时收集DOM
事务相关信息,代码如下:
const onClick = (callback) => {
['mousedown', 'touchstart'].forEach((eventType) => {
let timer;
window.addEventListener(eventType, (event) => {
clearTimeout(timer);
timer = setTimeout(() => {
const target = event.target as eventDom & EventTarget;
const { top, left } = (target as any).getBoundingClientRect();
callback({
top,
left,
eventType,
pageHeight: document.documentElement.scrollHeight || document.body.scrollHeight,
scrollTop: document.documentElement.scrollTop || document.body.scrollTop,
type: 'behavior',
subType: 'click',
target: target.tagName,
paths: (event as any).path?.map((item) => item.tagName).filter(Boolean),
startTime: event.timeStamp,
outerHTML: target.outerHTML,
innerHTML: target.innerHTML,
width: target.offsetWidth,
height: target.offsetHeight,
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
});
}, 500);
});
});
};
可以捕获到对应DOM
节点的触发时机和位置信息,在后台分析数据时过滤出指定的DOM
可以对于热门用户行为进行分析报告。
写在最后
至此,Track
组件写完了,业务方可以结合Track
组件在需要上报的时机进行数据收集,这其实是 concis
给业务端做出的收集层的便捷,由于上报的业务场景太多,本来是想在组件内部一起做完的,最后还是决定组件层只做数据收集,分析和上报留给业务方。