写点什么

Taro3 无埋点的探索与实践

发布于: 2021 年 06 月 17 日
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 源码中的 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 })}
复制代码

保留方法名

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

方法分类

具名方法

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



发布于: 2021 年 06 月 17 日阅读数: 329
用户头像

GrowingIO 技术团队经验分享 2020.05.09 加入

GrowingIO(官网网站www.growingio.com)的官方技术专栏,内容涵盖微服务架构,前端技术,数据可视化,DevOps,大数据方面的经验分享。 公众号:GrowingIO技术团队

评论

发布
暂无评论
Taro3无埋点的探索与实践