Taro3无埋点的探索与实践

677 阅读11分钟

引言

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

背景

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

  1. 如何拦截到用户事件的触发方法

  2. 如何为节点生成一个唯一且稳定的标识符

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

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

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

问题分析

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

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

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

  1. 运行时SDK能拦截用户方法

  2. 能在生产环境将用户方法名保留下来

逐一攻破

获取用户方法

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

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

// 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具体结构如下

模仿这个过程,就能拿到用户绑定的方法了。那应该怎么模仿呢?如何才能切入这个过程中?再观察可以发现运行时的document不在是小程序内置的了,而是Taro3通过ProvidePlugin提供的(可见Taro3的taro-runtime包中README),这里基本都是将dom中各类的实现了一遍。

在看dispatchEvent这个方法,想一下如果我们切入这个方法,那岂不是就能复制以上的过程来获取到__handlers了,同时也实现了事件的拦截。根据document的继承关系,通过原型链就能实现,如下:

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
  })
}

保留方法名

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

  1. 方法分类
  • 具名方法

    function signName() {}

  • 匿名方法

    const anonymousFunction = function () {}

  • 箭头函数

    const arrowsFunction = () => {}

  • 内联箭头函数

    <View onClick={() => {}}>

  • 类方法

    class Index extends Component { hasName() {} }

  • class fields语法方法

    class Index extends Component { arrowFunction = () => {} }

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

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

2. 留下方法名

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