关于神策数据web sdk源码的分析

1,651 阅读4分钟

介绍

神策分析 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的三个自定义生命周期:

  1. 初始化state为0,表示sdk还未做任何做操作
  2. 设置state为1,表示sdk还未做初始化的动作
  3. 设置state为2,表示sdk正在进行初始化的动作
  4. 设置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执行的操作:

  1. 通知sdk触发beforeInit事件,判断如果readyState>=2时返回false退出,因为readyState为2或3时已经正在做init操作或者做完init操作了
  2. is_compliance_enabled由window['sensors_data_pre_config']['is_compliance_enabled']决定是否给sd赋值
  3. 设置readyState为2之前会设置spaSwitch监听事件和初始化版本号等的动作
  4. 设置readyState为2之后初始化sd.para的配置参数,完成以后通知sdk触发afterInitPara事件
  5. detectMode会设置点击图、查询兼容、初始化store等工作,iOSWebClickPolyfill为ios的h5设备设备添加点击兼容事件处理
  6. 最后通知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执行的操作如下:

  1. 将readyState设置为3之后,判断sdk的点击图是否可视化,defineMode如果返回true则会提示用户debug文案
  2. bridge.app_js_bridge_v1会判读安卓和ios端是否打通上传渠道,选择将数据上报到移动端sdk
  3. pageInfo.initPage会做初始化url和domian的判断,listenSinglePage开启切换页面时触发spaSwitch事件
  4. 判断batch_send属性是否为true开启监控数据的批量上报
  5. 初始化sd.store的属性存储
  6. sd.vtrackBase添加页面监听实时逐条发送数据
  7. 在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…

如果有写的不对的地方,欢迎指正