The introduction

In the last article, we mainly implemented JSX rendering and updating on WebGL. We gained a better understanding of the virtual DOM and Diff, but we still lacked an important component mode compared to React.

Everyone can agree that the React Component has powerful functions, high extensibility and high decoupling. Various UI Component frameworks built on it completely change the traditional Web development mode and provide a good construction mode and guarantee for large and complex applications in the Web. Also let our development efficiency also had qualitative change.

In this article, we will add the React component mode on the basis of the implementation of the last article, and explain some principles and thinking in the implementation process, which is also conducive to the understanding and improvement of programming thinking from simple to profound.

Because the next chapter is completely based on the previous chapter. So if you haven’t read the previous post, please first poke:

React ->

Stop 7: Broken Ruins – Component

Code reusability has always been a core concept in programming. We most often use functions and classes to encapsulate code, but there is a pain point:

The separation of UI and logic on the Web makes it difficult to integrate packages elegantly.

Usually we need to cite JS, CSS, write the structure in HTML according to the rules, and then initialize the library. The process was fragmented and inelegant.

The React Component solves this problem by keeping the dynamic UI separate from the logic and giving us a new way to integrate it. This makes UI components very efficient and easy to use, just by introducing them in the form of tags in the structure.

Let’s build on the previous article and add the Component feature. With JSX variable passing, we just need to implement a Component class and render and update components accordingly.

TIPs:

With the addition of components, our virtual DOM(VNode) consists of two types: component node (compVNode) and element node (elVNode). I’ll make this distinction later for easy explanation.

// Component base class
class Component {
    // By keeping three snapshots of different periods
    // Facilitate the management and tracking of component states and properties

    / / property
    public __prevProps
    public props = {}
    public __nextProps
    
    / / state
    public __prevState
    public state = {}
    public __nextState
    
    // Store the VNode rendered by the current component
    public __vnode
    
    // Store component nodes
    public __component
    
    constructor(props) {
        // Initialize parameters
        this.props = props
    }
    public render(): any { return null }
    public __createVNode() {
        // This method is used to encapsulate the logic of reexecuting render
    
        // The logic of the props state change
        this.__prevProps = this.props
        this.__prevState = this.state

        this.props = this.__nextProps
        this.state = this.__nextState

        this.__nextState = this.__nextProps = undefined
        
        // re-execute render to generate vNodes
        this.__vnode = this.render()
        
        return this.__vnode
    }
}
Copy the code

With this class in place, we can use React to inherit custom components for rendering:

class App extends Component {
    constructor(props) {
        super(props)
        this.state = {
            content: 'ReactWebGL Component, Hello World',
        }
    }
    public render() {
        return (
            <Container name="parent">
                {this.state.content}
            </Container>
        )
    }
}

render(<App />, game.stage)
Copy the code

As we mentioned earlier, JSX is compiled as variable pass. Therefore, when
is converted to VNode, the value of vNode. type is App, not a string tag like div. So next we need to implement a Component initialization function (Component). The main functions of this function are:

Instantiate the component and get itrenderMethod to return the element node.

// Render component
function renderComponent(compVNode) {
    const { type: Comp, props } = compVNode
    let instance = compVNode.instance
    
    // When an instance already exists, there is no need to recreate the instance
    if(! instance) {// Pass props to initialize the component instance
        // Support class components or function components
        if (Comp.prototype && Comp.prototype.render) {
            instance = new Comp(props)
        } else {
            instance = new Component(props)
            instance.constructor = Comp
            instance.render = (a)= > instance.constructor(props)
        }
        
	    // First render
	    // Future properties and states are the same as current values
	    instance.__nextProps = props
	    instance.__nextState = instance.state
    }    
    
    // Call render to get VNode
    const vnode = instance.__createVNode()

    // Components, elements, and instances keep references to each other, which helps to bidirectionally connect the entire virtual tree
    instance.__component = compVNode
    compVNode.instance = instance
    compVNode.vnode = vnode
    vnode.component = compVNode

    return vnode
}
Copy the code

The next step is to call createElm to initialize the element node when it is passed in as a component node, and then simply continue with the original logic to render the component properly.

function createElm(vnode) {
	// Initialize the component when it is a component
	// Reassign to element nodes
    if (typeof vnode.type === 'function') {
        vnode = renderComponent(vnode) 
    }
    
    // Maintain the original logic. return vnode.elm }Copy the code

Save execution and the

component is rendered correctly on the page. Following the previous logic, after the initial rendering, the component is updated. This is the most commonly used this.setState.

Stop 8: Key to Purification – setState

In the last article, we implemented the virtual DOM update function Diff. The parameters are old and new virtual nodes (oldVNode and newVNode). So component updates work the same way:

Gets the old and new renderings of component instances successivelyVNodeTo triggerdiffFunction.

In the Component rendering, we saved the VNode generated by render on this.__vNode, which is the oldVNode generated at initialization. So what we’re going to do is:

setStateTo update the status, callrenderGenerate a new virtual node (newVNode) and the triggerdiffThe update.

So we add two new methods to the class: setState and __update:

class Component {
	// Other methods.// Update function
	public __update = (a)= > {
        // Temporarily store old virtual nodes (oldVNode)
        const oldVNode = this.__vnode
        
        this.__nextProps = this.props
        if (!this.__nextState) this.__nextState = this.state

        // Create a new virtual node (newVNode)
        this.__vnode = this.__createVNode()

        // Call diff to update VNode
        diff(oldVNode, this.__vnode)
    }
    
    // Update the status
    public setState(partialState, callback?) {
        // Merge state, temporarily stored in the upcoming update state
        if (typeof partialState === 'function') {
            partialState = partialState(this.state)
        }
        this.__nextState = { ... this.state, ... partialState, }// Call the update and perform the callback
        this.__update()
        callback && callback()
    }
}
Copy the code

Update the optimization

Here we see that setState encapsulates the diff method. However, due to the complexity of diFF, performance optimization is an important consideration. Each time setState is executed, newVNode needs to be regenerated for diff. Therefore, when the component is very complex or continuously updated, it can cause the main process to block, causing the page to freeze.

Here we need two optimizations:

  • setStateAsynchronization to avoid blocking the main process;

  • setStateMerge, where multiple consecutive calls are eventually merged into one;

    • Multiple updates of the same component;

    • The parent level triggers updates continuously. Since the parent level update actually contains updates of the child level, if the child level updates itself again, it becomes an unnecessary consumption.

For this optimization, we first need an update queue function:

  • Update can be called asynchronously;

  • Annotate components to ensure that individual components are updated only once in a loop;

Let’s start with an asynchronous execution queue:

// Update asynchronously, using promises belonging to microtasks, compatibility using setTimeout
// The microtask is used here to ensure that the macro task is executed first
// Ensure that more important tasks such as UI rendering are avoided;
const defer = typeof Promise= = ='function' 
    ? Promise.prototype.then.bind(Promise.resolve())
    : setTimeout

// Update the queue
const updateQueue: any[] = []

// Queue update API
export function enqueueRender(updater) {

    // Synchronously push all updaters to the update queue
    // Add an attribute __dirty to the instance to indicate whether it is in the state to be updated
    // This value is set to false after initial and update
    // When pushed into the queue, mark true
    if (
        !updater.__dirty && 
        (updater.__dirty = true) && 
        updateQueue.push(updater) === 1
    ) {
        // Asynchronize the flush queue
        // Finally, only one flush is performed
        defer(flushRenderQueue)
    }
}

// Merge multiple updaters in a loop
function flushRenderQueue() {
    if (updateQueue.length) {
        // Sort the update queue
        updateQueue.sort()

        // loop queue out of stack
        let curUpdater = updateQueue.pop()
        while (curUpdater) {
        
            // Triggers component updates when the component is in the state to be updated
            // If the component has already been updated, the status is false
            // Subsequent updates will not be executed
            if (curUpdater.__dirty) {
                // Call the update function of the component itself
                curUpdater.__update()

                / / callback execution
                flushCallback(curUpdater)
            }
            
            curUpdater = updateQueue.pop()
        }
    }
}

// Executes the callback cached on updater.__setStatecallbacks
function flushCallback(updater) {
    const callbacks = updater.__setStateCallbacks
    let cbk
    if (callbacks && callbacks.length) {
        while (cbk = callbacks.shift()) cbk.call(updater)
    }   
}
Copy the code

With this method done, we can modify the setState function above:

class Component {
	// Other methods. public setState(partialState = {}, callback?) {// Merge state, temporarily stored in the upcoming update state
        // Process arguments as functions
        if (typeof partialState === 'function') {
            partialState = partialState(this.state, this.props)
        }
        
        this.__nextState = { ... this.state, ... partialState, }// Cache callback
        callback && this.__setStateCallbacks.push(callback)
        // Push the component itself to the update queue first
        enqueueUpdate(this)}}Copy the code

At this point, because the update queue is asynchronous, when setState is called repeatedly, the state of the component will be synchronized and merged. After all the calls are completed, the flushing of the update queue will be entered and only one component update will be executed finally.

The React component also has a forceUpdate(callback) method, which does exactly the same thing as this.setstate ({}, callback). ShouldComponentUpdate doesn’t need to be checked, it just needs to add a bit.

Optimization strategy

Going back to the point of performance optimization, we can see from the simple implementation here that while the update process is asynchronized, it still doesn’t essentially address the complex component diff that blocks the main process for long periods of execution. I remember a previous article stating that the most effective performance optimization methods are asynchronous, task splitting, and caching strategies.

1. Asynchronization:

By making synchronous code execution asynchronous and serial code execution parallel, you can effectively improve execution time utilization and ensure code priority. From this, two optimization directions can be extended:

    1. Asynchrony: Optimizations like the one we did above can ensure that the main process is executed first and that page rendering or more important tasks are executed first to avoid stalling;
    1. parallel: by putting some high-cost operations intoThe main processFor example, the worker thread. But due to thediffThe complexity itself, and the need to handle the interaction between the main process and the thread, can lead to high complexity, but not impossible, and may be an optimization direction later.
    • For example, I have been thinking about the possibility of introducing WASM here, and what is the cost vs. benefit ratio, which can be discussed with interested children.

2. Task segmentation

Take chunks of logical execution that would otherwise block the main process and break them up into smaller tasks. In this way, you can find the right time in the logic to execute piecewise, that is, not to block the main process, but to allow the code to execute quickly and efficiently, maximizing the use of physical resources.

The Facebook gods chose this optimization direction, which is the main purpose of the new Fiber concept introduced by React 16. In the diff we implemented above, there was one big hurdle:

A complete virtual DOM tree update must be completed at one time and cannot be paused or split.

The most important function of Fiber is pointer mapping, which saves the last updated component and the next updated component, so as to complete pause and restart. Calculates the running time of the process, using the browser’s requestIdleCallback and requestAnimationFrame interfaces, and suspends updates to the next component when there is a higher priority task. Restart the update when idle.

Fiber is a programming idea that has many applications in other languages (Ruby Fiber). The core idea is:

Tasks are split and coordinated to actively delegate execution to the main thread so that the main thread has time to work on other high-priority tasks.

However, the implementation complexity is high, so it is not introduced for the convenience of this article. When we have the opportunity to further explore the implementation of Fiber, it may become a means of performance optimization for more scenarios.

Subcomponent update

In the previous article, we implemented the diffVNode method for updating element nodes first, but updating component nodes is not the same as updating element nodes. When components are nested, we need a new method (diffComponent) for updating component nodes.

Unlike element nodes, updates between component nodes are important for re-rendering, similar to our setState above.

Reuse the created component instance, re-execute render to generate element nodes according to the new state and properties, and then perform recursive comparison.

In other words, we need another layer of judgment processing around diffVNode:

function diff(oldVNode, newVNode) {
	if (isSameVNode(oldVNode, newVNode)) {
        if (typeof oldVNode.type === 'function') {
            // Component node
            diffComponent(oldVNode, newVNode)
        } else {
            // Element node,
            // Perform the comparison directly
            diffVNode(oldVNode, newVNode)
        }
	} else {
		// The new node replaces the old node. }}// Component alignment
function diffComponent(oldCompVNode, newCompVNode) {
    const { instance, vnode: oldVNode, elm } = oldCompVNode
    const { props: nextProps } = newCompVNode

    if (instance && oldVNode) {
        instance.__dirty = false
        
        // Update status and properties
        instance.__nextProps = nextProps
        if(! instance.__nextState) instance.__nextState = instance.state// Reuse old component instances and elements
        newCompVNode.instance = instance
        newCompVNode.elm = elm

        // Use new properties, new state, old component instance
        // Regenerate a new virtual DOM
        const newVNode = initComponent(newCompVNode)
        
        // Trigger diff recursively
        diff(oldVNode, newVNode)
    }
}
Copy the code

Life will Lifecycle

One of the most important characteristics of components is that they have a life cycle. Different function hooks correspond to the critical time points of a component from initialization to destruction. The main purpose is to give the business side the ability to plug into the rendering workflow of the component and write the business logic. Let’s take a look at the life cycle of the React component:

For the first time to render:

  • constructor

    • The components of theInstantiation timing, usually used to set initializationstate;
  • static getDerivedStateFromProps(nextProps, prevState)

    • In template rendering of components, we usually use props and state. The props is passed in by the parent, and the component itself cannot be modified directly. Therefore, the only common requirement is to dynamically modify state based on the props passed in by the parent. That’s what this life cycle is about;

    • You may be wondering: why is this method static? Instead of a regular instance method?

      • Just to be clear: using the instance approach is definitely going to do the trick. However, this hook is special in that it executes after the new state is merged but before rerendering, and the method intrudes into the update mechanism. It is very unmanageable to do things like change the state. When designed as a static method, the component instance cannot be accessed from within the function, becoming a pure function ensures the security and stability of the update process.
  • render()

    • According to thestate ε’Œ propsTo generate theVirtual DOM;
  • componentDidMount()

    • The component is created as a real element and called after rendering. At this time, the real element state can be obtained, which is mainly used for the execution of business logic, such as data request, event binding, etc.

Update the stage:

  • static getDerivedStateFromProps(nextProps, prevState)

  • shouldComponentUpdate(nextProps, nextState)

    • As mentioned in the diff optimization strategy in the previous article, to reduce unnecessary update consumption, give components an API that proactively interrupts the update stream. According to the update attribute and update status in the parameters, the service side determines whether to continue to perform diff, thus effectively improving the update performance.

    • React has a Component called a PureComponent. This class inherits from regular Component packages to reduce render and improve performance.

      • The shouldComponentUpdate function is used by default to set the update condition: the update is triggered only when the props and state are changed. Here, Object shallow level comparison is used, that is, only the first level comparison is performed, that is, 1. 2. Whether value is congruent; So if more than one layer of data changes are required, pure components cannot be updated properly;

      • This is why React advocates the principle of using immutable data, effectively using shallow comparisons;

      • Invariant data: immutable data is advocated. Any modification needs to return a new object instead of directly modifying the original object, which can effectively improve the comparison efficiency and reduce unnecessary performance loss.

  • render()

  • getSnapshotBeforeUpdate(prevProps, prevState)

    • Replace the old version of componentWillUpdate, trigger the timing point: when the data state has been updated, the latest VNode has been generated, but the real element has not been updated;

    • It can be used to calculate some information from real elements or states before the update, so that it can be used after the update.

  • componentDidUpdate(prevProps, prevState, snapshot)

    • Called after component update is complete;

    • SetState can be used to monitor data changes. Conditions must be added when using setState to avoid infinite loops.

Unloading phase:

  • componentWillUnmount()
    • Component is about to be destroyed. It can be used to unbind events, clear data, release memory, etc.

We implement these life cycles in our Component with this goal in mind. How do we better organize the life cycle? Here I take into account:

As a container of elements, the life cycle of a component is essentially the life cycle of the element nodes it renders.

In other words, it’s all about the element’s workflow in the view, when it’s mounted, updated, unloaded. Therefore, for better maintainability and extensibility, it is desirable to add a unified lifecycle for element nodes rather than separate components, which greatly reduces complexity and increases extensibility.

Node life cycle

So, the first step is to figure out what timing is needed according to the life cycle required above:

  • After creation (create);
  • After mount (insert);
  • Pre-update (willupdate);
  • Update;
  • Before deleting (willremove);

The principle is simple, just call the corresponding lifecycle function at the corresponding time in the VNode workflow. Add an hooks attribute to VNode that stores the corresponding lifecycle functions:

interface VNode { ... hooks: { create? :(vnode) = > voidinsert? :(vnode) = > voidwillupdate? :(oldVNode, newVNode) = > voidupdate? :(oldVNode, newVNode) = > voidwillremove? :(vnode) = > void}}Copy the code

Add a method to trigger (fireVNodeHook):

function fireVNodeHook(vnode, name, ... data) {
    // By life cycle name
    // Execute the corresponding function stored on VNode
    const { hooks: _hooks } = vnode
    if(_hooks) { hook = _hooks[name] hook && hook(... data) } }Copy the code

With this layer of basic methods in place, all we need to do is fire each function in the render and update process we wrote earlier.

1. create

This is after the element is created, but before it is mounted. Since we’ve previously grouped the logic into createElm, all we need to do is add a trigger at the end of the function.

function createElm(vnode) {
	// Create element logic.// Triggers the hook function stored in the virtual DOM
	fireVNodeHook(vnode, 'create', vnode)
	
	return vnode.elm
}
Copy the code

2. insert

The time when the element is mounted to the view. From the element’s point of view, it is the point in time to be appended to the parent. The timing is a bit scattered, but it’s also a good time to join. Find three places to join using the APPend method in the Api:

  • renderAdd pair to the functionThe root nodeThe trigger;
  • createElmAdd pair to the functionAll the childrenThe trigger;
  • diffChildrenList alignmentAdding a list itemThe trigger;

3. willupdate 与 update

Before and after the update corresponds to our diff function. Since diffVNode is the ultimate destination, only the beginning and end of diffVNode need to be triggered.

4. willremove

When an element is unloaded, it is similar to insert, just when removeChild is called in the Api. During diff list alignment, when the new list does not exist, we need to remove elements from the old list, namely the business function removeVNodes written earlier.

Component life cycle

Since element nodes are the key to render and update the entire virtual DOM, we first implemented the triggering of the life cycle of element nodes. But we ultimately need to be the life cycle of the component node. Since the component node and element node have a one-to-one hierarchical relationship, we also need to do a layer transition here:

Assigns the life cycle of a component node to its generated element node.

Define the component lifecycle, and define a relay object __hooks to convert component node cycles to element node cycles:

class Component {
    public __hooks = {
        DidMount is triggered when element nodes are inserted
        insert: (a)= > this.componentDidMount(),

        GetSnapshotBeforeUpdate is triggered before the element node is updated
        willupdate: (vnode) = > {
            this.__snapshot = this.getSnapshotBeforeUpdate(this.__prevProps, this.__prevState)
        },

        // didUpdate is triggered after the element node is updated
        update: (oldVNode, vnode) = > {
            this.componentDidUpdate(this.__prevProps, this.__prevState, this.__snapshot)
            this.__snapshot = undefined
        },

        WillUnmount is triggered before the element node is unmounted
        willremove: (vnode) = > this.componentWillUnmount(),
    }

    // Default lifecycle function
    // getDerivedStateFromProps(nextProps, state)
    public getSnapshotBeforeUpdate(prevProps, prevState) { return undefined }
    public shouldComponentUpdate(nextProps, nextState) { return true }
    public componentDidMount() { }
    public componentDidUpdate(prevProps, prevState, snapshot) { }
    public componentWillUnmount() { }
}
Copy the code

Then we simply assign this.__hooks to the generated VNode in the __createVNode method:

class Component {... public __createVNode() {// ...
	    this.__vnode = this.render()
	    
	    // Assign to the corresponding element node,
	    // Implement the lifecycle binding between the element node and the component
	    this.__vnode.hooks = this.__hooks
	    
	    return this.__vnode
	}
}
Copy the code

Finally, you may notice that we still have two hooks left unimplemented: GetDerivedStateFromProps and shouldComponentUpdate. This is because these two life cycles will affect the update result, so they need to be deeply involved in the update process and cannot be realized simply by the life cycle of element nodes.

The update logic needs to be adjusted according to the results of the two functions before updating:

// The __update method in Componet
class Component {
	// ...

	public __update = (a)= > {
        // Temporarily store old virtual nodes (oldVNode)
        const oldVNode = this.__vnode
        
        this.__nextProps = this.props
        if (!this.__nextState) this.__nextState = this.state
        
        / / getDerivedStateFromProps execution
        // Update the state
        const cls = this.constructor
        if (cls.getDerivedStateFromProps) {
            const state = cls.getDerivedStateFromProps(this.__nextProps, this.state)
            if (state) {
                this.__nextState = Object.assign(this.__nextState, state)
            }
        }
        
        // Call shouldComponentUpdate before diff to determine
        // true: Generate a new VNode and continue diff
        // false: clear the status
        if (this.shouldComponentUpdate(this.props, this.__nextState)) {
            // Create a new virtual node (newVNode)
            this.__vnode = this.__createVNode()

            // Call diff to update VNode
            diff(oldVNode, this.__vnode)
        } else {
            // Clear status updates
            this.__nextProps = this.__nextState = undefined
        }
        
        // The status of the component identified in the asynchronous update queue is to be updated
        // Set it to false after updating
        this.__dirty = false}}Copy the code

Component updates have another place, the diffComponent, that requires similar execution and judgment. With this part of the code completed, let’s test a simple DEMO:

    1. <App>,<BBB txt={this.state.txt} /> Correctly apply colours to a drawing;
    1. The two-component rendering life cycle is as expected;
    1. Triggered update, the call<App> setState.<BBB>Text elements are updated correctly
    1. The two-component update lifecycle is as expected;

Figure 1. Life cycle DEMO

Last stop: The end of the journey

In this series of articles, we implemented the core parts of React: JSX, components, rendering, and updates. We take a hands-on approach, step by step, to explore some principles and strategies, and come up with some best practices. I believe that after this journey, you will have a deeper understanding of React, which will inspire and help you. In fact, I am the same, but also in this journey with everyone to learn together, grow together.

React ->

There are also many modules, such as Context, Refs, fragments and some global apis, such as cloneElement, as well as some more rigorous judgment and boundary case handling in the code, which are not covered in this article, mainly because these parts are more purely logical extensions. But also to make it easier to understand. If you’re interested, you can check out the full code on Github:

react-webgl.js ->

I also want to talk a little bit about the idea of React-WebGL.

Recently, I got in touch with the development of Web games, and got some thinking and understanding from the front end developers. In the field of game development, traditional game developers have a completely different mind-programming model than the front-end field. With the development of the Web, they need to expand into the Js environment. As a result, a number of game engine libraries have emerged, essentially ported from libraries on other platforms. When I am developing from the perspective of a front-end developer, it is not that it is difficult to get started and the cost of learning is high, but it gives me the feeling that it is inefficient to write pages with pure native JS. Therefore, this is the starting point of React-WebGL, hoping to apply the better ideas of the current Web to game development, and even find a more efficient development mode to improve efficiency and improve the ecology.

Of course, this is just a starting point, game development and interface development do have many similarities and differences, how to find a more modern and efficient Web game development mode, it still needs a long journey. I have been thinking, have been groping, believe that there will be some fun things. Don’t give up at the beginning without trying or trying. Have any question, have any idea, directly find me to discuss ha. πŸ™ƒ ~ ~

Tips:

Look at the bloggers write so hard, kneel for praise, attention, Star! More articles ->

Email: [email protected] wechat /QQ: 159042708

Bless # Thanksgiving # go wuhan #RIP KOBE#