是什么
Virtual DOM 是对 DOM 的一种抽象表示,通常只是一个普通的 JavaScript 对象,对象里包含能够描述真实 DOM 节点的属性,最基本的有 tagName
、attributes
和 children
。
举例,有如下 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,一个很简单的实现,大家有兴趣可以去看看 snabbdom 和 React diff 算法 。