The introduction

As for the Taro framework, I believe that most small program developers have a certain understanding. With the Taro framework, developers can use React to develop applets and implement a set of code that can be adapted to each side of the application. This ability to drive down development costs has led Taro to be used by developers of all sizes. The native SDK of GrowingIO is not enough to be used directly in Taro. It needs to be specially adapted for its framework. This has been perfectly adapted in Taro2, but since Taro3, the Taro team has adjusted its overall architecture, making it impossible to achieve accurate no-burial points in previous methods, which prompted this exploration.

background

The implementation of GrowingIO applets SDK has two core problems:

  1. How do I intercept the triggering methods of user events

  2. How do I generate a unique and stable identifier for a node

As long as you can handle these two issues, you can implement a stable applets without burying SDK. In Taro2, the framework has different things to do at compile time and run time. The compile time is mainly to convert Taro code through Babel into small program code, such as: JS, WXML, WXSS, JSON. At runtime Taro2 provides two core apicreateApps, createComponent, which are used to create applets and build applets.

GrowingIO small program SDK realizes the interception of user events in the page by rewriting the createComponent method. After intercepting the method, the triggering node information and method name can be obtained when the event is triggered. If the node has id, id+ method name is used as the identifier; otherwise, method name is directly used as the identifier. The SDK doesn’t have anything to do with method names here, because Taro2 has done all this work at compile time, it keeps the user method names intact, and for anonymous methods, arrow functions are numbered to give proper method names.

However, after Taro3, Taro’s entire core has changed dramatically, both compile time and runtime are different from before. CreateApp and createComponent interfaces are no longer available, user methods are compressed at compile time, user method names are no longer reserved and anonymous methods are not numbered. As a result, the existing GrowingIO applets SDK cannot implement no-buried capability on Taro3.

Problem analysis

GrowingIO has adapted to this change in Taro3 before. In the analysis of Taro3 running code, it is found that Taro3 will assign a relatively stable ID to all nodes in the page, and all the event listening methods on the node are eh methods in the page instance. Under this condition, the previous GrowingIO intercepts the EH method in accordance with the processing method of the native applet SDK, and obtains the ID of the node to generate a unique identifier when the user event is triggered. This approach partly solves two of the core problems of the no-buried SDK.

It is not hard to imagine that GrowingIO before the processing, there is no way to achieve a stable node identifier. When the order of nodes in the page changes, or when some nodes are added or deleted dynamically, Taro3 assigns a new ID to the node. In this case, the node cannot provide a stable identifier, resulting in the failure of the unburied event defined by the circle.

If you want to deal with the failure of defined unburied events, you must be able to provide a stable identifier. Similar to the implementation on Taro2, it would be fine if the user method name could also be retrieved when the event was intercepted. That is to say, as long as we can deal with the following two problems, we can achieve this goal.

  1. The runtime SDK can intercept user methods

  2. The ability to keep user method names in production

One by one break

Method of obtaining users

Let’s start with the first question, how does the SDK pick up the user-bound method and intercept it. Analyze the Taro3 source code, it is not difficult to solve.

All page configurations are returned using the createPageConfig method, and each page configuration has an EH from which to obtain the binding method. Taro – Runtime source code eventHandler, dispatchEvent methods.

Export function eventHandler (event: MpEvent) {if (event.currenttarget == null) {event.currenttarget = event.target} Const node = document.getelementById (event.currenttarget.id) if (node! = null) {node.dispatchEvent(createEvent(event, node))}} Class TaroElement extends TaroNode {... public dispatchEvent (event: TaroEvent) {const cancelable = event. Cancelable // This __handlers attribute is key, Handlers [event. Type] // The listeners on this node are const listeners = this.__handlers[event. Many return notices are omitted! = null } ... }Copy the code

The __handlers structure is as follows

Mimic this process to get the user binding method. So how do you do that? How do you get into this process? It can be found that the runtime document is not built into the small program, but provided by Taro3 via ProvidePlugin (see Taro3 taro-Runtime package README), where the basic is to achieve all kinds of dom again.

If we look at the dispatchEvent method, think if we go to this method, wouldn’t we be able to copy the process above to get the __handlers, and also to intercept the events. According to the inheritance relationship of Document, it can be achieved through the prototype chain, as follows:

function hookDispatchEvent(dispatch) { return function() { const event = arguments[0] let node = Document.getelementbyid (event.currenttarget.id) // This puts the methods that trigger bindings on elements in let handlers = node.__handlers... Return dispatch.apply(this, arguments)}} // 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 }) }Copy the code

Reserved method name

Let’s take a look at the current situation, in the above steps can already get user methods, user methods are mainly divided into the following categories:

  1. Methods classification
  • A named method

    function signName() {}

  • Anonymous methods

    const anonymousFunction = function () {}

  • Arrow function

    const arrowsFunction = () => {}

  • Inline arrow function

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

  • Class method

    class Index extends Component { hasName() {} }

  • Class fields syntax method

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

Function. Name is available for both named and class methods, but not for the others. So how do you get the names of these methods?

Given what is currently available, it is impossible to get the method names of these methods at runtime. Because Taro3 is compressed in the build environment, anonymous methods are not numbered like Taro2. Since we can’t do this at runtime, we can only focus on compile time.

2. Leave the method name

Taro3 is still handled by Babel at compile time, so implementing a Babel plugin to give anonymous methods an appropriate name would eliminate this problem. The plugin development guide can be referred to handbook, and the structure of this tree can be visually seen through the AST Explorer. Knowing the basic development of the Babel plug-in, it’s time to choose the right time to access the tree.

The initial consideration was to set the access point to Function, so that methods of any type could be intercepted, and then retain the method name according to certain rules. There is no problem with this idea, and it can be used after trying to implement it, but it has the following two problems:

  • The scope is too large, and the non-event listening method is also transformed, which is unnecessary

  • In the face of code compression is still helpless, can only be configured to preserve the function name of the compression way to deal with the final package volume has a certain impact

Let’s examine the JSX syntax and consider that all user methods listen for element bindings in the form of onXXX, as follows

<Button onClick={handler}></Button>
Copy the code

The AST structure is shown below, so you can think of setting the access point to JSXAttribute and simply giving the method of its value the appropriate name. JSX/ast.md · GitHub

The overall framework of the plug-in can be as follows

function visitorComponent(path, State) {path.traverse({// access element attributes 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 components can be Class and Function // inside the component JSXAttribute visitor: {Function: visitorComponent, Class: visitorComponent } } }Copy the code

As long as the plug-in handles the value expression in JSXAttribute and can set appropriate method names for the various types of user methods, it can complete the task of preserving method names.

Babel plug-in function implementation

The plug-in mainly implements the following functions

  • Access user methods in JSXAttribute

  • Gets the appropriate method name

  • Inject code that sets the method name

The end result is as follows

_GIO_DI_NAME_ sets the method name for the function via object.defineProperty. Plug-ins provide a default implementation and can be customized.

Object.defineProperty(func, 'name', {
  value: name,
  writable: false,
  configurable: false
})
Copy the code

You may find that handleClick is already named in the converted code, so setting handleClick is unnecessary. But don’t forget that the production code is compressed, so you don’t know what the function name is.

The following describes how to deal with different event binding methods and how to write React.

identifier

Identifiers are the identifiers used on JSX attributes, and there is no limit to how the function is declared.

<Button onClick={varIdentifier}></Button>
Copy the code

AST structure is as follows

In this case, the method name simply takes the name value of the identifier.

Member expression

  • Ordinary member expression

As in the following methods in member expressions

<Button onClick={parent.props.arrowsFunction}></Button>
Copy the code

Will be converted to the following form

_reactJsxRuntime.jsx(Button, {
  onClick: _GIO_DI_NAME_("parent_props_arrowsFunction", parent.props.arrowsFunction)
})
Copy the code

The AST structure of a member expression looks something like this, where the plug-in takes all member identifiers and uses _ joins as method names.

  • This member expression

The this expression is treated specially, leaving the rest of this unreserved, as follows

<Button onClick={this.arrowsFunction}></Button>
Copy the code

Will be converted to

_reactJsxRuntime.jsx(Button, {
  onClick: _GIO_DI_NAME_("arrowsFunction", this.arrowsFunction)
})
Copy the code

Function execution expression

An execution expression is a function call of the form

<Button onClick={this.handlerClick.bind(this)}></Button>
Copy the code

The bind() term here is a CallExpression, which the plug-in handles with the following results

_reactJsxRuntime.jsx("button", {
  onClick: _GIO_DI_NAME_("handlerClick", this.handlerClick.bind(this))
})
Copy the code

Executing expressions can be complex. For example, if several listener functions on a page are generated by the same higher-order function with different parameters, parameter information needs to be preserved. The following

<Button onClick={getHandler('tab1')}></Button>
<Button onClick={getHandler(h1)}></Button>
<Button onClick={getHandler(['test'])}></Button>
Copy the code

Needs to be converted to the following form

// 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: ""
})
Copy the code

There are different processing methods for different parameter types. The whole idea is to splice the higher-order function name and parameter to form the method name.

The AST structure of a CallExpression is as follows

Transform.js [60-73] transform.js [60-73]

All this is just a direct function execution expression. Consider the following

<Button onClick={factory.buildHandler('tab2')}></Button>
Copy the code

If you look at the AST structure here, the callee part will be a member expression, and the value will be the same as the member expression above

The result is as follows

_reactJsxRuntime.jsx(Button, {
  onClick: _GIO_DI_NAME_("factory_buildHandler$tab2", factory.buildHandler('tab2')),
  children: ""
})
Copy the code

Functional expression

Functions are a little bit more complicated to deal with, but let’s see how many forms there are

<Button onClick={function(){}}/> <Button onClick={function name(){}}/> <Button onClick={() => this.doonclick ()}/>Copy the code

Take a look at the output of the above code conversion

_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(); })})Copy the code

As you can see, named functions are numbered by their names, while anonymous functions are numbered by fixed prefixes. As long as you control the number here, you’ll get a more stable method name.

Anonymous function number

In previous cases, method names are obtained according to some user identifiers, but in anonymous functions, there is no direct identification, only according to certain rules to generate method names. Here are the rules:

  • Individual components have been incremented as bounds

  • The method name consists of the component name, keyword, and increment number in the form HomeFunc0

The function number simply generates a method that increments the id of the component when accessing it, as shown below

Function getIncrementId(prefix = '_') {let I = 0 return function () {return prefix + I ++}} // call getIncrementId(compName + 'Func')Copy the code

Here we just need to get rid of the component name. Here are some common AST constructs for declaring components:

Based on the AST structure, you can obtain component names in the following ways:

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; // If the Component name is not available, use Component instead.Copy the code

This allows anonymous functions to be assigned a more stable method name.

conclusion

In the implementation of Taro3 no-buried function, GrowingIO small program SDK starts from the runtime and compile time at the same time, realizing event interception in the runtime and retaining user method name in the compile time, so as to achieve a relatively stable no-buried function. Specific usage can be seen: Taro3 integrated GrowingIO small program SDK. With Taro3 support, GrowingIO applet zero point implementation has been extended from run-time operation to compile-time operation, which is a new approach and may be further optimized in this direction in the future to provide more stable zero point functionality. Growingio/growing-babel-plugin-setName is an open source plugin for Babel