Taro3无埋点的探索与实践

引言

对于Taro框架,相信大多数小程序开发者都是有必定了解的。借助Taro框架,开发者们可使用React进行小程序的开发,并实现一套代码就可以适配到各端小程序。这种促使开发成本下降的能力使得Taro被各大小程序开发者所使用。使用Taro打包出来的小程序和原生相比是有必定区别的,GrowingIO小程序的原生SDK还不足以直接在Taro中使用,须要针对其框架的特别进行适配。这点在Taro2时期已是实现完美适配的,但在Taro3以后,因为Taro团队对其总体架构的调整,使得以前的方式已经没法实现准确的无埋点,促使了本次探索。node

背景

GrowingIO小程序SDK无埋点功能的实现有两个核心问题:react

  1. 如何拦截到用户事件的触发方法
  2. 如何为节点生成一个惟一且稳定的标识符

只要能处理好这两个问题,那就能实现一个稳定小程序无埋点SDK。在Taro2中,框架在编译期和运行期有不一样的工做内容。其中编译时主要是将 Taro 代码经过 Babel 转换成小程序的代码,如:JS、WXML、WXSS、JSON。在运行时Taro2提供了两个核心ApicreateApp,createComponent,分别用来建立小程序App和实现小程序页面的构建。git

GrowingIO 小程序SDK经过重写createComponent方法实现了对页面中用户事件的拦截,拦截到方法后便能在事件触发的时候获取到触发节点信息和方法名,若节点存在id,则用id+方法名做为标识符,不然就直接使用方法名做为标识符。这里方法名获取上sdk并无任何处理,由于在Taro2的编译期已经作好了这一系列的工做,它会将用户方法名完整的保留下来,而且对于匿名方法,箭头函数也会进行编号赋予合适的方法名。github

可是在Taro3以后,Taro的整个核心发生了巨大的变化,不管是编译期仍是运行期和以前都是不同的。createApp和createComponent接口也再也不提供,编译期也会对用户方法进行压缩,不在保留用户方法名也不会对匿名方法进行编号。这样就致使现有GrowingIO 小程序SDK没法在Taro3上实现无埋点能力。express

问题分析

在面对Taro3的这种变化,GrowingIO以前也作过适配。在分析Taro3运行期的代码中发现,Taro3会为页面内全部节点分配一个相对稳定的id,而且节点上的全部事件监听方法都是页面实例中的eh方法。在此条件下以前的GrowingIO即是按照原生小程序SDK的处理方式拦截该eh方法,在用户事件触发的时候获取到节点上的id以生成惟一标识符。这种处理方式在必定程度上也是解决了无埋点SDK的两个核心问题。小程序

不难想到,GrowingIO以前的处理方式上,是没办法作到获取一个稳定的节点标识符的。当页面中节点的顺序发生变化,或者动态的增删了部分节点,这时Taro3都会给节点分配一个新的id,这样的话那就没法提供一个稳定的标识符了,致使以前圈选定义的无埋点事件失效。babel

若是想处理掉已定义无埋点事件失效问题,那就必须能提供一个稳定的标识符。类比与在Taro2上的实现,若是也能在拦截到事件触发的时候获取到用户方法名,那就能够了。也就是说只要能把如下两个问题处理掉,便能实现这个目标了。架构

  1. 运行时SDK能拦截用户方法
  2. 能在生产环境将用户方法名保留下来

逐一攻破

获取用户方法

先看第一个问题,SDK如何获取到用户绑定的方法,并拦截它。分析下Taro3的源码,不难就能解决掉。app

全部的页面配置都是经过createPageConfig方法返回的,每一个page配置都会有一个eh,从这里下手便能获取到绑定的方法。可见taro-runtime源码中的 eventHandler,dispatchEvent方法。框架

// page配置中的eh即为该方法
export function eventHandler (event: MpEvent) {
  if (event.currentTarget == null) {
    event.currentTarget = event.target
  }
  // 运行时的document是Taro3.0定义的,能够获取虚拟dom中的节点
  const node = document.getElementById(event.currentTarget.id)
  if (node != null) {
    // 触发事件
    node.dispatchEvent(createEvent(event, node))
  }
}

// 在看看dispatchEvent方法,简化后
class TaroElement extends TaroNode {
  ...
  public dispatchEvent (event: TaroEvent) {
    const cancelable = event.cancelable
    // 这个__handlers属性是关键,这里保存着该节点上全部监听方法
    const listeners = this.__handlers[event.type]
    
    // ...省略不少
    return listeners != null
  }
  ...
}

__handlers具体结构以下:

function hookDispatchEvent(dispatch) {
  return function() {
    const event = arguments[0]
    let node = document.getElementById(event.currentTarget.id)
    // 这就把触发元素上的绑定的方法拿到了
    let handlers = node.__handlers
    ...
    return dispatch.apply(this, arguments)
  }
}

// 判断是否是在Taro3环境中
if (document?.tagName === '#DOCUMENT' && !!document.getElementById) {
  const TaroNode = document.__proto__.__proto__
  const dispatchEvent = TaroNode.dispatchEvent
  Object.defineProperty(TaroNode, 'dispatchEvent', {
    value: hookDispatchEvent(dispatchEvent),
    enumerable: false,
    configurable: false
  })
}

保留方法名

先来看看现状吧,在上面的步骤中已经能够拿到用户方法了,用户方法主要分为如下几类:

方法分类

  • 具名方法
function signName() {}
  • 匿名方法
const anonymousFunction = function () {}
  • 箭头函数
const arrowsFunction = () => {}
  • 内联箭头函数
<View onClick={() => {}}></View>
  • 类方法
class Index extends Component {
  hasName() {}
}
  • class fields语法方法
class Index extends Component {
  arrowFunction = () => {}
}

对于具名方法和类方法都是能够经过Function.name来获取到方法名的,可是其余几种就无法直接获取到了。那如何才能获取这些方法的名字呢?

按照当前可操做的内容,想要在运行期拿到这些方法的方法名那已是不可能实现的事情了。由于Taro3在生成环境中会进行压缩,并且对于匿名方法也不会像Taro2那样为其进行编号。那既然运行期作不到,就只能把目光聚焦到编译期来处理了。

留下方法名

Taro3在编译期仍是要借助Babel来处理的,那若是实现一个Babel插件来把这些匿名方法赋予一个合适的方法名那不就能把这个问题处理掉了吗。插件开发指南能够参考handbook,能够经过AST explorer直观的看到这棵树的结构。了解了babel插件的基本开发,下面就是要选择一个合适的时机去访问这棵树。

在最初考虑是把访问点设置为Function,这样不论什么类型的方法,都是能够拦截到,而后再根据必定规则将方法名保留下来。这个思路是没有问题的,而且尝试实现后也是可使用的,但它会有如下两点问题:

  • 范围太大,把非事件监听的方法也给转化了,这是没必要要的
  • 面对代码压缩依旧是无能为力,只能经过配置保留函数名的压缩方式来处理,对最终包体积形成必定影响

让咱们在分析下JSX语法吧,想一下全部的用户方法都是要经过onXXX的形式为元素绑定监听,以下

<Button onClick={handler}></Button>

下图为其AST结构,由此能够想到把访问点设置为JSXAttribute,并只需对其value值的方法赋予合适的名字就好了。JSX相关的类型可见jsx/AST.md · GitHub

插件的总体框架能够以下

function visitorComponent(path, state) {
  path.traverse({
    // 访问元素的属性
    JSXAttribute(path) {
      let attrName = path.get('name').node.name
      let valueExpression = path.get('value.expression')
      if (!/^on[A-Z][a-zA-Z]+/.test(attrName)) return
      
      // 在这里为用户方法设置名字便可
      replaceWithCallStatement(valueExpression)
    }
  })
}

module.exports = function ({ template }) {
  return {
    name: 'babel-plugin-setname',
    // React的组件能够Class和Function
    // 在组件内部在进行JSXAttribute的访问
    visitor: {
      Function: visitorComponent,
      Class: visitorComponent
    }
  }
}

只要插件处理好JSXAttribute中value表达式,能为各类类型的用户方法设置合适的方法名,就能完成保留方法名的这一任务了。

Babel插件功能实现

插件主要实现如下几部分功能

  • 访问JSXAttribute中用户方法
  • 获取合适的方法名
  • 注入设置方法名的代码

最终效果以下

_GIO_DI_NAME_经过Object.defineProperty为函数设置了方法名。插件提供了默认实现,也能够自定义。

Object.defineProperty(func, 'name', {
  value: name,
  writable: false,
  configurable: false
})

你可能会发现转化后的代码中handleClick已是具名的了,再set下不就画蛇添足吗。可是可别忘了生产环境的代码仍是要压缩的,这样函数名可就不知道会是啥了。

下面分别介绍针对不一样的事件绑定方式的处理,基本涵盖的React中的各类写法。

标识符

标识符是指在jsx属性上使用的标识符,函数具体如何声明不限。

<Button onClick={varIdentifier}></Button>

AST结构以下

这时方法名直接取标识符的name值便可。

成员表达式

  • 普通成员表达式
    如如下成员表达式内的方法
<Button onClick={parent.props.arrowsFunction}></Button>

会被转化为以下形式

_reactJsxRuntime.jsx(Button, {
  onClick: _GIO_DI_NAME_("parent_props_arrowsFunction", parent.props.arrowsFunction)
})

成员表达式的AST结构大体是这样的,插件会取全部成员标识符,并以_链接做为方法名。

  • this成员表达式
    this表达式会进行特殊处理,将不会保留this取其他部分,以下
<Button onClick={this.arrowsFunction}></Button>

会被转换为

_reactJsxRuntime.jsx(Button, {
  onClick: _GIO_DI_NAME_("arrowsFunction", this.arrowsFunction)
})

函数执行表达式

执行表达式就是函数的调用,形如

<Button onClick={this.handlerClick.bind(this)}></Button>

这里的bind()就是一个CallExpression,插件处理后会有如下结果

_reactJsxRuntime.jsx("button", {
  onClick: _GIO_DI_NAME_("handlerClick", this.handlerClick.bind(this))
})

执行表达式多是比较复杂的,好比一个页面中几个监听函数是同一个高阶函数使用不一样参数生成的,这时是须要保留参数信息的。以下

<Button onClick={getHandler('tab1')}></Button>
<Button onClick={getHandler(h1)}></Button>
<Button onClick={getHandler(['test'])}></Button>

须要被转化为如下形式

// getHandler('tab1')
_reactJsxRuntime.jsx(Button, {
  onClick: _GIO_DI_NAME_("getHandler$tab1", getHandler('tab1')),
  children: ""
})
// getHandler(h1)
_reactJsxRuntime.jsx(Button, {
  onClick: _GIO_DI_NAME_("getHandler$h1", getHandler(h1)),
  children: ""
})
// getHandler(['test'])
_reactJsxRuntime.jsx(Button, {
  onClick: _GIO_DI_NAME_("getHandler$$$1", getHandler(['test'])),
  children: ""
})

针对不一样的参数类型会有不一样的处理方式,总体思路就是把高阶函数名和参数进行拼接组成方法名。

一个CallExpression的AST结构以下

根据AST结构,对不一样参数处理逻辑代码可见插件源码:transform.js [60-73]
上面说的都只是直接的函数执行表达式,再考虑如下状况

<Button onClick={factory.buildHandler('tab2')}></Button>

观察下这里的AST结构,callee部分将是一个成员表达式,这里的取值将按照上面的成员表达式来

转换后结果以下

_reactJsxRuntime.jsx(Button, {
  onClick: _GIO_DI_NAME_("factory_buildHandler$tab2", factory.buildHandler('tab2')),
  children: ""
})

函数表达式

函数处理起来就有点小麻烦了,先看下有几种形式

<Button onClick={function(){}}/>
<Button onClick={function name(){}}/>
// 上面两种估计没人会写,下面将是最多见的
<Button onClick={() => this.doOnClick()}/>

先看下以上代码转换后的输出吧

_reactJsxRuntime.jsx(Button, {
  onClick: _GIO_DI_NAME_("HomeFunc0", function () {})
})
_reactJsxRuntime.jsx(Button, {
  onClick: _GIO_DI_NAME_("name", function name() {})
})
_reactJsxRuntime.jsx(Button, {
  onClick: _GIO_DI_NAME_("HomeFunc1", function () {
    return _this2.doOnClick();
  })
})

可见这里对于具名函数将会直接取函数名,对于匿名函数会用固定的前缀来进行编号处理。这里的编号取值只要控制好,那也就能得到比较稳定的方法名了。

匿名函数编号

以前状况下的方法名都是在依据一些用户的标识符来得到的,但在匿名函数中是没有直接的标识的,只能根据必定规则生成方法名。这里的规则以下:

  • 已单个组件做为界限进行递增编号
  • 方法名由组件名,关键字和递增编号组成,形如HomeFunc0
    函数编号就直接在访问组件时生成一个该组件下递增id的方法便可,以下
function getIncrementId(prefix = '_') {
  let i = 0
  return function () {
    return prefix + i++
  }
}
// 调用
getIncrementId(compName + 'Func')

这里只要再把组件名的获取处理掉就没问题了。如下是几种常见的声明组件方式的AST结构:

根据以上AST结构,能够经过如下方式获取组件名:

function getComponentName(componentPath) {
  let name
  let id = componentPath.node.id
  if (id) {
    name = id.name
  } else {
    name =
      componentPath.parent &&
      componentPath.parent.id &&
      componentPath.parent.id.name
  }
  return name || COMPONENT_FLAG; // 其余获取不到组件名的,将使用Component代替
}

至此便能为匿名函数分配一个比较稳定的方法名了。

结语

在Taro3无埋点功能的实现上,GrowingIO小程序SDK从运行期和编译期同时下手,在运行期实现事件拦截,在编译期实现用户方法名的保留,以此实现较稳定的无埋点功能。具体的使用方式可见:Taro3中集成GrowingIO小程序SDK。经过此次Taro3无埋点的支持,GrowingIO小程序无埋点实现也从仅运行期的操做扩展到了编译期,这也是一种新的方式,将来也可能会在这个方向上继续优化,提供更稳定的无埋点功能。相关Babel插件以开源,仓库可见:growingio/growing-babel-plugin-setname