引言
对于Taro框架,置信大多数小程序开发者都是有肯定理解的。借助Taro框架,开发者们能够应用React进行小程序的开发,并实现一套代码就可能适配到各端小程序。这种促使开发成本升高的能力使得Taro被各大小程序开发者所应用。应用Taro打包进去的小程序和原生相比是有肯定区别的,GrowingIO小程序的原生SDK还不足以间接在Taro中应用,须要针对其框架的特地进行适配。这点在Taro2期间曾经是实现完满适配的,但在Taro3之后,因为Taro团队对其整体架构的调整,使得之前的形式曾经无奈实现精确的无埋点,促使了本次摸索。
背景
GrowingIO小程序SDK无埋点性能的实现有两个外围问题:
- 如何拦挡到用户事件的触发办法
- 如何为节点生成一个惟一且稳固的标识符
只有能解决好这两个问题,那就能实现一个稳固小程序无埋点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上的实现,如果也能在拦挡到事件触发的时候获取到用户办法名,那就能够了。也就是说只有能把以下两个问题解决掉,便能实现这个指标了。
- 运行时SDK能拦挡用户办法
- 能在生产环境将用户办法名保留下来
逐个攻破
获取用户办法
先看第一个问题,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