pendo, ga脚本的部分原理和this

这篇文章有2个话题——数据收集技术和this. 数据收集技术主要是解析pendo/ga脚本的部分原理;this,就是javascript中的this, 我感觉每年都会从javascript weekly等newsletter中看到几篇文章讲解this, 而且它们都称作是 the last article about javascript this.

Hi,

这篇文章有2个话题——数据收集技术和this. 数据收集技术主要是解析pendo/ga脚本的部分原理;this,就是javascript中的this, 我感觉每年都会从javascript weekly等newsletter中看到几篇文章讲解this, 而且它们都称作是 the last article about javascript this.

pendo/ga脚本的部分原理

先看ga吧,这个比较简单

GA

(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
  (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
  m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
  })(window,document,'script','https://www.google-analytics.com/analytics.js','ga');

ga('create', 'UA-XXXXX-Y', 'auto');
ga('send', 'pageview');

//---------------------------------------------------

function installGA() {
  window['GoogleAnalyticsObject'] = 'ga';               //----- line 1
  window['ga'] = window['ga'] || function () {			//
    window['ga'].q = window['ga'].q || [];				//
    window['ga'].q.push(arguments);						//
  };													//
  window['ga'].l = 1 * new Date();						//----- line 6

  a = document.createElement('script');					//----- line 7
  a.async = 1;
  a.src = 'https://www.google-analytics.com/analytics.js'
  m = document.getElementsByTagName('script')[0];
  m.parentNode.insertBefore(a, m);						//----- line 11

  ga('create', 'UA-XXXXX-Y', 'auto');
  ga('send', 'pageview');
};

上面一部分是ga官方提供的脚本,下面是我改写的,为了方便理解。说ga简单是因为这段脚本只做了2件事:

  • 构造一个功能非常基础的ga对象 (line 1-6)
  • 加载完整版的ga功能 (line 7-11)

非常基础的ga功能仅仅能做一件事:把参数存起来。例如在初始化ga的时候,ga('create', 'UA-xxx', 'auto');仅仅是把3个参数('create', 'UA-xxx', 'auto')放在一个叫q的数组中,当然放进去的是一个Argument对象,类似一个array, 所以在调用结束后,q中大约是这样:

[
Argument['create', 'UA-xxx', 'auto'],
]

类似的,第二次调用ga也会把参数放在q中,那么接下来呢?接下来当ga完整版功能加载到浏览器中后,会处理q中的数据,所以这里的q就是一个queue, 处理完后会被清除。

Pendo

下面可以看一下复杂一点的pendo脚本

(function (apiKey) {
    (function(p,e,n,d,o){var v,w,x,y,z;o=p[d]=p[d]||{};o._q=[];
    v=['initialize','identify','updateOptions','pageLoad'];for(w=0,x=v.length;w<x;++w)(function(m){
    o[m]=o[m]||function(){o._q[m===v[0]?'unshift':'push']([m].concat([].slice.call(arguments,0)));};})(v[w]);
    y=e.createElement(n);y.async=!0;y.src='https://cdn.pendo.io/agent/static/'+apiKey+'/pendo.js';
    z=e.getElementsByTagName(n)[0];z.parentNode.insertBefore(y,z);})(window,document,'script','pendo');
})('<ACTUAL_API_KEY_HERE>');

// o._q[m===v[0]?'unshift':'push']是什么意思?
// 其实就是在选择函数
// []["length"]的作用和[].length一样
// 这里的意思是如果你调用initialize方法,就用[].unshift,否则用[].push
//--------------------------------------------------------

function installPendo() {
    window['pendo'] = window['pendo'] || {};
    o = window['pendo'];
    o._q = [];
    
    // 情怀如下
    ['initialize','identify','updateOptions','pageLoad'].forEach(methodName => {
        window['pendo'][methodName] = window['pendo'][methodName] || function () {
            if (methodName === 'initialize') {
                o._q.unshift(['initialize'].concat([].slice.call(arguments, 0)));
            } else {
                o._q.push([methodName].concat([].slice.call(arguments, 0)));
            }
        };
    })

    y = document.createElement('script');
    y.async = 1;
    y.src = 'https://cdn.pendo.io/agent/static/YOURAPIKEY/pendo.js';
    z = document.getElementsByTagName('script');
    z.parentNode.insertBefore(y, z);

    pendo.initialize({
        visitor: {
            id: 1,
            other_prop: 2,
            another_prop: 3
        },
        account: {

        }
    })
}

这个脚本比较有情怀,从参数的名字上可以看出:function(p,e,n,d,o),其实o是凑数的。

因为有GA脚本的铺垫,pendo脚本的原理很容易看出,也是做了2件事:

  • 构造基础版pendo
  • 加载完整版

第二点情怀体现在构造了一个看起来比较真实的基础版pendo:添加了几个方法,initialize, identify, updateOptions, pageLoad, ...这几个方法除了initialize比较特殊外,其他的方法和GA类似,把参数先收着。

pendo在处理参数的时候也比较情怀(nan dong),大约用了这样的方法处理arguments:o._q.push([methodName].concat([].slice.call(arguments, 0))),在第二部分讲this的时候会提到它。

当调用pendo.initialize或者pendo.pageLoad的时候,queue的样子大约是:

[
    ['initialize', {visitor:{id:1, xxx: 2}, account:{id: 3}}],
    ['pageLoad', {...}]
]

initialize特殊的地方在于,不管什么时候调用,都会被放在queue最前面,这是由于initialize用了unshift方法而不是push,具体可以参考js array unshift方法。

等pendo完整版加载后,queue会被处理。

最后猜测一下GA为啥不做一个情怀版的,可能是少一个字符能省下很多流量费,能精简就精简吧。


第一部分结束,通过阅读pendo/ga脚本学到了这种设计技巧:

  • 可以通过精简版的功能接着请求,等完整版加载完后再处理
  • 容易扩展功能,比如pendu希望提供trackEvent功能只需要在method list中再增加一个就行,GA更简单,只需要调用的时候第一个参数写成trackEvent

js中的this

本来没有这部分的计划,是因为pendo脚本中的这段代码引出

[].slice.call(arguments, 0)

这到底在干嘛?把argument处理成array,前面说过argument是一个array-like的对象。array-like意思是拥有length属性,并且可以从0开始访问数据,但没有array该有的那些方法。

Arguments不是很常用,简单来说,它是在function中可以访问到的一个特殊对象,代表了这个function的参数,例如

function f(a, b, c) {console.log(a, b, c, arguments);}
f(1,2,3)
// 1 2 3 Arguments(3) [1, 2, 3, callee: ƒ, Symbol(Symbol.iterator): ƒ]

GA选择了保存整个argument对象,而pendo选择了把它变成array存起来,变成array的办法就是上面的那段代码。

slice是array的一个方法,可以把array分割,返回一个新array。

为什么要用call而不是直接arguments.slice()?因为arguments是array-like, 没有slice方法。。。

那call做了什么?call把slice的this改成了arguments,并且把后面的0作为参数传给了slice,用slice处理arguments应该算是一种技巧,能使用这种技巧是有些前提条件的——slice正好只依赖this指向的那个对象有length。更好的做法自然是让arguments对象支持toArray之类的操作,当然,ES2015带来了Array.fromspread syntax,同样可以很简单地转成array,但浏览器兼容性显然没有slice那么好。

终于出现了this

当我们在global中,或者说浏览器的窗口中定义一个这样的函数并执行,你觉得this是什么?

function f() {console.log(this);}
f()

是Window,不信你可以自己试。

那如果用nodejs运行上面的代码,你觉得this是什么?

是一个叫Global的东西,大约这样,非常像window:

<ref *1> Object [global] {
  global: [Circular *1],
  clearInterval: [Function: clearInterval],
  clearTimeout: [Function: clearTimeout],
  setInterval: [Function: setInterval],
  setTimeout: [Function: setTimeout] {
    [Symbol(nodejs.util.promisify.custom)]: [Getter]
  },
  queueMicrotask: [Function: queueMicrotask],
  clearImmediate: [Function: clearImmediate],
  setImmediate: [Function: setImmediate] {
    [Symbol(nodejs.util.promisify.custom)]: [Getter]
  }
}

这里我想说的是,当你在全局空间中执行函数f的时候,实际上是这样执行的:

  • 在浏览器中:window.f()
  • 在nodejs中:global.f()

函数f中的this会被设置成window或者global,那么,当运行abc.func()的时候,func中的this是什么?当然是abc

下面有两个特别的情况:

  • new操作
  • call/apply
function Product(category){this.category = category;}
Product('tech') -> window.category is 'tech'
p = new Product('tech') -> p.category is 'tech'

如果把Product当作一个函数执行,那么在浏览器中,就会在window对象上添加category属性。

如果用new来操作,会得到一个对象,并且对象会有一个category属性。从这里可以看到,new操作同样做了修改this指向的事情:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/new

call/apply类似,会修改被调用函数的this指向。

再次看一下这一行代码

[].slice.call(arguments, 0)

调用array上的slice方法时,例如

[1,2,3,4].slice(2, 3)
// -> [3]

首先要明确一点,slice方法中如果用了this,那么this指的是什么?当然是[1,2,3,4]

所以用call的作用就是把slice中的this指向修改掉,修改成arguments, 就相当于在执行arguments.slice(0)一样,如果没想明白可以慢慢体会一下?

剩下的this

arrow function

const outerThis = this;
const arrowFunc = () => {
    console.log(this === outerThis);
}
arrowFunc();
// -> true

箭头函数,函数里面的this,总是和函数外的this一致,这和普通函数不一样。

另外一个不一样,arrow function不能当作constuctor, 也就是不能new

new arrowFunc()
// -> Uncaught TypeError: arrowFunc is not a constructor

可以修改this的函数

part2中提到了call和apply,其实还有bind.

bind返回一个新函数,而且把函数中的this改成了你传给它的那个,例如:

// Global is Window
function func() {console.log(this);}
func() // -> Window {window: Window, self: Window, document: document,...}
func.bind({name: 'new this'})(); // -> {name: 'new this'} instead of Window

然而这些可以修改this的函数,对于arrow function来说,不起作用

还有很多和this相关的东西

可以参考这里:https://web.dev/javascript-this/, 以及那些last articals about js this