乐趣区

关于taro:Taro3无埋点的探索与实践

引言

对于 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 源码中的 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

退出移动版