文章目录
1. 为什么要使用虚拟DOM
先介绍浏览器加载一个HTML文件需要做哪些事,帮助我们理解我们为什么需要虚拟dom。
webkit引擎的处理流程:
所有浏览器的引擎工作流程都差不多,大致分为5步:创建DOM tree–> 创建Style Rules–> 构建Render tree --> 布局Layout --> 绘制Painting。
- 用HTML分析器,分析HTML元素,构建一颗DOM树。
- 用CSS分析器,分析CSS文件和元素上的inline样式,生成页面的样式表。
- 将上面的DOM树和样式表,关联起来,构建一颗Render树。这一过程又称为Attachment。每个DOM节点都有attach方法,接收样式信息,返回一个render对象(又名renderer)。这些render对象最终会被构建成一颗Render树。
- 有了Render树后,浏览器开始布局,会为每个Render树上的节点确定一个在显示屏上出现的精确坐标值。
- Render树有了,节点显示的位置坐标也有了,最后就是调用每个节点的paint方法,让它们显示出来。
当你用传统的原生api或者jQuery去操作DOM时,浏览器会从构建DOM树开始从头到尾执行一遍流程。
比如当你在一次操作时,需要更新10个DOM节点。
理想的状态是一次性构建完DOM树,再执行后续操作。
但浏览器没有如此智能,收到第一个更新DOM请求后,并不知道后续还有9次更新操作,因此会马上执行流程,最终执行10次流程。
显然例如计算DOM节点的坐标值等都是白白浪费性能,可能这次计算完,紧接着下一个DOM更新请求,节点值就会改变,前面的计算就是浪费。
即使计算机硬件一直在更新迭代,操作DOM的代价也是很贵的,频繁操作会出现页面卡顿,影响用户体验。
真实的DOM节点,即使只是一个简单的div也包含很多的属性:
虚拟DOM就是为了解决这个浏览器的性能问题而被设计出来的。
例如前面的例子,假如一次操作中有10次更新DOM的动作,虚拟DOM不会立即操作DOM,而是将这10次更新的diff内容保存到本地的一个js对象中,最终将这个js对象一次性attach到DOM树上,通知浏览器去执行绘制工作,这样可以避免大量的无谓的计算量。
2. Virtual DOM 算法
相对于 DOM 对象,原生的 JavaScript 对象处理起来更快,而且更简单。
DOM 树上的结构、属性信息我们都可以很容易地用 JavaScript 对象表示出来:
var element = {
tagName: 'ul', // 节点标签名
props: { // DOM的属性,用一个对象存储键值对
id: 'list'
},
children: [ // 该节点的子节点
{tagName: 'li', props: {class: 'item'}, children: ["Item 1"]},
{tagName: 'li', props: {class: 'item'}, children: ["Item 2"]},
{tagName: 'li', props: {class: 'item'}, children: ["Item 3"]},
]
}
对应HTML写法为:
<ul id='list'>
<li class='item'>Item 1</li>
<li class='item'>Item 2</li>
<li class='item'>Item 3</li>
</ul>
既然原来 DOM 树的信息都可以用 JavaScript 对象来表示。
反过来,你就可以根据这个用 JavaScript 对象表示的树结构来构建一棵真正的DOM树。
可以用新渲染的对象树去和旧的树进行对比,记录这两棵树差异。
记录下来的不同就是我们需要对页面真正的 DOM 操作,然后把它们应用在真正的 DOM 树上,页面就变更了。
这样就可以做到:视图的结构确实是整个全新渲染了,但是最后操作DOM的时候确实只变更有不同的地方。
Virtual DOM 算法:
- 用JavaScript对象结构表示DOM树的结构,然后用这个树构建一个真正的DOM树,插到文档当中。
- 当状态变更的时候,重新构造一颗新的对象树。用新的树和旧的树进行比较,记录两棵树的差异。
- 把步骤2所记录的差异应用到步骤1所构建的真实DOM树上,更新视图。
Virtual DOM 本质上就是在 JS 和 DOM 之间做了一个缓存。
可以类比 CPU 和硬盘,既然硬盘这么慢,我们就在它们之间加个缓存:既然 DOM 这么慢,我们就在它们 JS 和 DOM 之间加个缓存。
CPU(JS)只操作内存(Virtual DOM),最后的时候再把变更写入硬盘(DOM)。
3. 算法实现
3.1 步骤一:用js对象模拟DOM树
用JavaScript来表示一个DOM节点,记录它的节点类型、属性、子节点:
- element.js
function Element (tagName, props, children) {
this.tagName = tagName
this.props = props
this.children = children
}
module.exports = function (tagName, props, children) {
return new Element(tagName, props, children)
}
例如上面的DOM结构可以表示:
var el = require('./element');
var ul = el('ul',{id:'list'},[
el('li', {class: 'item'}, ['Item 1']),
el('li', {class: 'item'}, ['Item 2']),
el('li', {class: 'item'}, ['Item 3'])
])
现在ul只是一个JavaScript对象表示的DOM结构,页面上没有这个结构。
我们可以根据ul构建真正的< ul >:
- element.js 继续添加
Element.prototype.render = function() {
let el = document.createElement(this.tagName); //根据tagName构建
let props = this.props;
for(let propsName in props){
let propsValue = props[propsName];
el.seAttribute(propsName,propsValue);
}
var children = this.children || [];
children.forEach(function(child){
let childEl = (child instanceof Element)
? child.render() // 如果子节点也是虚拟DOM,递归构建DOM节点
: document.createTextNode(child); // 如果是字符串,只构建文本节点
el.appendChild(childEl)
})
return el;
}
render方法会根据tagName构建一个真正的DOM节点,然后设置节点的属性,最后递归构建自己的子节点。
let ulRoot = ul.render();
document.body.appendChild(ulRoot)
上面的ulRoot是真正的DOM节点,把它塞入文档中,这样body里面就有了真正的< ul >的DOM结构:
<ul id='list'>
<li class='item'>Item 1</li>
<li class='item'>Item 2</li>
<li class='item'>Item 3</li>
</ul>
3.2 比较两颗虚拟DOM树的差异
比较两颗DOM树的差异是Virtual DOM算法最核心的部分,也就是Virtual DOM的diff算法。
Virtual DOM 只会对同一个层级的元素进行对比:
上面的div只会和同一层级的div对比,第二层级的只会跟第二层级对比。这样算法复杂度就可以达到 O(n)。
3.2.1 深度优先遍历,记录差异
在实际的代码中,会对新旧两棵树进行一个深度优先的遍历,这样每个节点都会有一个唯一的标记:
在深度优先遍历的时候,每遍历到一个节点就把该节点和新的的树进行对比。如果有差异的话就记录到一个对象里面。
// diff函数,对比两棵树
function diff(oldTree,newTree){
let index = 0; // 当前节点的标志
let patches = {}; //记录每个节点的差异对象
dfsWalk(oldTree, newTree, index, patches);
return patches;
}
// 对两棵树进行深度优先遍历
function dfsWalk(oldNode,newNode,index,patches){
// 对比oldNode和newNode的不同,记录下来
patches[index] = [...]
diffChildren(oldNode.children, newNode.children, index, patches)
}
// 遍历子节点
function diffChildren(oldChildren, newChildren, index, patches){
let leftNode = null;
let currentNodeIndex = index;
oldChildren.forEach(function(child,i){
let newChild = newChildren[i];
currentNodeIndex = (leftNode && leftNode.count) // 计算节点的标识
? currentNodeIndex + leftNode.count + 1
: currentNodeIndex + 1
dfsWalk(child, newChild, currentNodeIndex, patches) // 深度遍历子节点
leftNode = child
});
}
例如,上面的div和新的div有差异,当前的标记是0,那么:
patches[0] = [{difference}, {difference}, ...] // 用数组存储新旧节点的不同
同理p是patches[1],ul是patches[3],类推。
3.2.2 差异类型
上面说的节点的差异指的是什么呢?
对 DOM 操作可能会:
- 替换掉原来的节点,例如把上面的div换成了section
- 移动、删除、新增子节点,例如上面div的子节点,把p和ul顺序互换
- 修改了节点的属性
- 对于文本节点,文本内容可能会改变。例如修改上面的文本节点2内容为Virtual DOM 2。
所以我们定义了几种差异类型:
var REPLACE = 0
var REORDER = 1
var PROPS = 2
var TEXT = 3
对于节点替换,很简单。判断新旧节点的tagName和是不是一样的,如果不一样的说明需要替换掉。如div换成section,就记录下:
patches[0] = [{
type: REPALCE,
node: newNode // el('section', props, children)
}]
如果给div新增了属性id为container,就记录下:
patches[0] = [{
type: REPALCE,
node: newNode // el('section', props, children)
}, {
type: PROPS,
props: {
id: "container"
}
}]
如果是文本节点,如上面的文本节点2,就记录下:
patches[2] = [{
type: TEXT,
content: "Virtual DOM2"
}]
那如果把我div的子节点重新排序呢?例如p, ul, div的顺序换成了div, p, ul。这个该怎么对比?
如果按照同层级进行顺序对比的话,它们都会被替换掉。如p和div的tagName不同,p会被div所替代。
最终,三个节点都会被替换,这样DOM开销就非常大。
而实际上是不需要替换节点,而只需要经过节点移动就可以达到,我们只需知道怎么进行移动。
3.2.3 列表对比算法
假设现在可以英文字母唯一地标识每一个子节点:
旧的节点顺序:a b c d e f g h i
现在对节点进行了删除、插入、移动的操作。新增j节点,删除e节点,移动h节点:
新的节点顺序:a b c h d f g i j
现在知道了新旧的顺序,求最小的插入、删除操作(移动可以看成是删除和插入操作的结合)。
这个问题抽象出来其实是字符串的最小编辑距离问题(Edition Distance),最常见的解决算法是 Levenshtein Distance,通过动态规划求解,时间复杂度为 O(M * N)。
但是我们并不需要真的达到最小的操作,我们只需要优化一些比较常见的移动情况,牺牲一定DOM操作,让算法时间复杂度达到线性的(O(max(M, N))。
但是要注意的是,因为tagName是可重复的,不能用这个来进行对比。
所以需要给子节点加上唯一标识key,列表对比的时候,使用key进行对比,这样才能复用老的 DOM 树上的节点。
这样,我们就可以通过深度优先遍历两棵树,每层的节点进行对比,记录下每个节点的差异了。
3.3 把差异应用到真正的DOM树上
因为步骤一所构建的 JavaScript 对象树和render出来真正的DOM树的信息、结构是一样的。
所以我们可以对那棵DOM树也进行深度优先的遍历,遍历的时候从步骤二生成的patches对象中找出当前遍历的节点差异,然后进行 DOM 操作。
function patch (node, patches) {
var walker = {index: 0}
dfsWalk(node, walker, patches)
}
function dfsWalk (node, walker, patches) {
var currentPatches = patches[walker.index] // 从patches拿出当前节点的差异
var len = node.childNodes
? node.childNodes.length
: 0
for (var i = 0; i < len; i++) { // 深度遍历子节点
var child = node.childNodes[i]
walker.index++
dfsWalk(child, walker, patches)
}
if (currentPatches) {
applyPatches(node, currentPatches) // 对当前节点进行DOM操作
}
}
applyPatches,根据不同类型的差异对当前节点进行 DOM 操作:
function applyPatches (node, currentPatches) {
currentPatches.forEach(function (currentPatch) {
switch (currentPatch.type) {
case REPLACE:
node.parentNode.replaceChild(currentPatch.node.render(), node)
break
case REORDER:
reorderChildren(node, currentPatch.moves)
break
case PROPS:
setProps(node, currentPatch.props)
break
case TEXT:
node.textContent = currentPatch.content
break
default:
throw new Error('Unknown patch type ' + currentPatch.type)
}
})
}
4. 完整代码
util.js
var _ = exports
_.type = function (obj) {
return Object.prototype.toString.call(obj).replace(/\[object\s|\]/g, '')
}
_.isArray = function isArray (list) {
return _.type(list) === 'Array'
}
_.slice = function slice (arrayLike, index) {
return Array.prototype.slice.call(arrayLike, index)
}
_.truthy = function truthy (value) {
return !!value
}
_.isString = function isString (list) {
return _.type(list) === 'String'
}
_.each = function each (array, fn) {
for (var i = 0, len = array.length; i < len; i++) {
fn(array[i], i)
}
}
_.toArray = function toArray (listLike) {
if (!listLike) {
return []
}
var list = []
for (var i = 0, len = listLike.length; i < len; i++) {
list.push(listLike[i])
}
return list
}
_.setAttr = function setAttr (node, key, value) {
switch (key) {
case 'style':
node.style.cssText = value
break
case 'value':
var tagName = node.tagName || ''
tagName = tagName.toLowerCase()
if (
tagName === 'input' || tagName === 'textarea'
) {
node.value = value
} else {
// if it is not a input or textarea, use `setAttribute` to set
node.setAttribute(key, value)
}
break
default:
node.setAttribute(key, value)
break
}
}
element.js:
var _ = require('./util');
/**
* Virtual-dom Element.
* @param {String} tagName
* @param {Object} props - Element's properties,
* - using object to store key-value pair
* @param {Array<Element|String>} - This element's children elements.
* - Can be Element instance or just a piece plain text.
*/
function Element(tagName,props,children) {
if(!(this instanceof Element)){
if (!_.isArray(children) && children != null) {
children = _.slice(arguments, 2).filter(_.truthy)
}
return new Element(tagName, props, children)
}
if (_.isArray(props)) {
children = props
props = {}
}
this.tagName = tagName
this.props = props || {}
this.children = children || []
this.key = props
? props.key
: void 666
var count = 0
_.each(this.children, function (child, i) {
if (child instanceof Element) {
count += child.count
} else {
children[i] = '' + child
}
count++
})
this.count = count
}
/**
* Render the hold element tree.
*/
Element.prototype.render = function () {
var el = document.createElement(this.tagName)
var props = this.props
for (var propName in props) {
var propValue = props[propName]
_.setAttr(el, propName, propValue)
}
_.each(this.children, function (child) {
var childEl = (child instanceof Element)
? child.render()
: document.createTextNode(child)
el.appendChild(childEl)
})
return el
}
module.exports = Element
diff.js:
var _ = require('./util')
var patch = require('./patch')
var listDiff = require('list-diff2')
function diff (oldTree, newTree) {
var index = 0
var patches = {}
dfsWalk(oldTree, newTree, index, patches)
return patches
}
function dfsWalk (oldNode, newNode, index, patches) {
var currentPatch = []
// Node is removed.
if (newNode === null) {
// Real DOM node will be removed when perform reordering, so has no needs to do anything in here
// TextNode content replacing
} else if (_.isString(oldNode) && _.isString(newNode)) {
if (newNode !== oldNode) {
currentPatch.push({ type: patch.TEXT, content: newNode })
}
// Nodes are the same, diff old node's props and children
} else if (
oldNode.tagName === newNode.tagName &&
oldNode.key === newNode.key
) {
// Diff props
var propsPatches = diffProps(oldNode, newNode)
if (propsPatches) {
currentPatch.push({ type: patch.PROPS, props: propsPatches })
}
// Diff children. If the node has a `ignore` property, do not diff children
if (!isIgnoreChildren(newNode)) {
diffChildren(
oldNode.children,
newNode.children,
index,
patches,
currentPatch
)
}
// Nodes are not the same, replace the old node with new node
} else {
currentPatch.push({ type: patch.REPLACE, node: newNode })
}
if (currentPatch.length) {
patches[index] = currentPatch
}
}
function diffChildren (oldChildren, newChildren, index, patches, currentPatch) {
var diffs = listDiff(oldChildren, newChildren, 'key')
newChildren = diffs.children
if (diffs.moves.length) {
var reorderPatch = { type: patch.REORDER, moves: diffs.moves }
currentPatch.push(reorderPatch)
}
var leftNode = null
var currentNodeIndex = index
_.each(oldChildren, function (child, i) {
var newChild = newChildren[i]
currentNodeIndex = (leftNode && leftNode.count)
? currentNodeIndex + leftNode.count + 1
: currentNodeIndex + 1
dfsWalk(child, newChild, currentNodeIndex, patches)
leftNode = child
})
}
function diffProps (oldNode, newNode) {
var count = 0
var oldProps = oldNode.props
var newProps = newNode.props
var key, value
var propsPatches = {}
// Find out different properties
for (key in oldProps) {
value = oldProps[key]
if (newProps[key] !== value) {
count++
propsPatches[key] = newProps[key]
}
}
// Find out new property
for (key in newProps) {
value = newProps[key]
if (!oldProps.hasOwnProperty(key)) {
count++
propsPatches[key] = newProps[key]
}
}
// If properties all are identical
if (count === 0) {
return null
}
return propsPatches
}
function isIgnoreChildren (node) {
return (node.props && node.props.hasOwnProperty('ignore'))
}
module.exports = diff
patch.js:
var _ = require('./util')
var REPLACE = 0
var REORDER = 1
var PROPS = 2
var TEXT = 3
function patch (node, patches) {
var walker = {index: 0}
dfsWalk(node, walker, patches)
}
function dfsWalk (node, walker, patches) {
var currentPatches = patches[walker.index]
var len = node.childNodes
? node.childNodes.length
: 0
for (var i = 0; i < len; i++) {
var child = node.childNodes[i]
walker.index++
dfsWalk(child, walker, patches)
}
if (currentPatches) {
applyPatches(node, currentPatches)
}
}
function applyPatches (node, currentPatches) {
_.each(currentPatches, function (currentPatch) {
switch (currentPatch.type) {
case REPLACE:
var 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 PROPS:
setProps(node, currentPatch.props)
break
case TEXT:
if (node.textContent) {
node.textContent = currentPatch.content
} else {
// fuck ie
node.nodeValue = currentPatch.content
}
break
default:
throw new Error('Unknown patch type ' + currentPatch.type)
}
})
}
function setProps (node, props) {
for (var key in props) {
if (props[key] === void 666) {
node.removeAttribute(key)
} else {
var value = props[key]
_.setAttr(node, key, value)
}
}
}
function reorderChildren (node, moves) {
var staticNodeList = _.toArray(node.childNodes)
var maps = {}
_.each(staticNodeList, function (node) {
if (node.nodeType === 1) {
var key = node.getAttribute('key')
if (key) {
maps[key] = node
}
}
})
_.each(moves, function (move) {
var 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
var insertNode = maps[move.item.key]
? maps[move.item.key].cloneNode(true) // 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)
}
})
}
patch.REPLACE = REPLACE
patch.REORDER = REORDER
patch.PROPS = PROPS
patch.TEXT = TEXT
module.exports = patch
5. 结语
Virtual DOM 算法主要是实现上面步骤的三个函数:element,diff,patch。然后就可以实际的进行使用:
// 1. 构建虚拟DOM
var tree = el('div', {'id': 'container'}, [
el('h1', {style: 'color: blue'}, ['simple virtal dom']),
el('p', ['Hello, virtual-dom']),
el('ul', [el('li')])
])
// 2. 通过虚拟DOM构建真正的DOM
var root = tree.render()
document.body.appendChild(root)
// 3. 生成新的虚拟DOM
var newTree = el('div', {'id': 'container'}, [
el('h1', {style: 'color: red'}, ['simple virtal dom']),
el('p', ['Hello, virtual-dom']),
el('ul', [el('li'), el('li')])
])
// 4. 比较两棵虚拟DOM树的不同
var patches = diff(tree, newTree)
// 5. 在真正的DOM元素上应用变更
patch(root, patches)
当然这是非常粗糙的实践,实际中还需要处理事件监听等;生成虚拟 DOM 的时候也可以加入 JSX 语法。这些事情都做了的话,就可以构造一个简单的ReactJS了。
参考博客:Virtual DOM