Qualified Front-end Series Fifth Bullet-Virtual Dom & Diff

Keywords: Mobile github REST Attribute

Preface

This is a long article!!! Stick to see the last eggs!!!

At the beginning of the article, let's think about a question. Everyone says that virtual dom is what virtual dom is.

First of all, we have to make it clear that the so-called virtual dom is the virtual node. It simulates nodes in DOM through JS Object objects, and then renders them into real DOM nodes through specific render methods.

Secondly, we also know what virtual dom does. We know that the usual way to re-render a page is to do this by manipulating Dom and resetting innerHTML. virtual dom, on the other hand, returns a patch object, i.e. a patch object, by computing at the JS level, and parses the patch object through specific operations to complete the page re-rendering. A flow chart for specific virtual dom rendering is shown in the figure.

Next, I'll follow the old rules, code and parse, and bring my little buddies together to implement a virtual DOM & diff. The specific steps are as follows

  1. Implementing a utils method library
  2. Implementing an Element (virtual dom)
  3. Implementing diff algorithm
  4. Implementing patch

1. Implementing a utils method library

As the saying goes, sharpening knives does not waste chopping firewood. For the convenience of the latter, I will take some of the methods you often use to realize the latter. After all, if you write the method once every time, it will not be crazy, because the code is simple, so I will paste the code directly here.

const _ = exports

_.setAttr = function setAttr (node, key, value) {
  switch (key) {
    case 'value':
      node.style.cssText = value
      break;
    case 'value':
      let tagName = node.tagName || ''
      tagName = tagName.toLowerCase()
      if (
        tagName === 'input' || tagName === 'textarea'
      ) {
        node.value = value
      } else {
        // If the node is not input or textarea, use `setAttribute'to set properties
        node.setAttribute(key, value)
      }
      break;
    default:
      node.setAttribute(key, value)
      break;
  }
}

_.slice = function slice (arrayLike, index) {
  return Array.prototype.slice.call(arrayLike, index)
}


_.type = function type (obj) {
  return Array.prototype.toString.call(obj).replace(/\[object\s|\]/g, '')
}

_.isArray = function isArray (list) {
  return _.type(list) === 'Array'
}

_.toArray = function toArray (listLike) {
  if (!listLike) return []

  let list = []
  for (let i = 0, l = listLike.length; i < l; i++) {
    list.push(listLike[i])
  }
  return list
}

_.isString = function isString (list) {
  return _.type(list) === 'String'
}

_.isElementNode = function (node) {
  return node.nodeType === 1
}

Implementing an Element

One thing we need to do here is to implement an Object to simulate the presentation of DOM nodes. The real nodes are as follows

<ul id="list">
  <li class="item">item1</li>
  <li class="item">item2</li>
  <li class="item">item3</li>
</ul>

We need to complete an Element simulation of the real node above in the form of

let ul = {
  tagName: 'ul',
  attrs: {
    id: 'list'
  },
  children: [
    { tagName: 'li', attrs: { class: 'item' }, children: ['item1'] },
    { tagName: 'li', attrs: { class: 'item' }, children: ['item1'] },
    { tagName: 'li', attrs: { class: 'item' }, children: ['item1'] },
  ]
}

Looking at this, we can see that tagName, attrs, children in el object can be extracted into Element, that is, tagName, attrs, and children.

class Element {
  constructor(tagName, attrs, children) {
    this.tagName  = tagName
    this.attrs    = attrs
    this.children = children
  }
}
function el (tagName, attrs, children) {
  return new Element(tagName, attrs, children)
}
module.exports = el;

So the ul above can be written in a simpler way, that is

let ul = el('ul', { id: 'list' }, [
  el('li', { class: 'item' }, ['Item 1']),
  el('li', { class: 'item' }, ['Item 2']),
  el('li', { class: 'item' }, ['Item 3'])
])

ul is the Element object, as shown in the figure

OK, to this point, Element is half the implementation, and the rest is usually to provide a render function that renders Element objects into real DOM nodes. The complete Element code is as follows

import _ from './utils'

/**
 * @class Element Virtrual Dom
 * @param { String } tagName
 * @param { Object } attrs   Element's attrs, For example: {id:'list'}
 * @param { Array <Element|String> } It could be an Element object or just a string, textNode
 */
class Element {
  constructor(tagName, attrs, children) {
    // If there are only two parameters
    if (_.isArray(attrs)) {
      children = attrs
      attrs = {}
    }

    this.tagName  = tagName
    this.attrs    = attrs || {}
    this.children = children
    // Set this.key property to prepare for later list diff
    this.key = attrs
      ? attrs.key
      : void 0
  }

  render () {
    let el    = document.createElement(this.tagName)
    let attrs = this.attrs

    for (let attrName in attrs) { // Setting DOM properties of nodes
      let attrValue = attrs[attrName]
      _.setAttr(el, attrName, attrValue)
    }

    let children = this.children || []
    children.forEach(child => {
      let childEl = child instanceof Element
        ? child.render() // If the sub-node is also a virtual node, it is constructed recursively.
        : document.createTextNode(child)  // If it's a string, build the text node directly
      el.appendChild(childEl)
    })

    return el
  }
}
function el (tagName, attrs, children) {
  return new Element(tagName, attrs, children)
}
module.exports = el;

At this point, we execute the written render method, rendering Element objects into real nodes.

let ulRoot = ul.render()
document.body.appendChild(ulRoot);

The effect is shown in the figure.

So far, our Element has been implemented.

3. Implementing diff algorithm

What we do here is to implement a diff algorithm to compare virtual node Element s and return a patch object to store the differences between two nodes. This is also the core step of the whole virtual dom implementation. The diff algorithm contains two different algorithms, one is O(n), the other is O (max (m, n).

1. Comparison of Elements at the Same Level (O(n))

First of all, what we know is that if there is a complete comparison between elements, that is, the parent element of the old and new Element objects, and a hybrid comparison between the child elements, the time complexity of its implementation is O(n^3). But in our front-end development, there are few cross-level processing nodes, so here we will make a comparison between the same level elements, and its time complexity is O(n). The algorithm flow is shown in the figure.

Here, when we compare elements at the same level, there are four possible scenarios

  • The whole element is different, that is, the element is replace d.
  • Element attrs are different
  • The text text text of the element is different
  • The order of elements is replaced, that is, elements need reorder

The fourth case listed above belongs to the second algorithm of diff. We will not discuss it here, but we will discuss it in detail later.
For the above four cases, we first set four constants to express. The diff entry method and four states are as follows

const REPLACE = 0  // replace => 0
const ATTRS   = 1  // attrs   => 1
const TEXT    = 2  // text    => 2
const REORDER = 3  // reorder => 3

// diff entrance, comparing the difference between old and new trees
function diff (oldTree, newTree) {
  let index   = 0
  let patches = {} // Patch objects used to record differences between each node
  walk(oldTree, newTree, index, patches)
  return patches
}

OK, the status is defined, so let's go ahead. We implement it one by one, and we get different states. One thing to note here is that our diff comparison here will only compare between the two as shown in the flow chart above. If the node removes, pass will be removed here and list diff will be taken directly.

a. First, we compare the top-level elements down to the end of the last-level elements, and store the differences of each level in the patch object, that is, we implement the walk method.

/**
 * walk Traversing to Find Node Differences
 * @param  { Object } oldNode
 * @param  { Object } newNode
 * @param  { Number } index   - currentNodeIndex
 * @param  { Object } patches - Objects that record node differences
 */
function walk (oldNode, newNode, index, patches) {
  let currentPatch = []

  // If oldNode is remove d
  if (newNode === null || newNode === undefined) {
    // Do not do the operation first, and give it to list diff
  }
  // Compare the differences between texts
  else if (_.isString(oldNode) && _.isString(newNode)) {
    if (newNode !== oldNode) currentPatch.push({ type: TEXT, content: newNode })
  }
  // Comparing attrs
  else if (
    oldNode.tagName === newNode.tagName &&
    oldNode.key     === newNode.key
  ) {
    let attrsPatches = diffAttrs(oldNode, newNode)
    if (attrsPatches) {
      currentPatch.push({ type: ATTRS, attrs: attrsPatches })
    }
    // Recursive diff comparison of sub-nodes
    diffChildren(oldNode.children, newNode.children, index, patches)
  }
  else {
    currentPatch.push({ type: REPLACE, node: newNode})
  }

  if (currentPatch.length) {
    patches[index] = currentPatch
  }
}

function diffAttrs (oldNode, newNode) {
  let count    = 0
  let oldAttrs = oldNode.attrs
  let newAttrs = newNode.attrs

  let key, value
  let attrsPatches = {}

  // If there are different attrs
  for (key in oldAttrs) {
    value = oldAttrs[key]
    // If oldAttrs removes some attrs, newAttrs [key]==== undefined
    if (newAttrs[key] !== value) {
      count++
      attrsPatches[key] = newAttrs[key]
    }
  }
  // If a new attr exists
  for (key in newAttrs) {
    value = newAttrs[key]
    if (!oldAttrs.hasOwnProperty(key)) {
      attrsPatches[key] = value
    }
  }

  if (count === 0) {
    return null
  }

  return attrsPatches
}

b. In fact, we need to traverse the old and new elements in depth, adding a unique tag to each node. The specific flow is shown in the figure.

As shown above, the next thing we need to do is to mark each element node with a unique identifier while traversing the differences in depth. The specific methods are as follows

// Setting node unique identity
let key_id = 0
// diff with children
function diffChildren (oldChildren, newChildren, index, patches) {
  // Store the identity of the current node with an initialization value of 0
  let currentNodeIndex = index

  oldChildren.forEach((child, i) => {
    key_id++
    let newChild = newChildren[i]
    currentNodeIndex = key_id

    // Recursive Continuous Comparison
    walk(child, newChild, currentNodeIndex, patches)
  })
}

OK, this step is even. Let's call it and see what kind of patch object two different Element objects will return.

let ul = el('ul', { id: 'list' }, [
  el('li', { class: 'item' }, ['Item 1']),
  el('li', { class: 'item' }, ['Item 2'])
])
let ul1 = el('ul', { id: 'list1' }, [
  el('li', { class: 'item1' }, ['Item 4']),
  el('li', { class: 'item2' }, ['Item 5'])
])
let patches = diff(ul, ul1);
console.log(patches);

The console results are shown in the figure.

The complete diff code is as follows (including the method of calling list diff, if you're following the article, just comment out some of the code)

import _ from './utils'
import listDiff from './list-diff'

const REPLACE = 0
const ATTRS   = 1
const TEXT    = 2
const REORDER = 3

// diff entrance, comparing the difference between old and new trees
function diff (oldTree, newTree) {
  let index   = 0
  let patches = {} // Patch objects used to record differences between each node
  walk(oldTree, newTree, index, patches)
  return patches
}

/**
 * walk Traversing to Find Node Differences
 * @param  { Object } oldNode
 * @param  { Object } newNode
 * @param  { Number } index   - currentNodeIndex
 * @param  { Object } patches - Objects that record node differences
 */
function walk (oldNode, newNode, index, patches) {

  let currentPatch = []

  // If oldNode is remove d, that's when newNode === null
  if (newNode === null || newNode === undefined) {
    // Do not do the operation first, and give it to list diff
  }
  // Compare the differences between texts
  else if (_.isString(oldNode) && _.isString(newNode)) {
    if (newNode !== oldNode) currentPatch.push({ type: TEXT, content: newNode })
  }
  // Comparing attrs
  else if (
    oldNode.tagName === newNode.tagName &&
    oldNode.key     === newNode.key
  ) {
    let attrsPatches = diffAttrs(oldNode, newNode)
    if (attrsPatches) {
      currentPatch.push({ type: ATTRS, attrs: attrsPatches })
    }
    // Recursive diff comparison of sub-nodes
    diffChildren(oldNode.children, newNode.children, index, patches, currentPatch)
  }
  else {
    currentPatch.push({ type: REPLACE, node: newNode})
  }

  if (currentPatch.length) {
    patches[index] = currentPatch
  }
}

function diffAttrs (oldNode, newNode) {
  let count    = 0
  let oldAttrs = oldNode.attrs
  let newAttrs = newNode.attrs

  let key, value
  let attrsPatches = {}

  // If there are different attrs
  for (key in oldAttrs) {
    value = oldAttrs[key]
    // If oldAttrs removes some attrs, newAttrs [key]==== undefined
    if (newAttrs[key] !== value) {
      count++
      attrsPatches[key] = newAttrs[key]
    }
  }
  // If a new attr exists
  for (key in newAttrs) {
    value = newAttrs[key]
    if (!oldAttrs.hasOwnProperty(key)) {
      attrsPatches[key] = value
    }
  }

  if (count === 0) {
    return null
  }

  return attrsPatches
}

// Setting node unique identity
let key_id = 0
// diff with children
function diffChildren (oldChildren, newChildren, index, patches, currentPatch) {
  let diffs = listDiff(oldChildren, newChildren, 'key')
  newChildren = diffs.children

  if (diffs.moves.length) {
    let reorderPatch = { type: REORDER, moves: diffs.moves }
    currentPatch.push(reorderPatch)
  }

  // Store the identity of the current node with an initialization value of 0
  let currentNodeIndex = index

  oldChildren.forEach((child, i) => {
    key_id++
    let newChild = newChildren[i]
    currentNodeIndex = key_id

    // Recursive Continuous Comparison
    walk(child, newChild, currentNodeIndex, patches)
  })
}

module.exports = diff

If you think it's boring to see only the patch object but not the operation of re-rendering the page after parsing the patch, you can skip the list diff chapter and follow the patch method directly to implement that chapter. It may be more exciting. I also hope that my little friends can reach a consensus with me (because I seem to have done the same thing myself).

2. listDiff implements O (m*n) => O (max (m, n))

First of all, we have to make clear why we need the existence of list diff algorithm, what is one thing that list diff does, and then how it does such a thing.

For example, I have two Element objects, new and old, respectively.

let oldTree = el('ul', { id: 'list' }, [
  el('li', { class: 'item1' }, ['Item 1']),
  el('li', { class: 'item2' }, ['Item 2']),
  el('li', { class: 'item3' }, ['Item 3'])
])
let newTree = el('ul', { id: 'list' }, [
  el('li', { class: 'item3' }, ['Item 3']),
  el('li', { class: 'item1' }, ['Item 1']),
  el('li', { class: 'item2' }, ['Item 2'])
])

If we want to compare diff, we can directly use the above method to compare, but we can see that only one node move has been made here. If we compare the above diff directly and render the patch object analytically by the later patch method, we will need to operate three DOM nodes to complete the final update of the view.

Of course, if there are only three nodes, that's good. Our browser can eat and eat, and we can't see any difference in performance. Then the problem arises. If there are N multi-nodes, and these nodes only do a small part of remove, insert, move operations, then if we still re-render DOM according to one-to-one corresponding DOM operations, is it not too expensive to operate?

Therefore, the list diff arithmetic is derived, which is responsible for collecting remove, insert, move operations. Of course, for this operation, we need to declare a DOM attribute in the attrs of the node in advance to indicate the uniqueness of the node. In addition, the previous chart illustrates the time complexity of list diff, so the little buddies can look at it.

OK, let's give a concrete example to illustrate how list diff works. The code is as follows

let oldTree = el('ul', { id: 'list' }, [
  el('li', { key: 1 }, ['Item 1']),
  el('li', {}, ['Item']),
  el('li', { key: 2 }, ['Item 2']),
  el('li', { key: 3 }, ['Item 3'])
])
let newTree = el('ul', { id: 'list' }, [
  el('li', { key: 3 }, ['Item 3']),
  el('li', { key: 1 }, ['Item 1']),
  el('li', {}, ['Item']),
  el('li', { key: 4 }, ['Item 4'])
])

For the comparison of the differences between the old and new nodes in the above example, if I say to show the code directly to the small partners to understand the process of node operation, it is estimated that everyone will say that I am a hooligan. So I sorted out a flow chart to explain how list diff calculates node differences, as follows

Let's look at the pictures and talk. What Listdiff does is very simple and straightforward.

  • In the first step, newChildren operates close to the old Children form (mobile operation, which is done by traversing the old Children directly in the code) and gets simulateChildren = [key1, no key, null, key3]
    Step 1. Old Children's first element key1 corresponds to the second element in new Children
    Step 2. Old Children's second element has no key corresponding to the third element in new Children
    Step 3. Old Children's third element, key2, is not found in newChildren and is set directly to null.
    Step 4. oldChildren's fourth element key3 corresponds to the first element in newChildren
  • The second step is to process the simulateChildren slightly and add null elements and new elements in the new Children to get simulateChildren = [key1, no key, key3, key4]
  • The third step is to approach the simulate Children in the form of new Children and record all the mobile operations here. So at last we get an array of move s in the figure above, which stores the operations of all the mobile classes of the nodes.

OK, the whole process is clear, and the next thing will be much simpler. All we need to do is code the things listed above. (Note: I would like to implement it step by step, but there are a lot of things involved in each step. I'd like to write all the code in list diff and paste it with comments, for fear that there will be too much code posted at that time.)

/**
 * Diff two list in O(N).
 * @param {Array} oldList - Original list
 * @param {Array} newList - A new list of operations
 * @return {Object} - {moves: <Array>}
 *                  - moves list Collection of operation records
 */
function diff (oldList, newList, key) {
  let oldMap = getKeyIndexAndFree(oldList, key)
  let newMap = getKeyIndexAndFree(newList, key)

  let newFree = newMap.free

  let oldKeyIndex = oldMap.keyIndex
  let newKeyIndex = newMap.keyIndex
  // Record all move operations
  let moves = []

  // a simulate list
  let children = []
  let i = 0
  let item
  let itemKey
  let freeIndex = 0

  // newList operates close to the form of oldList
  while (i < oldList.length) {
    item = oldList[i]
    itemKey = getItemKey(item, key)
    if (itemKey) {
      if (!newKeyIndex.hasOwnProperty(itemKey)) {
        children.push(null)
      } else {
        let newItemIndex = newKeyIndex[itemKey]
        children.push(newList[newItemIndex])
      }
    } else {
      let freeItem = newFree[freeIndex++]
      children.push(freeItem || null)
    }
    i++
  }
  let simulateList = children.slice(0)

  // Remove some non-existent elements from the list
  i = 0
  while (i < simulateList.length) {
    if (simulateList[i] === null) {
      remove(i)
      removeSimulate(i)
    } else {
      i++
    }
  }
  // i  => new list
  // j  => simulateList
  let j = i = 0
  while (i < newList.length) {
    item = newList[i]
    itemKey = getItemKey(item, key)

    let simulateItem = simulateList[j]
    let simulateItemKey = getItemKey(simulateItem, key)

    if (simulateItem) {
      if (itemKey === simulateItemKey) {
        j++
      }
      else {
        // If removing the current simulateItem allows the item to be in the right place, remove it directly
        let nextItemKey = getItemKey(simulateList[j + 1], key)
        if (nextItemKey === itemKey) {
          remove(i)
          removeSimulate(j)
          j++ // After removal, the current value of j is correct, adding itself directly into the next loop
        } else {
          // Otherwise insert the item directly
          insert(i, item)
        }
      }
    // If it's a new item, execute inesrt directly
    } else {
      insert(i, item)
    }
    i++
  }
  // if j is not remove to the end, remove all the rest item
  // let k = 0;
  // while (j++ < simulateList.length) {
  //   remove(k + i);
  //   k++;
  // }

  // Record remote operations
  function remove (index) {
    let move = {index: index, type: 0}
    moves.push(move)
  }
  // Record insert operations
  function insert (index, item) {
    let move = {index: index, item: item, type: 1}
    moves.push(move)
  }
  // remove the element removing the node in the simulateList corresponding to the actual list
  function removeSimulate (index) {
    simulateList.splice(index, 1)
  }
  // Returns all operation records
  return {
    moves: moves,
    children: children
  }
}
/**
 * The list is transformed into a key-item keyIndex object for presentation.
 * @param {Array} list
 * @param {String|Function} key
 */
function getKeyIndexAndFree (list, key) {
  let keyIndex = {}
  let free = []
  for (let i = 0, len = list.length; i < len; i++) {
    let item = list[i]
    let itemKey = getItemKey(item, key)
    if (itemKey) {
      keyIndex[itemKey] = i
    } else {
      free.push(item)
    }
  }

  // Return key-item keyIndex
  return {
    keyIndex: keyIndex,
    free: free
  }
}

function getItemKey (item, key) {
  if (!item || !key) return void 0
  return typeof key === 'string'
    ? item[key]
    : key(item)
}

module.exports = diff

4. Implementing patch and parsing patch objects

I believe that there are still many small partners who will jump directly from the previous chapters in order to see the re-rendering of the page after diff.

If you come after you have carefully looked at diff's comparison with hierarchical elements, the operation here is actually quite simple. Because he is basically in line with the previous idea of operation, which is to traverse Element and give it a unique identifier, then here it is parsed along the unique key value provided by the patch object. Give you some deep traversal code directly

function patch (rootNode, patches) {
  let walker = { index: 0 }
  walk(rootNode, walker, patches)
}

function walk (node, walker, patches) {
  let currentPatches = patches[walker.index] // Extract the difference of the current node from patches

  let len = node.childNodes
    ? node.childNodes.length
    : 0
  for (let i = 0; i < len; i++) { // Depth traversal subnode
    let child = node.childNodes[i]
    walker.index++
    walk(child, walker, patches)
  }

  if (currentPatches) {
    dealPatches(node, currentPatches)  // DOM operation on the current node
  }
}

History has always been strikingly similar, and now little buddies should know the benefits of deep traversal before adding a unique identifier to each Element node. OK, next we operate on the current node according to different types of differences

function dealPatches (node, currentPatches) {
  currentPatches.forEach(currentPatch => {
    switch (currentPatch.type) {
      case REPLACE:
        let newNode = (typeof currentPatch.node === 'string')
          ? document.createTextNode(currentPatch.node)
          : currentPatch.node.render()
        node.parentNode.replaceChild(newNode, node)
        break
      case REORDER:
        reorderChildren(node, currentPatch.moves)
        break
      case ATTRS:
        setProps(node, currentPatch.props)
        break
      case TEXT:
        if (node.textContent) {
          node.textContent = currentPatch.content
        } else {
          // for ie
          node.nodeValue = currentPatch.content
        }
        break
      default:
        throw new Error('Unknown patch type ' + currentPatch.type)
    }
  })
}

Specific setAttrs and reorder s are implemented as follows

function setAttrs (node, props) {
  for (let key in props) {
    if (props[key] === void 0) {
      node.removeAttribute(key)
    } else {
      let value = props[key]
      _.setAttr(node, key, value)
    }
  }
}
function reorderChildren (node, moves) {
  let staticNodeList = _.toArray(node.childNodes)
  let maps = {} // Storing nodes with key special fields

  staticNodeList.forEach(node => {
    // If the current node is ElementNode, the node containing the key field is stored through maps
    if (_.isElementNode(node)) {
      let key = node.getAttribute('key')
      if (key) {
        maps[key] = node
      }
    }
  })

  moves.forEach(move => {
    let index = move.index
    if (move.type === 0) { // remove item
      if (staticNodeList[index] === node.childNodes[index]) { // maybe have been removed for inserting
        node.removeChild(node.childNodes[index])
      }
      staticNodeList.splice(index, 1)
    } else if (move.type === 1) { // insert item
      let insertNode = maps[move.item.key]
        ? maps[move.item.key] // reuse old item
        : (typeof move.item === 'object')
            ? move.item.render()
            : document.createTextNode(move.item)
      staticNodeList.splice(index, 0, insertNode)
      node.insertBefore(insertNode, node.childNodes[index] || null)
    }
  })
}

At this point, our patch method has been implemented, and virtual DOM & diff has been completed. Finally, we can breathe a sigh of relief. I can see the little friends here and give you a big compliment.

summary

This paper begins with Element simulating DOM nodes, and then restores Element to real DOM nodes by render method. Then, by completing the diff algorithm, we compare the differences between the old and the new Elements and record them in the patch object. Finally, the patch method is completed, and the patch object is parsed to complete the update of DOM.

Note: My level is limited, and I have learned so much. If you find any imprecise places, you are welcome to point out that, of course, you can give a compliment (collection is not praised by hooligans)!

All of the above code is in my github overwrite project.

Full code portal

github-https://github.com/xuqiang521/overwrite
Code cloud- http://git.oschina.net/qiangdada_129/overwrite

If you like overwrite, you can star t a wave. What are you doing? The homepage of the project is introduced.

Finally, let's give a famous quote to our little companion.

Posted by rpieszak on Fri, 21 Jun 2019 16:03:35 -0700