Why the Virtual Dom

As we all know, manipulating the DOM is a performance-intensive affair, so we can consider using JS objects to simulate DOM objects, since manipulating JS objects takes much less time than manipulating the DOM.

For example

// Suppose we simulate a ul that contains 5 Li's
[1.2.3.4.5] 
// Replace the above li here
[1.2.5.4]
Copy the code

From the above example, we can see at a glance that the third Li in the previous UL has been removed, and four and five have been replaced.

If the above corresponds to the DOM, this is the code below

// Delete the third li
ul.childNodes[2].remove()
// Switch the fourth li and the fifth li
let fromNode = ul.childNodes[4]
let toNode = node.childNodes[3]
let cloneFromNode = fromNode.cloneNode(true)
let cloenToNode = toNode.cloneNode(true)
ul.replaceChild(cloneFromNode, toNode)
ul.replaceChild(cloenToNode, fromNode)
Copy the code

Of course, in actual operation, we also need to give each node a label, as the basis to judge that it is the same node. This is why nodes in Vue and React’s official recommended lists use unique keys to ensure performance.

So since DOM objects can be simulated by JS objects, the corresponding DOM can also be rendered by JS objects

Here is a simple implementation of a JS object that emulates a DOM object

export default class Element {
  /** * @param {String} tag 'div' * @param {Object} props { class: 'item' } * @param {Array} children [ Element1, 'text'] * @param {String} key option */
  constructor(tag, props, children, key) {
    this.tag = tag
    this.props = props
    if (Array.isArray(children)) {
      this.children = children
    } else if (isString(children)) {
      this.key = children
      this.children = null
    }
    if (key) this.key = key
  }
  / / rendering
  render() {
    let root = this._createElement(
      this.tag,
      this.props,
      this.children,
      this.key
    )
    document.body.appendChild(root)
    return root
  }
  create() {
    return this._createElement(this.tag, this.props, this.children, this.key)
  }
  // Create a node
  _createElement(tag, props, child, key) {
    // Create a node with a tag
    let el = document.createElement(tag)
    // Set the node properties
    for (const key in props) {
      if (props.hasOwnProperty(key)) {
        const value = props[key]
        el.setAttribute(key, value)
      }
    }
    if (key) {
      el.setAttribute('key', key)
    }
    // Add child nodes recursively
    if (child) {
      child.forEach(element= > {
        let child
        if (element instanceof Element) {
          child = this._createElement(
            element.tag,
            element.props,
            element.children,
            element.key
          )
        } else {
          child = document.createTextNode(element)
        }
        el.appendChild(child)
      })
    }
    return el
  }
}
Copy the code

Description of Virtual Dom algorithm

Now that we have implemented the DOM through JS emulation, the next challenge is to determine the difference between the old object and the new object.

DOM is a multi-fork tree structure. If you need to completely compare the differences between two trees, the time complexity will be O(n ^ 3), which is definitely unacceptable. So the React team optimized the algorithm to achieve O(n) complexity to compare differences.

The key to achieving O(n) complexity is to compare nodes only at the same level, not across layers, given that few DOM elements are moved across layers in real business.

So there’s a two-step algorithm for determining differences

  • The object is first traversed from top to bottom and left to right, the depth of the tree, adding indexes to each node to facilitate the final rendering of the differences
  • Once the node has child elements, determine if the child elements are different

Virtual Dom algorithm implementation

Tree recursion

First of all, we will implement the recursive algorithm of the tree. Before implementing the algorithm, we will consider several situations when the two nodes are compared

  1. New-nodetagNameorkeyUnlike the old one, this means that the old node needs to be replaced and there is no longer a need to traverse the child elements of the old node because the entire old node has been deleted
  2. New-nodetagNamekeySame as the old one, start traversing the subtree
  3. No new nodes, so nothing to do
import { StateEnums, isString, move } from './util'
import Element from './element'

export default function diff(oldDomTree, newDomTree) {
  // To record the difference
  let pathchs = {}
  // The initial index is 0
  dfs(oldDomTree, newDomTree, 0, pathchs)
  return pathchs
}

function dfs(oldNode, newNode, index, patches) {
  // Used to save changes to the subtree
  let curPatches = []
  // There are three cases to judge
  // 1. There is no new node, so there is nothing to do
  // 2. The tagName and 'key' of the new node are different from those of the old node
  // 3. The new node has the same tagName and key as the old node, and starts traversing the subtree
  if(! newNode) { }else if (newNode.tag === oldNode.tag && newNode.key === oldNode.key) {
    // Determine whether the attribute has changed
    let props = diffProps(oldNode.props, newNode.props)
    if (props.length) curPatches.push({ type: StateEnums.ChangeProps, props })
    // Walk through the subtree
    diffChildren(oldNode.children, newNode.children, index, patches)
  } else {
    // The node is different and needs to be replaced
    curPatches.push({ type: StateEnums.Replace, node: newNode })
  }

  if (curPatches.length) {
    if (patches[index]) {
      patches[index] = patches[index].concat(curPatches)
    } else {
      patches[index] = curPatches
    }
  }
}
Copy the code

Determine changes to properties

Determining property changes is also a three-step process

  1. Walk through the old property list to see if each property still exists in the new property list
  2. Iterate through the new property list to see if the value of the property that exists in both lists has changed
  3. In the second step, check to see if there are any properties that do not exist in the old property column list
function diffProps(oldProps, newProps) {
  // Determine Props in three steps
  // Iterate over the oldProps to see if any properties are deleted
  // Then traverse newProps to see if any property values have been modified
  // Finally check to see if any attributes are added
  let change = []
  for (const key in oldProps) {
    if(oldProps.hasOwnProperty(key) && ! newProps[key]) { change.push({prop: key
      })
    }
  }
  for (const key in newProps) {
    if (newProps.hasOwnProperty(key)) {
      const prop = newProps[key]
      if(oldProps[key] && oldProps[key] ! == newProps[key]) { change.push({prop: key,
          value: newProps[key]
        })
      } else if(! oldProps[key]) { change.push({prop: key,
          value: newProps[key]
        })
      }
    }
  }
  return change
}
Copy the code

Judge list difference algorithm implementation

This algorithm is the most core algorithm in the entire Virtual Dom, and let me tell you one by one. The main steps here are actually similar to determining attribute differences, which are also divided into three steps

  1. Iterate through the old node list to see if each node still exists in the new node list
  2. Iterate over the new node list to see if there are any new nodes
  3. In the second step, determine whether the node has moved

PS: This algorithm only deals with nodes with keys

function listDiff(oldList, newList, index, patches) {
  // Retrieve all keys from both lists for easy traversal
  let oldKeys = getKeys(oldList)
  let newKeys = getKeys(newList)
  let changes = []

  // Used to save the changed node data
  // Saving with this array has the following advantages
  // 1. The index of the deleted node can be obtained correctly
  // 2. Switch node positions only need to operate DOM once
  // 3. Use the diffChildren function to judge, just need to traverse
  // Nodes exist in both trees, but are not necessary for new or removed nodes
  // Try again
  let list = []
  oldList &&
    oldList.forEach(item= > {
      let key = item.key
      if (isString(item)) {
        key = item
      }
      // Find if the new children contains the current node
      // If not, delete it
      let index = newKeys.indexOf(key)
      if (index === - 1) {
        list.push(null)}else list.push(key)
    })
  // Iterate over the changed array
  let length = list.length
  // Because deleting an array element changes the index
  // Delete from back to front to keep index unchanged
  for (let i = length - 1; i >= 0; i--) {
    // Determine whether the current element is empty, which means that it needs to be deleted
    if(! list[i]) { list.splice(i,1)
      changes.push({
        type: StateEnums.Remove,
        index: i
      })
    }
  }
  // Iterate through the new list to see if any nodes are added or moved
  // Also add and move nodes to 'list'
  newList &&
    newList.forEach((item, i) = > {
      let key = item.key
      if (isString(item)) {
        key = item
      }
      // Find the current node in the old children
      let index = list.indexOf(key)
      // New node not found, need to be inserted
      if (index === - 1  || key == null) {
        changes.push({
          type: StateEnums.Insert,
          node: item,
          index: i
        })
        list.splice(i, 0, key)
      } else {
        // If you need to move it, you need to move it
        if(index ! == i) { changes.push({type: StateEnums.Move,
            from: index,
            to: i
          })
          move(list, index, i)
        }
      }
    })
  return { changes, list }
}

function getKeys(list) {
  let keys = []
  let text
  list &&
    list.forEach(item= > {
      let key
      if (isString(item)) {
        key = [item]
      } else if (item instanceof Element) {
        key = item.key
      }
      keys.push(key)
    })
  return keys
}
Copy the code

Iterate over child elements for identification

For this function, there are only two main functions

  1. Determine the difference between the two lists
  2. Label the node

In general, what this function does is simple

function diffChildren(oldChild, newChild, index, patches) {
  let { changes, list } = listDiff(oldChild, newChild, index, patches)
  if (changes.length) {
    if (patches[index]) {
      patches[index] = patches[index].concat(changes)
    } else {
      patches[index] = changes
    }
  }
  // Record the last node traversed
  let last = null
  oldChild &&
    oldChild.forEach((item, i) = > {
      let child = item && item.children
      if (child) {
        index =
          last && last.children ? index + last.children.length + 1 : index + 1
        let keyIndex = list.indexOf(item.key)
        let node = newChild[keyIndex]
        // Only the existing nodes are traversed, other new or deleted nodes are not traversed
        if (node) {
          dfs(item, node, index, patches)
        }
      } else index += 1
      last = item
    })
}
Copy the code

Rendering differences

We can already figure out the difference between the two trees using our previous algorithm. Now that you know the difference, you need to update the DOM locally, so let’s take a look at the final step of the Virtual DOM algorithm

This function has two main functions

  1. Walk through the tree in depth, pulling out changes that need to be made
  2. Local DOM update

Overall this part of the code is pretty easy to understand

let index = 0
export default function patch(node, patchs) {
  let changes = patchs[index]
  let childNodes = node && node.childNodes
  // The depth traversal is the same as in diff
  if(! childNodes) index +=1
  if (changes && changes.length && patchs[index]) {
    changeDom(node, changes)
  }
  let last = null
  if (childNodes && childNodes.length) {
    childNodes.forEach((item, i) = > {
      index =
        last && last.children ? index + last.children.length + 1 : index + 1
      patch(item, patchs)
      last = item
    })
  }
}

function changeDom(node, changes, noChild) {
  changes &&
    changes.forEach(change= > {
      let { type } = change
      switch (type) {
        case StateEnums.ChangeProps:
          let { props } = change
          props.forEach(item= > {
            if (item.value) {
              node.setAttribute(item.prop, item.value)
            } else {
              node.removeAttribute(item.prop)
            }
          })
          break
        case StateEnums.Remove:
          node.childNodes[change.index].remove()
          break
        case StateEnums.Insert:
          let dom
          if (isString(change.node)) {
            dom = document.createTextNode(change.node)
          } else if (change.node instanceof Element) {
            dom = change.node.create()
          }
          node.insertBefore(dom, node.childNodes[change.index])
          break
        case StateEnums.Replace:
          node.parentNode.replaceChild(change.node.create(), node)
          break
        case StateEnums.Move:
          let fromNode = node.childNodes[change.from]
          let toNode = node.childNodes[change.to]
          let cloneFromNode = fromNode.cloneNode(true)
          let cloenToNode = toNode.cloneNode(true)
          node.replaceChild(cloneFromNode, toNode)
          node.replaceChild(cloenToNode, fromNode)
          break
        default:
          break}})}Copy the code

The last

The realization of Virtual Dom algorithm is the following three steps

  1. Create DOM objects through JS simulation
  2. Determine the difference between two objects
  3. Rendering differences
let test4 = new Element('div', { class: 'my-div'},'test4'])
let test5 = new Element('ul', { class: 'my-div'},'test5'])

let test1 = new Element('div', { class: 'my-div' }, [test4])

let test2 = new Element('div', { id: '11' }, [test5, test4])

let root = test1.render()

let pathchs = diff(test1, test2)
console.log(pathchs)

setTimeout((a)= > {
  console.log('Start updating')
  patch(root, pathchs)
  console.log('End of update')},1000)
Copy the code

Of course, the current implementation is a little rough, but it’s good enough to understand the Virtual Dom algorithm.

You can read the code in the article here. All articles in this series will be updated in the repository, if you are interested.

The next article will be about state management, so stay tuned.

Finally, attach my official account