自己做一个前端埋点SDK真的是泰裤辣!
我是一个特别固执的人。
我从来不会在意别人跟我说什么,让我去做。
如果你也可以像我一样(开发一个前端埋点SDK),那我觉得这件事情。
泰裤辣!!!

Why?
你可能有疑问:
为什么我要自己开发一个埋点SDK?
为什么不用神策?不用友盟?
领导是这么回答的:没钱。🥲🥲🥲
主打的就是一手折腾(公司的司训里面确实有这么一条【勇于折腾】,所以我在季度评分的时候直接给自己评了100分不过分吧,salute)
How?
调研
一开始,我也想借鉴神策开源的前端SDK来整个阉割版。但是一打开神策的Github仓库我直接给吓傻了。他的sensorsdata.es6.full.js
一个文件有1w多行代码。我滴妈。哥你不累吗。好歹分一下啊。

还好我用过他家的东西。不然真的得看到天荒地老。
它的事件上报有2种形式:
看过其他的文章,navigator.sendBeacon
这种形式发送数据还存在着或多或少的兼容性问题,所以在我的SDK里面,暂时就不打算支持了(其实是后端说我不想再写一个POST接口了🥲,gif是GET接口。我直呼好家伙。倒是给我省事了)
需求分析
上面给我的需求有3点:
- 支持自定义上报
- 支持PV采集(page view)
- 支持元素点击事件采集(这个是用来分析页面的热力图,跟神策的差不多)
ojbk啊。诞生于1996,梦想当前端领袖的我一下子就想着把后面2个需求拆分成插件的形式,按需开启,毕竟不是所有的项目都需要这些功能,元素点击事件采集对于服务器压力也是有那么一丢丢的。
码代码
创建了一个类叫CjmTracker
,(cjm是我公司的前缀。大家伙懂的都懂。下面我就不多说了)
设计了3个变量来存放对应的数据。具体看注释吧。
export type BaseProperties = {
$title: string; // 页面标题
$url: string; // 页面url
$referrer: string; // 页面来源
$userAgent: string; // 用户代理
};
export type Options = {
reportUrl: string; // required 上报地址
debug?: boolean; // non-required 是否开启debug模式
enablePVEvent?: boolean; // non-required 是否开启页面浏览事件自动上报, 默认不开启
enableWebClickEvent?: boolean; // non-required 是否开启页面点击事件自动上报,默认不开启
};
type TrackData = {
distinctId: string; // 对用户的标识,填充UUID
version: string; // 上传版本 客户端+版本号,web_1.0.0
project: string; // 项目唯一标识
event: string; // 事件名称
time: string; // 事件发生的时间
properties?: BaseProperties & {
[key: string]: any;
}; // 自定义参数
};
class CjmTracker {
/**
* 默认参数,也就是每次上报都需要带上的参数,
* 例如:对用户的标识、上传版本、项目唯一标识、事件名称、事件发生的时间等等
*/
baseConfig: Partial<TrackData> = {};
/**
* 默认属性,在不同的项目里面其实想要采集的数据不太一样
* 比如说A项目里,我需要在登录后把用户id每一次上报都带上。
* 我就可以把自定义的$userId存放在baseProperties中,
* 然后每次上报的时候都会拼接到TrackData.properties中进行上报。
*/
baseProperties: Record<string, any> = {};
/**
* 默认配置
* 上报地址、PV事件开关、点击事件开关、debug开关
*/
options: Options = {
reportUrl: '',
debug: false,
enablePVEvent: false,
enableWebClickEvent: false,
};
...
}
设计好了数据结构。那么就开始构造函数的实现。
构造函数无非是把该初始化的东西初始化了,这里我们针对上面描述的3个参数进行一个赋值。
class CjmTracker {
...
/**
* 构造函数
* @param options 默认配置
* @param defaultConfig 默认参数
*/
constructor(options: Options, defaultConfig: Partial<TrackData>) {
// 这里写了一些必填参数的校验。没有这些参数的话。后面做再多事也没什么意义了
if (!!defaultConfig?.version) {
throw new Error('version为保留字段,不允许设置默认值');
}
if (!!defaultConfig?.distinctId) {
throw new Error('distinctId为保留字段,不允许设置默认值');
}
if (!options?.reportUrl) {
throw new Error('reportUrl为必填字段');
}
if (!defaultConfig?.project) {
throw new Error('project为必填字段');
}
// 默认参数的赋值
this.baseConfig = {
// 这里SDK_TYPE固定为"web",SDK_VERSION则从package.json中获取version
version: `${SDK_TYPE}_${SDK_VERSION}`,
// getDistinctId函数会先从localStorage里面取值,没有的话再生成一个存进去
distinctId: getDistinctId(),
...defaultConfig
};
// 默认配置的赋值
this.options = { ...this.options, ...options };
// 自己封装的log函数。看源码去吧。不值一提。
log({
level: 'info',
message: `CjmTracker ${SDK_TYPE}_${SDK_VERSION} 实例化成功`,
});
// 接下来就开始初始化事件了
this.initEvent();
}
...
}
到这。构造函数就算完成了。
在初始化事件initEvent
之前,还得再暴露几个方法:
- addProperties,针对不同的项目,可添加项目特有的预置属性,例如添加
$userId
- track,上传数据方法
- imagePost,图片请求方法
class CjmTracker {
...
/**
* 针对不同的项目,可添加项目特有的预置属性
*/
addProperties(properties: Record<string, any>) {
this.baseProperties = { ...this.baseProperties, ...properties };
}
/**
* 图片请求
*/
imagePost(stringData: string) {
if (!this.options?.reportUrl) {
log({
level: 'error',
message: '上报地址为空',
});
return;
}
const image = new Image();
image.crossOrigin = 'anonymous';
image.src = `${this.options.reportUrl}?data=${encodeURIComponent(stringData)}`;
}
/**
* 添加追踪数据,无论自定义或者是内置插件的数据上报,都使用该方法
* @param event 事件名称
* @param customProperties 自定义属性
* @param callback 回调函数
*/
track(event: string, customProperties?: object, callback?: Function) {
// 合并参数
const trackData: Partial<TrackData> = {
time: getTime(), // 时间戳
event, // 事件名称
...this.baseConfig, // 默认配置
properties: {
...this.getDefaultProperties(), // 获取默认属性
...customProperties, // 自定义属性
},
};
// 校验必填参数
const { result, lostKeys } = this.checkRequiredParams(trackData);
if (!result) {
this.log({
message: `track -> 缺少必填参数: ${lostKeys.join(',')}`,
level: 'error',
});
return;
}
let encodeDataString = '';
try {
const stringifyData = JSON.stringify(trackData);
this.log({
message: `track -> raw data:\n${stringifyData}`,
level: 'success',
});
encodeDataString = base64Encode(stringifyData);
this.log({
message: `track -> encode data:\n${encodeURIComponent(encodeDataString)}`,
level: 'success',
});
} catch (e) {
encodeDataString = '';
this.log({ message: 'track -> encode data error', level: 'error' });
}
if (encodeDataString) {
this.imagePost(encodeDataString);
if (isFunction(callback)) {
callback?.(trackData);
}
}
}
...
}
track
方法把数据先转成json字符串,再转成base64,参考的神策,为什么这么做我是这么觉得的
- 可以避免很多非法url的问题
- 避免明文传输
- ...(肯定还有其他原因,懂王们请在评论区跟我说说呗,求求了)
接下来就是2个插件的设计了。
- PV事件采集插件
page view事件的采集,我目前只考虑了单页应用的。插件我单独放在了libs
目录下
// src/libs/pageview.ts
import {isFunction} from "../utils";
export function addPageViewListener(callback: Function) {
// lastUrl用来存储上一个页面的url
let lastUrl = location.href;
// 下面2个常量存储了没有自定义之前的pushState和replaceState方法
const historyPushState = window.history.pushState;
const historyReplaceState = window.history.replaceState;
if (isFunction(window.history.pushState)) {
// 如果你的浏览器支持history.pushState,那么需要对它进行一次自定义
window.history.pushState = function () {
// 首先需要实现它原来的功能
historyPushState.apply(window.history, arguments);
// 然后我们把上一个页面的url传给回调函数
callback(lastUrl);
// 最后更新一下lastUrl
lastUrl = location.href;
};
}
if (isFunction(window.history.replaceState)) {
// replaceState也是同上
window.history.replaceState = function () {
historyReplaceState.apply(window.history, arguments);
callback(lastUrl);
lastUrl = location.href;
};
}
let singlePageEvent;
// 这里的documentMode我参考的神策sdk,是对IE的兼容,具体就不赘述了
if (window.document.documentMode) {
singlePageEvent = 'hashchange';
} else {
singlePageEvent = historyPushState ? 'popstate' : 'hashchange';
}
// 开始监听popstate或者hashchange事件
window.addEventListener(singlePageEvent, function () {
callback(lastUrl);
lastUrl = location.href;
});
}
- 元素点击事件采集插件
// src/libs/webclick.ts
import {WebClickEventProperties} from "../types";
/**
* 获取元素的关系字符串(从子级一直递归到最外层)
* 例如两层div的关系会得到字符串: div>div
*/
export function getNodeXPath(node: Element, curPath = ''): string {
if (!node) return curPath
const parent = node.parentElement
const { id } = node
const tagName = node.tagName.toLowerCase()
const path = curPath ? `>${curPath}` : ''
if (
!parent ||
parent === document.documentElement ||
parent === document.body
) {
return `${tagName}${path}`
}
if (id) {
return `#${id}${path}` // 知道了id 就不需要获取上下级关系了(id是唯一的)
}
return getNodeXPath(parent, `${tagName}${path}`)
}
export function addWebClickListener(callback: (properties: WebClickEventProperties) => void) {
// 在这个插件里面我们唯一要做的就是收集被点击元素的信息,
// 我这里收集的内容都定义在WebClickEventProperties中了。
document.addEventListener('click', (event: MouseEvent) => {
const target = event.target as HTMLElement;
const $elementId = target.id; // 元素id
const $elementContent = target.textContent?.slice(0, 128); // 元素内容,长度限制128字符
const $elementClassName = target.className; // 元素class名
const $elementName = target.getAttribute('name'); // 元素name属性
const $elementType = target.tagName; // 元素标签名
const $elementPath = getNodeXPath(target).slice(-128); // 元素path,长度限制128字符
if ($elementType === 'BODY') return; // 点击body不上报,无意义
callback({
$elementId,
$elementContent,
$elementClassName,
$elementName,
$elementType,
$elementPath,
});
});
}
至此2个插件已经设计完成了,当然还有的小伙伴可能需要页面性能采集、页面报错采集等等,这些都可以后续新增,我没时间,需求也没提,所以我就没做了。
ok,上面说到的initEvent
还没实现,现在我们可以实现一下了。
class CjmTracker {
...
initEvent() {
// PV事件采集
if (!!this.options.enablePVEvent) {
// 这里在页面初始化的时候,就要开始进行一次采集
this.track('pageview');
// 后面就开始根据前后url的比较进行采集了,前后url不一致才会进行采集
addPageViewListener((lastUrl: string) => {
if (lastUrl !== location.href) {
this.track('pageview');
}
});
}
// 元素点击事件采集
if (!!this.options.enableWebClickEvent) {
addWebClickListener((properties) => {
this.track('webclick', properties);
});
}
}
...
}
到此。整个SDK的设计基本就完事了。
打包
为了把整个SDK运用于更多的项目中,我采用了umi团队开发的father来进行打包,我这里需要2种打包产物:
- esm:可以通过npm安装的形式导入到项目中
- umd:生成的js文件可以在HTML中通过script标签直接引入
发布
我在package.json中设置了3个发布的命令,发布版本时,会根据patch、minor、major来进行相应的版本号自增。
...
"scripts": {
"patch": "npm version patch -m \"build: release %s\" && npm publish",
"minor": "npm version minor -m \"build: release %s\" && npm publish",
"major": "npm version major -m \"build: release %s\" && npm publish",
},
...
使用
至于怎么使用,我就不多说了。具体看我github上的example内容吧。
反正就先这样,然后那样,最后这样就ok了🥹(嘻嘻)
也不知道上面说清楚了没有。水平有限。多多包涵🥹
差点忘了贴上Github的地址,github.com/Fa-haxiki/c…

转载自:https://juejin.cn/post/7229516691172524069