介绍
神策分析 Web JS SDK,是一款轻量级用于 Web 端和 H5 端的数据采集埋点 SDK,它的核心功能是数据采集和发送数据到指定的服务端。具体而言,是指使用原生 JavaScript 技术实现代码埋点、全埋点、可视化全埋点、网页热力图和触达图等功能
初始化sdk
从项目入口的index.html中引入sdk源码开始使用,最重要的三个属性需要设置:
- name:name属性会挂载在全局window下提供api调用
- server_url:在sdk1.31.3版本以后,无需设置web_url和heatmap_url,在源码中会自动指向神策数据的cdn地址,但是这里必须设置数据上传的后端接收地址
- heatmap:点击图配置,默认配置表示不自动采集元素点击事件和页面停留事件,配置成 {} 表示开启 WebClick 采集 和 WebStay 自动采集,默认 $WebClick 只采集 a,button,input ,textarea 四个 dom 元素的点击事件
<!-- 载入 JS SDK -->
<script>
(function (para) {
if (typeof window["sensorsDataAnalytic201505"] !== "undefined") {
return false;
}
window["sensorsDataAnalytic201505"] = para.name;
window[para.name] = {
para: para,
};
})({
name: "sensors",
server_url: "http://test-syg.datasink.sensorsdata.cn/sa?token=xxxxx",
heatmap: {},
});
</script>
<script src="<%= BASE_URL %>cdn/sensorsdata/1.23.2/sensorsdata.min.js"></script>
sdk init
生命周期
在完成上述的sdk配置以后会开启埋点监控上传数据,在源码中的表现是调用 sd.init方式进行初始化,首先需要了解初始化sdk的三个自定义生命周期:
- 初始化state为0,表示sdk还未做任何做操作
- 设置state为1,表示sdk还未做初始化的动作
- 设置state为2,表示sdk正在进行初始化的动作
- 设置state为3,表示store实例完成,暂时只需要知道store是一个内部实现的存储器,后面会提及
// 重点是state和stateType中的三个自定义状态
var readyState = {
state: 0,
historyState: [],
stateType: {
1: '1-init未开始',
2: '2-init开始',
3: '3-store完成'
},
getState: function() {
return this.historyState.join('\n');
},
setState: function(n) {
if (String(n) in this.stateType) {
this.state = n;
}
this.historyState.push(this.stateType[n]);
}
};
init事件阶段
var spa = new EventEmitter();
var sdk = new EventEmitter();
var ee = {};
ee.spa = spa;
ee.sdk = sdk;
ee.EVENT_LIST = {
spaSwitch: ['spa', 'switch'],
sdkAfterInitPara: ['sdk', 'afterInitPara'],
sdkBeforeInit: ['sdk', 'beforeInit'],
sdkAfterInit: ['sdk', 'afterInit']
};
通过模拟实现的Node EventEmitter创建spa和sdk事件订阅和监听,spa指的是单页面,sdk指的是整体应用
在sd.init执行过程中对于sdk主要会有三个事件阶段:sdkBeforeInit、sdkAfterInitPara、sdkAfterInit
对于spa只会有spaSwitch事件,表示在单页面应用切换地址时会触发
ee.initSystemEvent = function() {
addSinglePageEvent(function(url) {
spa.emit('switch', url);
});
};
初始化步骤
sd.init中会传入para的参数,para会有defaultPara预置参数和我们在初始化传入的para属性
sd.init = function(para) {
ee.sdk.emit('beforeInit');
if (sd.readyState && sd.readyState.state && sd.readyState.state >= 2) {
return false;
}
if (is_compliance_enabled) {
implementCore(true);
}
ee.initSystemEvent();
sd.setInitVar();
sd.readyState.setState(2);
sd.initPara(para);
ee.sdk.emit('afterInitPara');
sd.detectMode();
sd.iOSWebClickPolyfill();
ee.sdk.emit('afterInit');
};
结合上述生命周期和事件阶段再来分析sd.init执行的操作:
- 通知sdk触发beforeInit事件,判断如果readyState>=2时返回false退出,因为readyState为2或3时已经正在做init操作或者做完init操作了
- is_compliance_enabled由window['sensors_data_pre_config']['is_compliance_enabled']决定是否给sd赋值
- 设置readyState为2之前会设置spaSwitch监听事件和初始化版本号等的动作
- 设置readyState为2之后初始化sd.para的配置参数,完成以后通知sdk触发afterInitPara事件
- detectMode会设置点击图、查询兼容、初始化store等工作,iOSWebClickPolyfill为ios的h5设备设备添加点击兼容事件处理
- 最后通知sdk触发afterInit完成整个初始化的过程
初始化store
在上一小节中查看detectMode函数,用途是在当前页面url中取出sensors相关配置参数做对应的回调赋值,重点关注在trackMode函数
function detectMode() {
if (heatmapMode.isSeachHasKeyword()) {
heatmapMode.hasKeywordHandle();
} else if (window.parent !== self && vtrackMode.isSearchHasKeyword()) {
vtrackMode.verifyVtrackMode();
} else if (heatmapMode.isWindowNameHasKeyword()) {
heatmapMode.windowNameHasKeywordHandle();
} else if (heatmapMode.isStorageHasKeyword()) {
heatmapMode.storageHasKeywordHandle();
} else if (window.parent !== self && vtrackMode.isStorageHasKeyword()) {
vtrackMode.verifyVtrackMode();
} else {
trackMode();
vtrackMode.notifyUser();
}
}
根据readyState的值从上一步的2-> 3 -> 4,此函数完成sd.store的初始化
function trackMode() {
sd.readyState.setState(3);
new sd.SDKJSBridge('visualized').onAppNotify(function() {
if (typeof sa_jssdk_app_define_mode !== 'undefined') {
defineMode(true);
} else {
defineMode(false);
}
});
defineMode(false);
sd.bridge.app_js_bridge_v1();
pageInfo.initPage();
listenSinglePage();
if (!sd.para.app_js_bridge && sd.para.batch_send && _localStorage.isSupport()) {
sd.batchSend.batchInterval();
}
sd.store.init();
sd.vtrackBase.init();
sd.readyState.setState(4);
enterFullTrack();
}
trackMode执行的操作如下:
- 将readyState设置为3之后,判断sdk的点击图是否可视化,defineMode如果返回true则会提示用户debug文案
- bridge.app_js_bridge_v1会判读安卓和ios端是否打通上传渠道,选择将数据上报到移动端sdk
- pageInfo.initPage会做初始化url和domian的判断,listenSinglePage开启切换页面时触发spaSwitch事件
- 判断batch_send属性是否为true开启监控数据的批量上报
- 初始化sd.store的属性存储
- sd.vtrackBase添加页面监听实时逐条发送数据
- 在readyState设置为4以后,enterFullTrack初始化点击图
store存储器
在前面初始化sdk时存储了默认的para预值参数,但是用户更新以后的属性会放在store里去存储,store会将数据转字符以后存储到cookie中
var store = {
requests: [],
_sessionState: {},
_state: {
distinct_id: '',
first_id: '',
props: {},
identities: {}
},
save: function() {
var copyState = JSON.parse(JSON.stringify(this._state));
delete copyState._first_id;
delete copyState._distinct_id;
if (copyState.identities) {
copyState.identities = base64Encode(JSON.stringify(copyState.identities));
}
var stateStr = JSON.stringify(copyState);
if (sd.para.encrypt_cookie) {
stateStr = encrypt(stateStr);
}
cookie$1.set(this.getCookieName(), stateStr, 73000, sd.para.cross_subdomain);
},
getCookieName: function() {
var sub = '';
if (sd.para.cross_subdomain === false) {
try {
sub = _URL(location.href).hostname;
} catch (e) {
sd.log(e);
}
if (typeof sub === 'string' && sub !== '') {
sub = 'sa_jssdk_2015_' + sd.para.sdk_id + sub.replace(/./g, '_');
} else {
sub = 'sa_jssdk_2015_root' + sd.para.sdk_id;
}
} else {
sub = 'sensorsdata2015jssdkcross' + sd.para.sdk_id;
}
return sub;
}
}
数据发送
我们将采集到的数据发送到后端server_url上,需要使用xhr的方式,源码中做了一层ajax的封装,首先查询ajax的使用地方
ajax$1
此封装方法会先调用protocol.ajax判断server_url和当前url的协议是否相同打印错误提示
function ajax$1(para) {
debug.protocol.ajax(para.url);
return ajax(para);
}
protocol:{
ajax: function(url) {
if (url === sdPara.server_url) {
return false;
}
if (isString(url) && url !== '' && !this.protocolIsSame(url, location.href)) {
sdLog('SDK 检测到您的数据发送地址和当前页面地址的协议不一致,建议您修改成一致的协议。因为 http 页面使用 https + ajax 方式发数据,在 ie9 及以下会丢失数据。');
}
}
}
批量发送
getSendCall的其中一个作用是判断localstorage是否支持和是否批量发送数据,调用sd.batchSend.add将当前事件存入缓存中,当调用sd.batchSend.send时遍历缓存事件,并将它们标记key,当调用batchSend.request发送完数据data(此时是一个数组)里后成功回调里清除标记key的事件
sendState.getSendCall = function(data, config, callback) {
...
var originData = data;
data = JSON.stringify(data);
var requestData = {
data: originData,
config: config,
callback: callback
};
// 批量发送数据
if (!sd.para.app_js_bridge && sd.para.batch_send && _localStorage.isSupport() && localStorage.length < 100) {
sd.log(originData);
sd.batchSend.add(requestData.data);
return false;
}
// sensors.setItem和sensors.deleteItem会触发这里,后文会讲
if (originData.type === 'item_set' || originData.type === 'item_delete') {
this.prepareServerUrl(requestData);
} else {
sendStageImpl.stage.process('beforeSend', requestData);
}
...
};
在初始化时会将以前堆积未发送的数据批量发送出去
BatchSend.prototype = {
// 每send_interval毫秒执行一次
batchInterval: function() {
if (this.serverUrl === '') this.getServerUrl();
if (!this.hasTabStorage) {
// 生成tabKey和data在localStorage存储
this.generateTabStorage();
this.hasTabStorage = true;
}
var self = this;
self.timer = setTimeout(function() {
// 更新expire过期时间
self.updateExpireTime();
// 循环expire清除过期数据
self.recycle();
// 遍历批量发送数据
self.send();
clearTimeout(self.timer);
self.batchInterval();
}, sd.para.batch_send.send_interval);
},
}
saEvent
saEvent.sendItem
往下查询可以追溯到调用会找到 saEvent.sendItem => sendState.getSendCall (后文会提及)
saEvent.sendItem = function(p) {
var data = {
lib: {
$lib: 'js',
$lib_method: 'code',
$lib_version: String(sd.lib_version)
},
time: new Date() * 1
};
extend(data, p);
dataStageImpl.stage.process('formatData', data);
sd.sendState.getSendCall(data);
};
saEvent.send
初始化sdk时设置debug_mode为true时开启调试模式,也就是sd.para.debug_mode变量。
当debug_mode为true时会在浏览器里打印发送到后端的数据,并且调用saEvent.debugPath方法发送get请求,下面会提及
当para.debug_mode为false时调用sendState.getSendCall(后文会提及)
saEvent.send = function(p, callback) {
var data = sd.kit.buildData(p);
sd.kit.sendData(data, callback);
};
kit.sendData = function(data, callback) {
var data_config = searchConfigData(data.properties);
if (sd.para.debug_mode === true) {
sd.log(data);
sd.saEvent.debugPath(JSON.stringify(data), callback);
} else {
sd.sendState.getSendCall(data, data_config, callback);
}
};
saEvent.debugPath
初始化sdk时设置了debug_mode为true时走入到这里发送get请求给server_url便于调试
saEvent.debugPath = function(data) {
var _data = data;
var url = '';
if (sd.para.debug_mode_url.indexOf('?') !== -1) {
url = sd.para.debug_mode_url + '&' + sd.kit.encodeTrackData(data);
} else {
url = sd.para.debug_mode_url + '?' + sd.kit.encodeTrackData(data);
}
ajax$1({
url: url,
type: 'GET',
cors: true,
header: {
'Dry-Run': String(sd.para.debug_mode_upload)
},
success: function(data) {
isEmptyObject(data) === true ? alert('debug数据发送成功' + _data) : alert('debug失败 错误原因' + JSON.stringify(data));
}
});
};
数据发送方式
概况
基于前文提到的数据发送saEvent会执行到sendState.getSendCall函数内部,继续往后走可以看到处理单条数据的发送
sendState.getSendCall = function(data, config, callback) {
...
this.prepareServerUrl(requestData);
...
}
//继续往下执行
sendState.prepareServerUrl = function(requestData) {
...
this.sendCall(requestData, requestData.config.server_url, requestData.callback);
...
};
sendState.sendCall = function(requestData, server_url, callback) {
...
this.realtimeSend(data);
...
};
sendState.realtimeSend = function(data) {
var instance = getRealtimeInstance(data);
instance.start();
};
function getRealtimeInstance(data) {
//重点是这个getSender函数
var obj = getSender(data);
var start = obj.start;
obj.start = function() {
...
};
return obj;
}
function getSender(data) {
var sendType = getSendType(data);
// 默认使用image方式上传数据
switch (sendType) {
case 'image':
return new ImageSender(data);
case 'ajax':
return new AjaxSender(data);
case 'beacon':
return new BeaconSender(data);
default:
return new ImageSender(data);
}
}
ImageSender
创建一个小图片地址指向server_url,在onload、onerror、onabort时做请求发送
var ImageSender = function(para) {
this.callback = para.callback;
this.img = document.createElement('img');
this.img.width = 1;
this.img.height = 1;
if (sd.para.img_use_crossorigin) {
//发送cors的origin
this.img.crossOrigin = 'anonymous';
}
this.data = para.data;
this.server_url = getSendUrl(para.server_url, para.data);
};
ImageSender.prototype.start = function() {
var me = this;
if (sd.para.ignore_oom) {
this.img.onload = function() {
this.onload = null;
this.onerror = null;
this.onabort = null;
me.isEnd();
};
this.img.onerror = function() {
this.onload = null;
this.onerror = null;
this.onabort = null;
me.isEnd();
};
this.img.onabort = function() {
this.onload = null;
this.onerror = null;
this.onabort = null;
me.isEnd();
};
}
this.img.src = this.server_url;
};
ImageSender.prototype.lastClear = function() {
var sys = getUA();
if (sys.ie !== undefined) {
this.img.src = 'about:blank';
} else {
this.img.src = '';
}
};
AjaxSender
ajaxSender代码比较简单,直接调用ajax$1方法使用post方式发送数据
var AjaxSender = function(para) {
this.callback = para.callback;
this.server_url = para.server_url;
this.data = getSendData(para.data);
};
AjaxSender.prototype.start = function() {
var me = this;
ajax$1({
url: this.server_url,
type: 'POST',
data: this.data,
credentials: false,
timeout: sd.para.datasend_timeout,
cors: true,
success: function() {
me.isEnd();
},
error: function() {
me.isEnd();
}
});
};
BeaconSender
navigator.sendBeacon() 方法可用于通过 HTTP POST 将少量数据 异步 传输到 Web 服务器,它主要用于将统计数据发送到 Web 服务器,同时避免了用传统技术(如:XMLHttpRequest)发送分析数据的一些问题。但是低版本浏览器不兼容该api,需要慎重使用
var BeaconSender = function(para) {
this.callback = para.callback;
this.server_url = para.server_url;
this.data = getSendData(para.data);
};
BeaconSender.prototype.start = function() {
var me = this;
if (typeof navigator === 'object' && typeof navigator.sendBeacon === 'function') {
navigator.sendBeacon(this.server_url, this.data);
}
setTimeout(function() {
me.isEnd();
}, 40);
};
基于saEvent封装的sensors Api
track
SDK 初始后,即可以通过 track() 方法追踪用户行为事件,并添加自定义属性
直接调用saEvent.send方法
function track(e, p, c) {
if (saEvent.check({
event: e,
properties: p
})) {
saEvent.send({
type: 'track',
event: e,
properties: p
},
c
);
}
}
login
当用户注册成功或者登录成功时,需要调用 login() 方法传入登录 ID
login => loginBody => sendSignup => saEvent.send
function login(id, callback) {
if (typeof id === 'number') {
id = String(id);
}
var returnValue = loginBody({
id: id,
callback: callback,
name: IDENTITY_KEY.LOGIN
});
!returnValue && isFunction(callback) && callback();
}
function loginBody(obj) {
var id = obj.id;
var callback = obj.callback;
...
sendSignup(id, '$SignUp', {}, callback);
...
}
function sendSignup(id, e, p, c) {
var original_id = store.getFirstId() || store.getDistinctId();
store.set('distinct_id', id);
saEvent.send({
original_id: original_id,
distinct_id: sd.store.getDistinctId(),
type: 'track_signup',
event: e,
properties: p
},
c
);
}
bind/unbind
对key和value的绑定和解绑,直接调用saEvent.send
function bind(itemName, itemValue) {
if (!saEvent.check({
bindKey: itemName,
bindValue: itemValue
})) {
return false;
}
sd.store._state.identities[itemName] = itemValue;
sd.store.save();
saEvent.send({
type: 'track_id_bind',
event: '$BindID',
properties: {}
});
}
function unbind(itemName, itemValue) {
if (!saEvent.check({
unbindKey: itemName,
bindValue: itemValue
})) {
return false;
}
...
saEvent.send({
identities: identities,
type: 'track_id_unbind',
event: '$UnbindID',
properties: {}
});
}
$WebClick事件实现
回到初始化enterFullTrack函数中继续往下看
function enterFullTrack() {
if (sd._q && isArray(sd._q) && sd._q.length > 0) {
each(sd._q, function(content) {
sd[content[0]].apply(sd, Array.prototype.slice.call(content[1]));
});
}
if (isObject(sd.para.heatmap)) {
// 初始化点击
heatmap.initHeatmap();
heatmap.initScrollmap();
}
}
initHeatmap方法会初始化点击,通过调用自定义addEvent方法监听元素的点击事件,调用start方法采集$webClick事件
initHeatmap: function(){
...
if (sd.para.heatmap.collect_elements === 'all') {
addEvent$1(document, 'click', function(e) {
...
var parent_ele = target.parentNode.tagName.toLowerCase();
if (parent_ele === 'a' || parent_ele === 'button') {
that.start(ev, target.parentNode, parent_ele);
} else {
that.start(ev, target, tagName);
}
});
} else {
addEvent$1(document, 'click', function(e) {
...
var target = ev.target || ev.srcElement;
var theTarget = sd.heatmap.getTargetElement(target, e);
if (theTarget) {
that.start(ev, theTarget, theTarget.tagName.toLowerCase());
} else if (isElement(target) && target.tagName.toLowerCase() === 'div' && isObject(sd.para.heatmap) && sd.para.heatmap.get_vtrack_config && sd.unlimitedDiv.events.length > 0) {
if (sd.unlimitedDiv.isTargetEle(target)) {
that.start(ev, target, target.tagName.toLowerCase(), {
$lib_method: 'vtrack'
});
}
}
});
}
}
start: function(ev, target, tagName, customProps, callback) {
...
if (tagName === 'a' && sd.para.heatmap && sd.para.heatmap.isTrackLink === true) {
sd.trackLink({
event: ev,
target: target
}, '$WebClick', prop);
} else {
sd.track('$WebClick', prop, userCallback);
}
},
$pageview事件实现
查看官方文档在sensor.quick("autoTrack")实现自动采集页面埋点
function quick() {
var arg = Array.prototype.slice.call(arguments);
var arg0 = arg[0];
var arg1 = arg.slice(1);
if (typeof arg0 === 'string' && commonWays[arg0]) {
// commonWays里调用autoTrack
return commonWays[arg0].apply(commonWays, arg1);
} else if (typeof arg0 === 'function') {
arg0.apply(sd, arg1);
} else {
sd.log('quick方法中没有这个功能' + arg[0]);
}
}
继续看commonWays.autoTrack
autoTrack: function(para, callback) {
...
if (sd.para.is_single_page) {
// 监听执行$pageview采集
addHashEvent(function() {
var referrer = getReferrer(current_page_url, true);
sd.track(
'$pageview',
extend({
$referrer: referrer,
$url: getURL(),
$url_path: getURLPath(),
$title: document.title
},
$utms,
para
),
callback
);
current_page_url = getURL();
});
}
sd.track(
'$pageview',
extend({
$referrer: getReferrer(null, true),
$url: getURL(),
$url_path: getURLPath(),
$title: document.title
},
$utms,
para
),
callback
);
...
}
最后来到addEvent函数中,此函数监听history路由的前进回退刷新事件来发送$pageview采集
function addHashEvent(callback) {
var hashEvent = 'pushState' in window.history ? 'popstate' : 'hashchange';
addEvent(window, hashEvent, callback);
}
$WebStay事件实现
回到初始化enterFullTrack函数中继续往下看
function enterFullTrack() {
if (sd._q && isArray(sd._q) && sd._q.length > 0) {
each(sd._q, function(content) {
sd[content[0]].apply(sd, Array.prototype.slice.call(content[1]));
});
}
if (isObject(sd.para.heatmap)) {
// 初始化点击
heatmap.initHeatmap();
// 初始化视区停留
heatmap.initScrollmap();
}
}
initScrollmap方法会初始化可视区停留事件,通过调用自定义addEvent方法监听window的scroll和unload事件,调用delayTime.go方法采集$WebStay事件
initScrollmap: function() {
...
var interDelay = function(param) {
var interDelay = {};
interDelay.timeout = param.timeout || 1000;
interDelay.func = param.func;
interDelay.hasInit = false;
interDelay.inter = null;
interDelay.main = function(para, isClose) {
this.func(para, isClose);
this.inter = null;
};
interDelay.go = function(isNoDelay) {
var para = {};
if (!this.inter) {
para.$viewport_position = (document.documentElement && document.documentElement.scrollTop) || window.pageYOffset || document.body.scrollTop || 0;
para.$viewport_position = Math.round(para.$viewport_position) || 0;
if (isNoDelay) {
interDelay.main(para, true);
} else {
this.inter = setTimeout(function() {
interDelay.main(para);
}, this.timeout);
}
}
};
return interDelay;
};
var delayTime = interDelay({
timeout: 1000,
func: function(para, isClose) {
var offsetTop = (document.documentElement && document.documentElement.scrollTop) || window.pageYOffset || document.body.scrollTop || 0;
var current_time = new Date();
var delay_time = current_time - this.current_time;
// 默认滑动延迟是4s
if ((delay_time > sd.para.heatmap.scroll_delay_time && offsetTop - para.$viewport_position !== 0) || isClose) {
para.$url = getURL();
para.$title = document.title;
para.$url_path = getURLPath();
para.event_duration = Math.min(sd.para.heatmap.scroll_event_duration, parseInt(delay_time) / 1000);
para.event_duration = para.event_duration < 0 ? 0 : para.event_duration;
sd.track('$WebStay', para);
}
this.current_time = current_time;
}
});
delayTime.current_time = new Date();
//页面滑动事件监听
addEvent$1(window, 'scroll', function() {
if (!isPageCollect) {
return false;
}
delayTime.go();
});
//页面资源卸载监听
addEvent$1(window, 'unload', function() {
if (!isPageCollect) {
return false;
}
delayTime.go('notime');
});
}
参考资料
Web JS SDK 架构解析 | 数据采集 juejin.cn/post/697426…
神策数据web sdk源码 github.com/sensorsdata…
如果有写的不对的地方,欢迎指正