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:
-
How do I intercept the triggering methods of user events
-
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.
-
The runtime SDK can intercept user methods
-
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:
- 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