The meaning and implementation of virtual DOM

This article comes from "An essay on the meaning and implementation of virtual DOM" , if you think it's good, welcome to Star Github warehouse.

abstract

With the rise of react, the principle and implementation of Virtual DOM also began to appear in interviews and community articles. In fact, this approach has been implemented as early as d3.js. It is the rapid establishment of react ecology that makes it officially enter the perspective of developers.

Before the formal start, several problems will be raised to guide the thinking. These problems will be gradually solved in different sections:

  • How to understand VDom?
  • How to express VDom?
  • How to compare VDom trees and update them efficiently?

⚠⚠⚠⚠⚠⚠⚠⚠⚠⚠⚠⚠⚠⚠⚠⚠⚠ github.com/dongyuanxin.

How to understand VDom?

Once upon a time, what the front-end often did was to update the interface view according to the update of data status. We are gradually aware that for the interface of complex views, frequent DOM updates will cause backflow or redraw, resulting in performance degradation and page jam.

Therefore, we need methods to avoid frequently updating the DOM tree. The idea is also very simple, that is: compared with the DOM gap, only part of the nodes are needed to update, rather than updating a tree. To implement this algorithm, we need to traverse the nodes of DOM tree to compare and update.

For faster processing, instead of using DOM objects, it is represented by JS objects, which is like a layer of caching between JS and DOM.

How to represent VDom?

With the class of ES6, VDom is more semantic. A basic VDom needs a label name, a label attribute, and a child node, as follows:

class Element {
  constructor(tagName, props, children) {
    this.tagName = tagName;
    this.props = props;
    this.children = children;
  }
}

To make it easier to call (without writing new every time), encapsulate the function returned to the instance:

function el(tagName, props, children) {
  return new Element(tagName, props, children);
}

At this point, if you want to express the following DOM structure:

<div class="test">
  <span>span1</span>
</div>

Using VDom is:

// The elements of a child node array can be text or a VDom instance
const span = el("span", {}, ["span1"]);
const div = el("div", { class: "test" }, [span]);

Later, when comparing and updating two VDom trees, it involves rendering VDom into a real Dom node. Therefore, add the render method to the class Element:

class Element {
  constructor(tagName, props, children) {
    this.tagName = tagName;
    this.props = props;
    this.children = children;
  }

  render() {
    const dom = document.createElement(this.tagName);
    // Set label property value
    Reflect.ownKeys(this.props).forEach(name =>
      dom.setAttribute(name, this.props[name])
    );

    // Recursively update child nodes
    this.children.forEach(child => {
      const childDom =
        child instanceof Element
          ? child.render()
          : document.createTextNode(child);
      dom.appendChild(childDom);
    });

    return dom;
  }
}

How to compare VDom trees and update them efficiently?

The usage and meaning of VDom have been explained previously. Multiple vdoms will form a virtual DOM tree. What remains to be done is to add, delete and modify nodes in the tree according to different situations. This process is divided into diff and patch:

  • diff: recursively compare the node differences between two VDom trees and their corresponding positions
  • patch: update nodes according to different differences

At present, there are two ways of thinking: one is to diff once, record all the differences, and then carry out a unified patch; the other is to diff at the same time, carry out a patch. In contrast, the second method is less than one recursive query and does not need to construct too many objects. The second idea is taken below.

Meaning of variables

Put the process of diff and patch into the updateEl method, which is defined as follows:

/**
 *
 * @param {HTMLElement} $parent
 * @param {Element} newNode
 * @param {Element} oldNode
 * @param {Number} index
 */
function updateEl($parent, newNode, oldNode, index = 0) {
  // ...
}

All variables starting with $represent the real DOM.

The parameter index indicates the subscript position of the array composed of all the child nodes of $parent.

Case 1: new node

If the oldNode is undefined, the newNode is a new DOM node. You can simply append it to the DOM node:

function updateEl($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(newNode.render());
  }
}

Case 2: delete node

If the newNode is undefined, there is no node in the current location in the new VDom tree, so it needs to be removed from the actual DOM. To delete, call $parent.removeChild(). With the index parameter, you can get the reference of the deleted element:

function updateEl($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(newNode.render());
  } else if (!newNode) {
    $parent.removeChild($parent.childNodes[index]);
  }
}

Case 3: change node

Compared with oldNode and newNode, there are three situations, which can be regarded as changes:

  1. Node type changes: text becomes vdom; vdom becomes text
  2. New and old nodes are text, and the content changes
  3. The attribute value of the node changes

First, the three changes are better semantically declared with Symbol:

const CHANGE_TYPE_TEXT = Symbol("text");
const CHANGE_TYPE_PROP = Symbol("props");
const CHANGE_TYPE_REPLACE = Symbol("replace");

There is no ready-made api for us to update the node attributes in batch. Therefore, it encapsulates the replaceAttribute to map the attributes of the new vdom directly to the dom structure:

function replaceAttribute($node, removedAttrs, newAttrs) {
  if (!$node) {
    return;
  }

  Reflect.ownKeys(removedAttrs).forEach(attr => $node.removeAttribute(attr));
  Reflect.ownKeys(newAttrs).forEach(attr =>
    $node.setAttribute(attr, newAttrs[attr])
  );
}

Write the checkChangeType function to determine the type of change; if there is no change, return null:

function checkChangeType(newNode, oldNode) {
  if (
    typeof newNode !== typeof oldNode ||
    newNode.tagName !== oldNode.tagName
  ) {
    return CHANGE_TYPE_REPLACE;
  }

  if (typeof newNode === "string") {
    if (newNode !== oldNode) {
      return CHANGE_TYPE_TEXT;
    }
    return;
  }

  const propsChanged = Reflect.ownKeys(newNode.props).reduce(
    (prev, name) => prev || oldNode.props[name] !== newNode.props[name],
    false
  );

  if (propsChanged) {
    return CHANGE_TYPE_PROP;
  }
  return;
}

In updateEl, do the corresponding processing according to the change type returned by checkChangeType. If the type is empty, no processing is performed. The specific logic is as follows:

function updateEl($parent, newNode, oldNode, index = 0) {
  let changeType = null;

  if (!oldNode) {
    $parent.appendChild(newNode.render());
  } else if (!newNode) {
    $parent.removeChild($parent.childNodes[index]);
  } else if ((changeType = checkChangeType(newNode, oldNode))) {
    if (changeType === CHANGE_TYPE_TEXT) {
      $parent.replaceChild(
        document.createTextNode(newNode),
        $parent.childNodes[index]
      );
    } else if (changeType === CHANGE_TYPE_REPLACE) {
      $parent.replaceChild(newNode.render(), $parent.childNodes[index]);
    } else if (changeType === CHANGE_TYPE_PROP) {
      replaceAttribute($parent.childNodes[index], oldNode.props, newNode.props);
    }
  }
}

Case 4: recursively Diff child nodes

If case 1, case 2 and case 3 do not hit, it means that the current new and old nodes have not changed. At this point, we need to traverse the children array (DOM sub nodes) of Virtual Dom and deal with it recursively.

The code implementation is very simple:

function updateEl($parent, newNode, oldNode, index = 0) {
  let changeType = null;

  if (!oldNode) {
    $parent.appendChild(newNode.render());
  } else if (!newNode) {
    $parent.removeChild($parent.childNodes[index]);
  } else if ((changeType = checkChangeType(newNode, oldNode))) {
    if (changeType === CHANGE_TYPE_TEXT) {
      $parent.replaceChild(
        document.createTextNode(newNode),
        $parent.childNodes[index]
      );
    } else if (changeType === CHANGE_TYPE_REPLACE) {
      $parent.replaceChild(newNode.render(), $parent.childNodes[index]);
    } else if (changeType === CHANGE_TYPE_PROP) {
      replaceAttribute($parent.childNodes[index], oldNode.props, newNode.props);
    }
  } else if (newNode.tagName) {
    const newLength = newNode.children.length;
    const oldLength = oldNode.children.length;
    for (let i = 0; i < newLength || i < oldLength; ++i) {
      updateEl(
        $parent.childNodes[index],
        newNode.children[i],
        oldNode.children[i],
        i
      );
    }
  }
}

Effect observation

take github.com/dongyuanxin/pure-virtual-dom The code clone to local, Chrome Open index.html.

Add dom node.gif:

Update text content.gif:

Change node attribute.gif:

⚠ please move to github warehouse

Reference link

Tags: Javascript github Attribute React less

Posted on Mon, 02 Dec 2019 06:59:42 -0500 by anon_login_001