浅析 Virtual DOM

733 阅读4分钟

是什么

Virtual DOM 是对 DOM 的一种抽象表示,通常只是一个普通的 JavaScript 对象,对象里包含能够描述真实 DOM 节点的属性,最基本的有 tagNameattributeschildren

举例,有如下 DOM 结构:

<ul class="list">
  <li>item1</li>
  <li>item2</li>
</ul>

可以用 VDOM 对象表示:

{
  tagName: 'ul',
  attributes: {
    class: 'list'
  },
  children: [
    {
	tagName: 'li',
	attributes: {},
	children: ['item1']
    },
    {
	tagName: 'li',
	attributes: {},
	children: ['item2']
    }
  ]
}

为什么需要

由于 VDOM 只是一个节点的抽象表示,我们可以借助不同的渲染函数,实现跨平台。

另外,目前的主流框架,如 Vue 和 React,都是数据驱动视图渲染,即状态总是和页面UI保持同步的。

UI = render(state)

由于操作 DOM 成本比较高,借助 VDOM,我们可以把 state 多次的变更后生成的 VDOM 和原始 VDOM 在内存中只做一次比较,然后对真实 DOM 做最小增量更新。这样对状态复杂的页面渲染,有很大性能提升。

简单实现

定义 VDOM

首先,定义一个 VNode 构造函数,用它的实例来表示DOM节点。

function VNode(type, props, children) {
  this.type = type
  this.props = props
  this.children = children
}

生成 VDOM

当我们使用 JSX 语法时,可以使用 Babel 和 @babel/plugin-transform-react-jsx 插件做转译。

import React from 'react'

const List = (
  <ul class='list'>
    <li>item1</li>
    <li>item2</li>
    <li>item3</li>
  </ul>
)

// 上面的代码 List 会被转译成:

const List = React.createElement('ul', { className: 'list' },
  React.createElement('li', {}, 'item1'),
  React.createElement('li', {}, 'item2'),
  React.createElement('li', {}, 'item3'),
)

console.log(List)

React.createElement 函数在调用后会返回一个 VDOM 对象。

现在我们想要实现自己的 VDOM,所以需要重新实现一个和 React.createElement 有类似功能的函数 h

/** @jsx h */

function VNode(type, props, children) {
  this.type = type
  this.props = props
  this.children = children
}

function h(type, props, ...children) {
  return new VNode(type, props || {}, children)
}

const List = (
  <ul class="list">
    <li>item1</li>
    <li>item2</li>
    <li>item3</li>
  </ul>
)

// 这次,上面的代码 List 会被转译成:

const List = h('ul', { className: 'list' },
  h('li', {}, 'item1'),
  h('li', {}, 'item2'),
  h('li', {}, 'item3'),
)

console.log(List)

VDOM 渲染

现在已经能够通过 VDOM 对象,来表示一颗 DOM 树了,但现在它只是一个普通的 JavaScript 对象,我们需要把它渲染为真实的 DOM。

function createElement(vnode) {
  if (!(vnode instanceof VNode)) {
    return document.createTextNode(vnode)
  }
  const el = document.createElement(vnode.type)

  // 设置属性,待补充

  // 添加子节点
  const fragment = document.createDocumentFragment()
  flatten(vnode.children).forEach(child => {
    fragment.appendChild(createElement(child))
  })
  el.appendChild(fragment)
  // 返回真实 dom 元素
  return el

createElement 函数做的事,就是将 VDOM 转换为真实 DOM,但是这里我们漏掉了对属性值的处理。我们可以实现一个 setProps 函数来补上这个功能。

function setProp(el, propName, propValue) {
  const lower = str => str.toLowerCase()
  const isEventProp = p => p.slice(0, 2) === 'on'

  if (isEventProp(propName)) {
    el.addEventListener(lower(propName.slice(2)), propValue)
  } else {
    typeof propValue !== 'undefined' && el.setAttribute(propName, propValue)
  }
}

function setProps(el, props) {
  Object.keys(props).forEach(propName => setProp(el, propName, props[propName]))
}

到此,我们已经实现了 VDOM 的 定义 => 创建 => 渲染 三个阶段。但这只是基础功能,页面没有状态,也没有状态变更。

VDOM 更新

首先,我们将 List 改写为函数

const List = props => (
  <ul class='list'>
    <li class='list--item'>item1</li>
    <li style='color: blue;'>item2</li>
    <li onClick={e => window.alert(e.target.innerHTML)}>item3(click me)</li>
    {props.items.map(index => (
      <li>item{index}</li>
    ))}
  </ul>
)

createElement(List({ items: [4, 5] }))

状态变更后,需要对比新旧 VDOM 的差异,并应用到真实 DOM 上。 下面是一个简单版本的实现

function updateDom(el, newVNode, oldVNode, index = 0) {
  if (!oldVNode) {
    el.appendChild(createElement(newVNode))
  } else if (!newVNode) {
    el.removeChild(el.childNodes[index])
  } else if (isDifferent(newVNode, oldVNode)) {
    el.replaceChild(createElement(newVNode), el.childNodes[index])
  } else if (newVNode instanceof VNode) 
    // 两个节点都是类型相同的 VNode
    // 对比、更新属性值

    // 递归对比他们的子节点
  }
}

function isDifferent(newVNode, oldVNode) {
  return (
    // 节点类型不同
    typeof newVNode !== typeof oldVNode ||
    // 节点类型相同,且都不是 VNode(是 number 和 string),且值不相同
    (!(newVNode instanceof VNode) && newVNode !== oldVNode) ||
    // 两个节点标签类型不同
    newVNode.type !== oldVNode.type
  )
}

对比子节点

// ...
else if (newVNode instanceof VNode) {
    // 两个节点都是类型相同的 VNode
    // 对比、更新属性值

    // 递归对比他们的子节点
    const newVNodeChildren = flatten(newVNode.children)
    const oldVNodeChildren = flatten(oldVNode.children)
    const newLength = newVNodeChildren.length
    const oldLength = oldVNodeChildren.length

    let i = Math.max(newLength, oldLength) - 1
    while (i >= 0) {
      updateDom(
        el.childNodes[index],
        newVNodeChildren[i],
        oldVNodeChildren[i],
        i
      )
      i--
    }
  }
// ...

对比属性值

else if (newVNode instanceof VNode) {
    // 两个节点都是类型相同的 VNode
    // 对比、更新属性值
    updateProps(el.childNodes[index], newVNode.props, oldVNode.props)
    // ...
}

function updateProps(el, newProps, oldProps) {
  const props = Object.assign({}, newProps, oldProps)
  Object.keys(props).forEach(propName => {
    const newVal = newProps[propName]
    const oldVal = oldProps[propName]

    if (!newVal) {
      el.removeAttribute(propName)
    } else if (typeof newVal === 'function') {
      // diff 绑定事件,待实现
    } else if (!oldVal || newVal !== oldVal) {
      setProp(el, propName, newVal)
    }
  })
}

完整示例代码在这里

总结

上面只是为了帮助我们理解 Virtual DOM,一个很简单的实现,大家有兴趣可以去看看 snabbdomReact diff 算法