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

相關文章
相關標籤/搜索