前端如何优雅的设置一个无侵入式埋点

4,413 阅读5分钟

前端埋点的痛点

我大概是三个月前第一次接触埋点这个概念。前端的埋点主要就是用于捕捉用户在前端界面的交互行为,以便统计和监测产品的实际使用情况。设置埋点一般是在整个业务逻辑已经跑通的情况下后期加入的。其实这个时候向业务代码中加入埋点的代码是非常奇怪的。业务代码和埋点代码是完全不相关的两块逻辑,这个时候却需要强行组织在一起,导致了对业务代码的侵入。同步的代码可能相对来说更好处理,直接将埋点代码放到相应事件代码最前面或者最后面,进行显式的分离。

function testSync(){
  const handleClick= () => {
    /*

    。。。发送埋点

    */
    
    /*

    。。。业务代码+组件状态控制逻辑

    */
  }

  return (
    <div>
      <button onClick={handleClick}></button>
    </div>
  )
}

如果遇到异步的情况,情况会变得相当的糟糕,我们不得不在Promise返回的结果中完全与业务代码混合在一起。

function testAsync(){
  const handleClick= async () => {
    /*

    。。。组件状态控制逻辑

    */
   await service.getResult(data).then(result => {
        /*

        。。。发送埋点

        */

        /*

        。。。业务代码

        */

   })
    /*

    。。。组件状态控制逻辑

    */
  }

  return (
    <div>
      <button onClick={handleClick}></button>
    </div>
  )
}

再加上一个应用的埋点往往有很多处,这导致原本组织得体的业务代码被侵入的支离破碎。无论是从逻辑分离、代码简洁或后期维护的角度,这都是让人难以接受。

所以我尝试着去思考如何将业务代码和埋点代码进行分离。其实埋点代码的特点也非常鲜明,因为与某段业务逻辑强绑定,所以埋点代码总是与该段业务代码同时(指之前或之后)调用。又因为埋点代码是自成一块的,所以很容易抽象成一块切片。所以这很自然就让我想到利用AOP的设计思想来解决这个问题。

什么是AOP

AOP是一种面向横切面编程的思想,是对面向过程编程思想非常好的横向维度补充。常见应用于日志打印、性能监测、安全控制、异常处理等。本质上是对数据和逻辑“拦截”后进行过滤或改造,之后再重新放归到主业务流程。AOP在前端的应用似乎并不多见,但据我了解在后端有非常广泛的应用。前端接触较多的express中间件的洋葱模型,本质上也是对AOP思想的极致实践。

利用装饰器语法设置无侵入式埋点

好了,现在我们已经利用AOP思想将埋点逻辑抽象成了一块切片。AOP思想的实现层面一般是利用装饰器语法,那么与业务逻辑组织的问题也就迎刃而解了。ES的装饰器语法目前还处于提案中,但幸好我们有TS,在tsconfig.json里启用experimentalDecorators编译器选项:

 "compilerOptions": {
    "experimentalDecorators": true
 }

以下是利用装饰器语法设置埋点的写法,可以看到业务代码独立、清晰的组织在了一起,而每次执行业务逻辑的时候,埋点逻辑将会自动被调用:

function testSync(){
  const handleClick= () => {
    /*

    。。。组件状态控制逻辑

    */
    service.handleClick(data)
  }

  return (
    <div>
      <button onClick={handleClick}></button>
    </div>
  )
}


class Service{
  @sendPointByClick
  handleClick(data:TData){
  /*

  。。。业务代码

  */
  }
  
}

这一切发生的关键在于sendPointByClick这一方法装饰器,该装饰器在代码编译阶段运行时传入下列3个参数:

  1. 类的原型对象
  2. 被装饰的方法名
  3. 被装饰方法的属性描述符{value:any, writable:boolean, enumerable:boolean, configurable:boolean}

注意!装饰器对类的行为的改变,是代码编译时发生的,而不是在运行时。这意味着,装饰器能在编译阶段运行代码。也就是说,装饰器本质就是编译时执行的函数。

function sendPointByClick(
  target: ActionService, 
  propertyName: string, 
  descriptor: PropertyDescriptor
){
  const oldValue = descriptor.value
  
  descriptor.value = function(data:TData) {
    /*

    。。。发送埋点

    */
    return oldValue.apply(this, arguments);
  }
}

这里我们只需要改造属性描述符的value,它代表的是方法本身的函数定义。所以我们将方法原先的定义保存在oldValue变量等待调用,然后构建一个插入埋点逻辑的新函数重新赋值给属性描述符的value。当然,在这个改造后的新函数中我们需要执行原定义的函数(保存在oldValue)并返回结果。最终在代码运行阶段,业务逻辑代码分毫不差的被执行,而且埋点也被正确无感的发送。

异步的情况

那么异步的情况又该如何解决呢?业务代码和埋点代码都需要调用异步返回的结果。其实也很容易想到利用Promise的特性,将业务逻辑和埋点逻辑隔离在不同的then中执行,然后将异步返回结果result不断原样向后抛出即可。这样分离在不同then中注册的函数就都能调用到相同的值了:

function testAsync(){
  const handleClick= async () => {
    /*

    。。。组件状态控制逻辑

    */
    await service.getResult(data).then(result => {
          /*
          。。。业务代码

          */
    })
    /*

    。。。组件状态控制逻辑

    */
  }

  return (
    <div>
      <button onClick={handleClick}></button>
    </div>
  )
}
class Service{
  @sendPointByGetResult
  public getResult(data:TData){
    /* 。。。 */
  }
}


function sendPointByGetResult(
  target: Service, 
  propertyName: string, 
  descriptor: PropertyDescriptor
){
  const oldValue = descriptor.value
  
  descriptor.value = function(data:TData) {
    return oldValue.apply(this, arguments).then((result:TResult) => {
      /*

      。。。发送埋点

      */
      return result
    })
  }
}

前端小白处女作,文中难免有谬误,请各位大佬轻喷!