基本概念和实现
什么是虚拟DOM(what)
用数据结构表示DOM结构,没有挂载到DOM上,被称为“虚拟DOM"。
// DOM结构
<ul id="chapterList">
<li class="chapter">chapter1</li>
<li class="chapter">chapter2</li>
<li class="chapter">chapter13</li>
</ul>
// javascript对象结构形式表示
const chapterListVirtualDom = {
tagName: 'ul',
attributes: {
id: 'chapterList'
},
children: [
{ tagName: 'li', attributes: { class: 'chapter' }, children: ['chapter1'] }
{ tagName: 'li', attributes: { class: 'chapter' }, children: ['chapter2'] },
{ tagName: 'li', attributes: { class: 'chapter' }, children: ['chapter3'] }
]
}
为什么要使用虚拟DOM(why)
操作数据结构远比通过和浏览器交互操作DOM快。
操作数据结构是指改变对象(虚拟DOM),这个过程比修改真是DOM快很多。但是虚拟DOM最终也是要挂载到浏览器上。
怎么实现虚拟DOM(how)
下面我们来实现一个虚拟DOM生成类,用于生产虚拟DOM。
class Element {
constructor (tagName, attributes = {}, children = []) {
this.tagName = tagName
this.attributes = attributes
this.children = children
}
}
function element (tagName, attributes, children) {
return new Element(tagName, attributes, children)
}
const chapterListVirtualDom = element('ul', { id: 'list' }, [
element('li', { class: 'chapter' }, 'chapter1'),
element('li', { class: 'chapter' }, 'chapter2'),
element('li', { class: 'chapter' }, 'chapter3')
])
现在我们来将虚拟DOM生成真是DOM节点。首先实现一个setAttributes方法来对DOM节点进行属性设置:
const setAttributes = (node, key, value) => {
switch (key) {
case 'style':
node.style.cssText = value
break
case 'value':
let tagName = node.tagName || ''
tagName = tagName.toLowerCase()
if (tagName === 'input' || tagName === 'textarea') {
node.value = value
} else { // 如果节点不是input或textarea, 则使用setAttributes设置属性
node.setAttributes(key, value)
}
break
default:
node.setAttributes(key, value)
break
}
}
然后在Element类中加入render原型方法:根据虚拟DOM生成真实DOM片段。
class Element {
constructor (tagName, attributes = {}, children = []) {
this.tagName = tagName
this.attributes = attributes
this.children = children
}
render () {
let element = document.createElement(this.tagName)
let attributes = this.attributes
for (let key in attributes) {
setAttributes(element, key, attributes[key])
}
let children = this.children
children.forEach(child => {
let childElement = child instanceof Element
? child.render()
: document.createTextNode(child)
element.appendChild(childElement)
})
}
}
有了真实的DOM节点片段,我们就可以将真实的DOM节点渲染到浏览器上了,这里我们来实现一个renderDom方法。
const renderDom = (element, target) => {
target.appendChild(element)
}
到这里,我们实现了生成DOM元素的必备方法集合。完整代码如下:
const setAttributes = (node, key, value) => {
switch (key) {
case 'style':
node.style.cssText = value
break
case 'value':
let tagName = node.tagName || ''
tagName = tagName.toLowerCase()
if (tagName === 'input' || tagName === 'textarea') {
node.value = value
} else {
node.setAttributes(key, value)
}
break
default:
node.setAttributes(key, value)
break
}
}
class Element {
constructor (tagName, attributes, children) {
this.tagName = tagName
this.attributes = attributes
this.children = children
}
render () {
let element = document.createElement(this.tagName)
let attributes = this.attributes
for (let key in attributes) {
setAttributes(element, key, attributes[key])
}
let children = this.children
children.forEach(child => {
let childElement = child instanceof Element
? child.render()
: document.createTextNode(child)
element.appendChild(childElement)
})
return element
}
}
function element (tagName, attributes, children) {
return new Element(tagName, attributes, children)
}
const renderDom = (element, target) => {
target.appendChild(element)
}
const chapterListVirtualDom = element('ul', { id: 'list' }, [
element('li', { class: 'chapter' }, 'chapter1'),
element('li', { class: 'chapter' }, 'chapter2'),
element('li', { class: 'chapter' }, 'chapter3')
])
const dom = chapterListVirtualDom.render()
renderDom(dom, document.body)
虚拟DOM diff
用户进行特定操作后,会产出一份新的虚拟DOM,如何得出前后两份虚拟DOM的差异,这里就涉及到DOM diff的内容了。
因为虚拟DOM是个树形结构,所以我们需要对两份虚拟DOM进行递归比较,并将变化存储到patches中,代码如下:
const diff = (oldVirtualDom, newVirtualDom) => {
let patches = {} // 递归树,将比较后的结果存储到patches中
walkToDiff(oldVirtualDom, newVirtualDom, 0, patches)
return patches
}
walkToDiff中的前两个参数是需要比较的虚拟DOM对象,第三个参数用来记录nodeIndex,在删除节点时会使用,初始值为0,第四个参数是一个闭包变量,用来记录diff的结果。
walkToDiff代码实现:
let initialIndex = 0
const walkToDiff = (oldVirtualDom, newVirtualDom, index, patches) => {
let diffResult = []
// 如果newVirtualDom不存在 则说明该节点已经被移除,接着可以将type为REMOVE的对象推进diffResult,并记录index
if (!newVirtualDom) {
diffResult.push({
type: 'REMOVE',
index
})
} else if (typeof oldVirtualDom === 'string' && typeof newVirtualDom === 'string') { // 如果新旧节点都是文本节点
// 比较文本中的内容是否相同,如果不同则记录新的结果
if (oldVirtualDom !== newVirtualDom) {
diffResult.push({
type: 'MODIFY_TEXT',
data: newVirtualDom,
index
})
}
} else if (oldVirtualDom.tagName === newVirtualDom.tagName) { // 如果新旧节点类型相同
// 比较属性是否相同
let diffAttributeResult = {}
let oldAttributes = oldVirtualDom.attributes
let newAttributes = newVirtualDom.attributes
for (let key in oldAttributes) {
if (oldAttributes[key] !== newAttributes[key]) {
diffAttributeResult[key] = newAttributes[key]
}
}
for (let key in newAttributes) {
// 旧节点不存在的新属性
if (!oldAttributes.hasOwnProperty(key)) {
diffAttributeResult[key] = newAttributes[key]
}
}
if (Object.keys(diffAttributeResult).length > 0) {
diffResult.push({
type: 'MODIFY_ATTRIBUTES',
diffAttributeResult
})
}
// 如果有子节点 则遍历子节点
oldVirtualDom.children.forEach((child, index) => {
walkToDiff(child, newVirtualDom.children[index], ++initialIndex, patches)
})
} else {
// 如果节点类型不同 已经被直接替换了 则直接将新的结果放入diffResult数组中
diffResult.push({
type: 'REPLACE',
newVirtualDom
})
}
if (!oldVirtualDom) {
diffResult.push({
type: 'REPLACE',
newVirtualDom
})
}
if (diffResult.length) {
patches[index] = diffResult
}
}
我们测试下diff函数,代码如下:
const chapterListVirtualDom = element('ul', { id: 'list' }, [
element('li', { class: 'chapter' }, ['chapter1']),
element('li', { class: 'chapter' }, ['chapter2']),
element('li', { class: 'chapter' }, ['chapter3'])
])
const chapterListVirtualDom1 = element('ul', { id: 'list1' }, [
element('li', { class: 'chapter1' }, ['chapter4']),
element('li', { class: 'chapter1' }, ['chapter5']),
element('li', { class: 'chapter1' }, ['chapter6'])
])
diff(chapterListVirtualDom, chapterListVirtualDom1)
执行以上代码,结果如下:
最小化差异应用
到这里,我们来回顾下我们已经做了哪些事情:
- 通过Element class生成了虚拟DOM
- 通过diff方法对任意两个虚拟DOM进行比对 那么,如何将这个差异更新到现有的DOM节点中呢?下面我们接着来实现一个patch方法:
const patch = (node, patches) => {
let walker = { index: 0 }
walk(node, walker, patches)
}
patch方法第一个参数是一个真实的DOM节点(需要更新的DOM节点),第二个参数是一个最小化差异集合,该集合其实就是diff方法返回的结果,其内部调用了walk函数,代码如下:
const walk = (node, walker, patches) => {
let currentPatch = patches[walker.index]
let childNodes = node.childNodes
childNodes.forEach(child => {
walker.index++
walk(child, walker, patches)
})
if (currentPatch) {
doPatch(node, currentPatch)
}
}
walk函数会进行自身递归,对当前节点的差异调用doPatch方法进行更新:
const doPatch = (node, patches) => {
patches.forEach(patch => {
switch (patch.type) {
case 'MODIFY_ATTRIBUTES':
const attributes = patch.diffAttributeResult
for (let key in attributes) {
if (node.nodeType !== 1) {
return
}
const value = attributes[key]
if (value) {
setAttributes(node, key, value)
} else {
node.removeAttribute(key)
}
}
break
case 'MODIFY_TEXT':
node.textContent = patch.data
break
case 'REPLACE':
let newNode = patch.newVirtualDom instanceof Element
? patch.newVirtualDom.render()
: document.createTextNode(patch.newVirtualDom)
node.parentNode.replaceChild(newNode, node)
break
case 'REMOVE':
node.parentNode.removeChild(node)
break
default:
break
}
})
}
doPatch方法会对4种类型的diff进行处理,测试代码如下:
let element = chapterListVirtualDom.render()
renderDom(element, document.body)
const patches = diff(chapterListVirtualDom, chapterListVirtualDom1)
patch(element, patches)