做個簡易 Virtual DOM

以前看了別的人教學影片之後沒有很懂也沒有實際寫寫看,趁機來厄補一下

所以 Virtual DOM 是啥

  • 用 JS Object 去代表 DOM Node

  • 資料更新的時候,去比對新的跟舊的 virtual dom

  • 把整個 tree 裡面有變化的更新到真實 DOM 上面

舉個例子

const virtualNode = {
  tagName: 'div',
  attributes: {
    id: 'unique',
    className: [
      'square',
      'yellow',
      'bounce',
    ],
    dataVariant: 'box'
  },
  children: [
    '一個很讚的標題'
  ]
}

Virtual DOM 的運作流程

  1. create element —— 把 DOM 屬性做成 object (v-node)

  2. render —— 把 v-node 轉成真的 HTML Element

  3. mount —— 把這個 HTML Element 更新到 DOM 上

  4. diff —— 資料更新的時候,進行比對,然後做1、2類似的動作

  5. patch —— 更新變動過的 HTML Element

跟 React 的 API 不一樣唷

  1. create element —— 把 DOM 屬性做成 object (v-node)

  2. render —— 把 v-node 轉成真的 HTML Element

  3. mount —— 把這個 HTML Element 更新到 DOM 上
    (useEffect 觸發時間)

  4. diff —— 資料更新的時候,進行比對,然後做1、2類似的動作

  5. patch —— 更新變動過的 HTML Element
    (useEffect 觸發時間)

createElement()

把 DOM 屬性做成 object (v-node)

重點就是三個:
1. 是什麼 HTML 元素
2. 有什麼屬性
3. children 是誰

export default function createElement(
  tagName, 
  options
 ) => {
  return {
    tagName,
    attrs: options.attrs,
    children: options.children
  }
 }

render()

把 v-node 轉成真的 HTML Element

  • 主要當然是使用 document.createElement,然後把 v-node 上面的屬性跟children 掛上去
  • attr 用 setAttributes
  • children 用 appendChild,但因為 child 也會是 v-node,所以他們也要 render
    React 有分好幾種 children,這裡為了簡化只分成兩種:text node & element。
export default function render(vNode) {
  // if the node type is string, call create text node
  if (typeof vNode === 'string') {
    return document.createTextNode(vNode);
  }

  // create actual element
  const $el = document.createElement(vNode.tagName);

  // append attributes
  const attrs = Object.entries(vNode.attrs);

  attrs.forEach(([key, value]) => {
    $el.setAttribute(key, value)
  })

  // append childs
  const { children } = vNode;
  
  if (children && children.length) {
    children.forEach((childVNode) => {
      const $child = render(childVNode);

      $el.appendChild($child);
    })
  }
  
  return $el
 }

mount()

把 HTML Element 更新到 DOM 上

沒什麼好說的...

export default function mount($node, $target) {
  $target.appendChild($node); // or replaceWith()

  return $node
}

diff() & patch()

資料更新的時候,進行比對,將更新過後的東西送到 HTML 中

參考教學的話,最基本的比較方式是:

  1. 如果 tag name 不一樣的話,整個 v-node 換成新的

  2. 如果一樣,才去比對 attributes 跟 children

  3. children 也進行 diff

  4. 最後回傳一個 patch function,call 了之後就只會更新有變化的節點

diff 回傳 patch

export default diff(vOldNode, vNewNode) {
  
  // diff attributes and children
  const patchAttrs = diffAttr(vOldNode.attrs, vNewNode.attrs);
  const patchChildren = diffChildren(vOldNode.children, vNewNode.children);
  
  // return patch
  return ($node) => {
	patchAttrs($node);
    patchChildren($node);
  }
}

tag name 不一樣的話直接替換

export default function diff(vOldNode, vNewNode) {
  // tag name 不一樣的話,就可以直接替換
  if (vOldNode.tagName !== vNewNode.tagName) {
    return ($node) => {
      const $newNode = render(vNewNode);

      $node.replaceWith($newNode);

      return $newNode
    }
  }

  const patchAttrs = diffAttr(vOldNode.attrs, vNewNode.attrs);
  const patchChildren = diffChildren(vOldNode.children, vNewNode.children);

  return ($node) => {
    patchAttrs($node);
    patchChildren($node);
  }
}

處理 text node 跟 empty 的狀況

export default function diff(vOldNode, vNewNode) {
  // 沒有 new node 的話可以直接移除
  if (!vNewNode) {
    return ($node) => {
      $node.remove();

      return undefined
    }
  }

  // 其中一個是 text node 的情況
  if (typeof vOldNode === 'string' || typeof vNewNode === 'string') {
    if (vOldNode !== vNewNode) {
      return ($node) => {
        const $newNode = render(vNewNode);

        $node.replaceWith($newNode);

        return $newNode
      }
    } else {
      return ($node) => undefined
    }
  }

  // tag name 不一樣的話,就可以直接替換
  if (vOldNode.tagName !== vNewNode.tagName) {
    return ($node) => {
      const $newNode = render(vNewNode);

      $node.replaceWith($newNode);

      return $newNode
    }
  }

  const patchAttrs = diffAttr(vOldNode.attrs, vNewNode.attrs);
  const patchChildren = diffChildren(vOldNode.children, vNewNode.children);

  return ($node) => {
    patchAttrs($node);
    patchChildren($node);
  }
}

diffAttr:

  • 把新舊 attributes 轉成 array,新的全部掛上去
  • 舊的屬性如果不在新的 attr 裡面,就移除這個屬性
  • 使用 set / removeAttributes
function diffAttr(vOldAttrs, vNewAttrs) {
  const vOldAttrEntries = Object.entries(vOldAttrs);
  const vNewAttrEntries = Object.entries(vNewAttrs);

  const patches = [];

  vNewAttrEntries.forEach(([key, value]) => {
    const patch = ($node) => {
      $node.setAttribute(key, value)

      return $node
    }

    patches.push(patch)
  })

  vOldAttrEntries.forEach(([key, value]) => {
    if (!(key in vNewAttrs)) {
      const patch = ($node) => {
        $node.removeAttribute(key)
  
        return $node
      }
  
      patches.push(patch)
    }
  })

  return ($node) => {
    patches.forEach((patch) => {
      patch($node)
    })
  }
}

diffChildren:

  • 新舊 children diff 一遍得到 patches
  • 完全新的直接掛上去
function diffChildren(vOldChildren, vNewChildren) {
  const patches = [];
  const newNodePatches = [];
  const zippedChildren = zip(vOldChildren, vNewChildren);

  zippedChildren.forEach(([vOldChild, vNewChild]) => {
    if (!vOldChild) {
      const vNewChildNode = render(vNewChild);

      const patch = ($node) => {
        $node.appendChild(vNewChildNode)

        return $node
      }

      newNodePatches.push(patch);
    }

    const childrenPatch = diff(vOldChild, vNewChild);

    patches.push(childrenPatch)
  })

  return ($parentNode) => {
    const zippedPairs = zip(patches, $parentNode.childNodes);

    zippedPairs.forEach(([patch, childNode]) => {
      patch(childNode)
    })

    newNodePatches.forEach((patch) => {
      patch($parentNode)
    })

    return $parentNode
  }
}

diffChildren:

  • 新舊 children diff 一遍得到 patches
  • 完全新的直接掛上去
function diffChildren(vOldChildren, vNewChildren) {
  const patches = [];
  const newNodePatches = [];
  const zippedChildren = zip(vOldChildren, vNewChildren);

  zippedChildren.forEach(([vOldChild, vNewChild]) => {
    if (!vOldChild) {
      const vNewChildNode = render(vNewChild);

      const patch = ($node) => {
        $node.appendChild(vNewChildNode)

        return $node
      }

      newNodePatches.push(patch);
    }

    const childrenPatch = diff(vOldChild, vNewChild);

    patches.push(childrenPatch)
  })

  return ($parentNode) => {
    const zippedPairs = zip(patches, $parentNode.childNodes);

    zippedPairs.forEach(([patch, childNode]) => {
      patch(childNode)
    })

    newNodePatches.forEach((patch) => {
      patch($parentNode)
    })

    return $parentNode
  }
}

參考:

  • 這篇幫我破解了 DOM 效能的迷思
    https://blog.techbridge.cc/2019/02/04/vdom-from-scratch/

  • 很詳盡的介紹,有點歷史跟很多線索可以深究 react
    https://www.accelebrate.com/blog/the-real-benefits-of-the-virtual-dom-in-react-js/

  • 第一個參考文章作者參考的影片。看他實作真的很棒,雖然此影片聲音處理不佳。主要照著這個寫的
    https://www.youtube.com/watch?v=85gJMUEcnkc&feature=youtu.be

end.

做個簡易 Virtual DOM

By Kai Ting Liu

做個簡易 Virtual DOM

  • 275