虚拟dom
优点-
前端优化方面,避免频繁操作DOM,频繁操作DOM会可能让浏览器回流和重绘,性能也会非常低,还有就是手动操作 DOM 还是比较麻烦的,要考虑浏览器兼容性问题,当前jQuery等库简化了 DOM操作,但是项目复杂了,DOM操作还是会变得复杂,数据操作也变得复杂
-
并不是所有情况使用虚拟
DOM都提高性能,是针对在复杂的的项目使用。如果简单的操作,使用虚拟DOM,要创建虚拟DOM对象等等一系列操作,还不如普通的DOM操作 -
虚拟
DOM可以实现跨平台渲染,服务器渲染 、小程序、原生应用都使用了虚拟DOM -
使用虚拟
DOM改变了当前的状态不需要立即的去更新DOM而且更新的内容进行更新,对于没有改变的内容不做任何操作,通过前后两次差异进行比较 -
虚拟 DOM 可以维护程序的状态,跟踪上一次的状态
example
真实DOM 结构
<div class="container">
<p>哈哈</p>
<ul class="list">
<li>1</li>
<li>2</li>
</ul>
</div>
虚拟DOM 结构
{
// 选择器
"sel": "div",
// 数据
"data": {
"class": { "container": true }
},
// DOM
"elm": undefined,
// 和 Vue :key 一样是一种优化
"key": undefined,
// 子节点
"children": [
{
"elm": undefined,
"key": undefined,
"sel": "p",
"data": { "text": "哈哈" }
},
{
"elm": undefined,
"key": undefined,
"sel": "ul",
"data": {
"class": { "list": true }
},
"children": [
{
"elm": undefined,
"key": undefined,
"sel": "li",
"data": {
"text": "1"
},
"children": undefined
},
{
"elm": undefined,
"key": undefined,
"sel": "li",
"data": {
"text": "1"
},
"children": undefined
}
]
}
]
}
复制代码
在之前提到的 snabbdom 中 patch方法
就是对 新的虚拟DOM 和 老的虚拟DOM 进行diff(精细化比较),找出最小量更新 是在虚拟DOM 比较
不可能把所有的 DOM 都拆掉 然后全部重新渲染
浏览器渲染步骤
-
第一步,构建 DOM 树:用 HTML 分析器,分析 HTML 元素,构建一棵 DOM 树;
-
第二步,生成样式表:用 CSS 分析器,分析 CSS 文件和元素上的 inline 样式,生成页面的样式表;
-
第三步,构建 Render 树:将 DOM 树和样式表关联起来,构建一棵 Render 树(Attachment)。每个 DOM 节点都有 attach 方法,接受样式信息,返回一个 render 对象(又名 renderer),这些 render 对象最终会被构建成一棵 Render 树;
-
第四步,确定节点坐标:根据 Render 树结构,为每个 Render 树上的节点确定一个在显示屏上出现的精确坐标;
-
第五步,绘制页面:根据 Render 树和节点显示坐标,然后调用每个节点的 paint 方法,将它们绘制出来。
注意
1、DOM 树的构建是文档加载完成开始的? 构建 DOM 树是一个渐进过程,为达到更好的用户体验,渲染引擎会尽快将内容显示在屏幕上,它不必等到整个 HTML 文档解析完成之后才开始构建 render 树和布局。
2、Render 树是 DOM 树和 CSS 样式表构建完毕后才开始构建的? 这三个过程在实际进行的时候并不是完全独立的,而是会有交叉,会一边加载,一边解析,以及一边渲染。
3、CSS 的解析注意点? CSS 的解析是从右往左逆向解析的,嵌套标签越多,解析越慢。
4、JS 操作真实 DOM 的代价? 用我们传统的开发模式,原生 JS 或 JQ 操作 DOM 时,浏览器会从构建 DOM 树开始从头到尾执行一遍流程。在一次操作中,我需要更新 10 个 DOM 节点,浏览器收到第一个 DOM 请求后并不知道还有 9 次更新操作,因此会马上执行流程,最终执行10 次。例如,第一次计算完,紧接着下一个 DOM 更新请求,这个节点的坐标值就变了,前一次计算为无用功。计算 DOM 节点坐标值等都是白白浪费的性能。即使计算机硬件一直在迭代更新,操作 DOM 的代价仍旧是昂贵的,频繁操作还是会出现页面卡顿,影响用户体验
虚拟dom相比操作dom的优化
虚拟 DOM 就是为了解决浏览器性能问题而被设计出来的。如前,若一次操作中有 10 次更新 DOM 的动作,虚拟 DOM 不会立即操作 DOM,而是将这 10 次更新的 diff 内容保存到本地一个 JS 对象中,最终将这个 JS 对象一次性 attch 到 DOM 树上,再进行后续操作,避免大量无谓的计算量。所以,用 JS 对象模拟 DOM 节点的好处是,页面的更新可以先全部反映在 JS 对象(虚拟 DOM )上,操作内存中的 JS 对象的速度显然要更快,等更新完成后,再将最终的 JS 对象映射成真实的 DOM,交由浏览器去绘制。
简单代码实现dom树构建和diff算法
* Element virdual-dom 对象定义
* @param {String} tagName - dom 元素名称
* @param {Object} props - dom 属性
* @param {Array<Element|String>} - 子节点
*/
function Element(tagName, props, children) {
this.tagName = tagName
this.props = props
this.children = children
// dom 元素的 key 值,用作唯一标识符
if(props.key){
this.key = props.key
}
var count = 0
children.forEach(function (child, i) {
if (child instanceof Element) {
count += child.count
} else {
children[i] = '' + child
}
count++
})
// 子元素个数
this.count = count
}
function createElement(tagName, props, children){
return new Element(tagName, props, children);
}
module.exports = createElement;
diff 算法用来比较两棵 Virtual DOM 树的差异,如果需要两棵树的完全比较,那么 diff 算法的时间复杂度为O(n^3)。但是在前端当中,你很少会跨越层级地移动 DOM 元素,所以 Virtual DOM 只会对同一个层级的元素进行对比,如下图所示, div 只会和同一层级的 div 对比,第二层级的只会跟第二层级对比,这样算法复杂度就可以达到 O(n)。
(1)深度优先遍历,记录差异
在实际的代码中,会对新旧两棵树进行一个深度优先的遍历,这样每个节点都会有一个唯一的标记:
在深度优先遍历的时候,每遍历到一个节点就把该节点和新的的树进行对比。如果有差异的话就记录到一个对象里面。
// diff 函数,对比两棵树
function diff(oldTree, newTree) {
var index = 0 // 当前节点的标志
var patches = {} // 用来记录每个节点差异的对象
dfsWalk(oldTree, newTree, index, patches)
return patches
}
// 对两棵树进行深度优先遍历
function dfsWalk(oldNode, newNode, index, patches) {
var currentPatch = []
if (typeof (oldNode) === "string" && typeof (newNode) === "string") {
// 文本内容改变
if (newNode !== oldNode) {
currentPatch.push({ type: patch.TEXT, content: newNode })
}
} else if (newNode!=null && oldNode.tagName === newNode.tagName && oldNode.key === newNode.key) {
// 节点相同,比较属性
var propsPatches = diffProps(oldNode, newNode)
if (propsPatches) {
currentPatch.push({ type: patch.PROPS, props: propsPatches })
}
// 比较子节点,如果子节点有'ignore'属性,则不需要比较
if (!isIgnoreChildren(newNode)) {
diffChildren(
oldNode.children,
newNode.children,
index,
patches,
currentPatch
)
}
} else if(newNode !== null){
// 新节点和旧节点不同,用 replace 替换
currentPatch.push({ type: patch.REPLACE, node: newNode })
}
if (currentPatch.length) {
patches[index] = currentPatch
}
}
复制代码
从以上可以得出,patches[1] 表示 p ,patches[3] 表示 ul ,以此类推。
(2)差异类型
DOM 操作导致的差异类型包括以下几种:
- 节点替换:节点改变了,例如将上面的
div换成h1; - 顺序互换:移动、删除、新增子节点,例如上面
div的子节点,把p和ul顺序互换; - 属性更改:修改了节点的属性,例如把上面
li的class样式类删除; - 文本改变:改变文本节点的文本内容,例如将上面
p节点的文本内容更改为 “Real Dom”;
以上描述的几种差异类型在代码中定义如下所示:
var REPLACE = 0 // 替换原先的节点
var REORDER = 1 // 重新排序
var PROPS = 2 // 修改了节点的属性
var TEXT = 3 // 文本内容改变
复制代码
(3)列表对比算法
子节点的对比算法,例如 p, ul, div 的顺序换成了 div, p, ul。这个该怎么对比?如果按照同层级进行顺序对比的话,它们都会被替换掉。如 p 和 div 的 tagName 不同,p 会被 div 所替代。最终,三个节点都会被替换,这样 DOM 开销就非常大。而实际上是不需要替换节点,而只需要经过节点移动就可以达到,我们只需知道怎么进行移动。
将这个问题抽象出来其实就是字符串的最小编辑距离问题(Edition Distance),最常见的解决方法是 Levenshtein Distance , Levenshtein Distance 是一个度量两个字符序列之间差异的字符串度量标准,两个单词之间的 Levenshtein Distance 是将一个单词转换为另一个单词所需的单字符编辑(插入、删除或替换)的最小数量。Levenshtein Distance 是1965年由苏联数学家 Vladimir Levenshtein 发明的。Levenshtein Distance 也被称为编辑距离(Edit Distance),通过动态规划求解,时间复杂度为 O(M*N)。
定义:对于两个字符串 a、b,则他们的 Levenshtein Distance 为:
示例:字符串 a 和 b,a=“abcde” ,b=“cabef”,根据上面给出的计算公式,则他们的 Levenshtein Distance 的计算过程如下:
本文的 demo 使用插件 list-diff2 算法进行比较,该算法的时间复杂度伟 O(n*m),虽然该算法并非最优的算法,但是用于对于 dom 元素的常规操作是足够的。该算法具体的实现过程这里不再详细介绍,该算法的具体介绍可以参照:github.com/livoras/lis…
(4)实例输出
两个虚拟 DOM 对象如下图所示,其中 ul1 表示原有的虚拟 DOM 树,ul2 表示改变后的虚拟 DOM 树
var ul1 = el('div',{id:'virtual-dom'},[ el('p',{},['Virtual DOM']),
el('ul', { id: 'list' }, [ el('li', { class: 'item' }, ['Item 1']),
el('li', { class: 'item' }, ['Item 2']),
el('li', { class: 'item' }, ['Item 3'])
]),
el('div',{},['Hello World'])
])
var ul2 = el('div',{id:'virtual-dom'},[ el('p',{},['Virtual DOM']),
el('ul', { id: 'list' }, [ el('li', { class: 'item' }, ['Item 21']),
el('li', { class: 'item' }, ['Item 23'])
]),
el('p',{},['Hello World'])
])
var patches = diff(ul1,ul2);
console.log('patches:',patches);
复制代码
我们查看输出的两个虚拟 DOM 对象之间的差异对象如下图所示,我们能通过差异对象得到,两个虚拟 DOM 对象之间进行了哪些变化,从而根据这个差异对象(patches)更改原先的真实 DOM 结构,从而将页面的 DOM 结构进行更改。
2.2.3、将两个虚拟 DOM 对象的差异应用到真正的 DOM 树
(1)深度优先遍历 DOM 树
因为步骤一所构建的 JavaScript 对象树和 render 出来真正的 DOM 树的信息、结构是一样的。所以我们可以对那棵 DOM 树也进行深度优先的遍历,遍历的时候从步骤二生成的 patches 对象中找出当前遍历的节点差异,如下相关代码所示:
function patch (node, patches) {
var walker = {index: 0}
dfsWalk(node, walker, patches)
}
function dfsWalk (node, walker, patches) {
// 从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)
}
// 对当前节点进行DOM操作
if (currentPatches) {
applyPatches(node, currentPatches)
}
}
复制代码
(2)对原有 DOM 树进行 DOM 操作
我们根据不同类型的差异对当前节点进行不同的 DOM 操作 ,例如如果进行了节点替换,就进行节点替换 DOM 操作;如果节点文本发生了改变,则进行文本替换的 DOM 操作;以及子节点重排、属性改变等 DOM 操作,相关代码如 applyPatches 所示 :
function applyPatches (node, currentPatches) {
currentPatches.forEach(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:
node.textContent = currentPatch.content
break
default:
throw new Error('Unknown patch type ' + currentPatch.type)
}
})
}
(3)DOM结构改变
通过将第 2.2.2 得到的两个 DOM 对象之间的差异,应用到第一个(原先)DOM 结构中,我们可以看到 DOM 结构进行了预期的变化,如下图所示:
正常人看得懂的
虚拟(Virtual )DOM
Virtual DOM 其实就是一棵以 JavaScript 对象(VNode 节点)作为基础的树,用对象属性来描述节点,相当于在js和真实dom中间加来一个缓存,利用dom diff算法避免没有必要的dom操作,从而提高性能。当然算法有时并不是最优解,因为它需要兼容很多实际中可能发生的情况,比如后续会讲到两个节点的dom树移动。
上几篇文章中讲vue的数据状态管理结合Virtual DOM更容易理解,在vue中一般都是通过修改元素的state,订阅者根据state的变化进行编译渲染,底层的实现可以简单理解为三个步骤:
- 1、用JavaScript对象结构表述dom树的结构,然后用这个树构建一个真正的dom树,插到浏览器的页面中。
- 2、当状态改变了,也就是我们的state做出修改,vue便会重新构造一棵树的对象树,然后用这个新构建出来的树和旧树进行对比(只进行同层对比),记录两棵树之间的差异。
- 3、把2记录的差异在重新应用到步骤1所构建的真正的dom树,视图就更新了。
举例子:有一个 ul>li 列表,在template中的写法是:
<ul id='list'>
<li class='item1'>Item 1</li>
<li class='item2'>Item 2</li>
<li class='item3' style='font-size: 20px'>Item 3</li>
</ul>
vue首先会将template进行编译,这其中包括parse、optimize、generate三个过程。
parse会使用正则等方式解析template模版中的指令、class、style等数据,形成AST,于是我们的ul> li 可能被解析成下面这样
// js模拟DOM结构
var element = {
tagName: 'ul', // 节点标签名
props: { // DOM的属性,用一个对象存储键值对
class: 'item',
id: 'list'
},
children: [ // 该节点的子节点
{tagName: 'li', props: {class: 'item1'}, children: "Item 1"},
{tagName: 'li', props: {class: 'item2'}, children: "Item 2"},
{tagName: 'li', props: {class: 'item3', style: 'font-size: 20px'}, children: "Item 3"},
]
}
optimize过程其实只是为了优化后文diff算法的,如果不加这个过程,那么每层的节点都需要做比对,即使没变的部分也得弄一遍,这也违背了Virtual DOM 最初本质,造成不必要的资源计算和浪费。因此在编译的过程中vue会主动标记static静态节点,个人理解为就是页面一些不变的或者不受state影响的节点。比如我们的ul节点,不论li如何变化ul始终是不会变的,因此在这个编译的过程中可以个ul打上一个标签。当后续update更新视图界面时,patch过程看到这个标签会直接跳过这些静态节点。
最后通过generate 将 AST 转化成 render function 字符串,得到结果是 render 的字符串以及 staticRenderFns 字符串。大家听起来可能很困惑,首先前两步大家应该都差不多知道了,当拿到一个AST时,vue内部有一个叫element ASTs的代码生成器,犹如名字一样generate函数拿到解析好的AST对象,递归AST树,为不同的AST节点创建不同的内部调用的方法,然后组合可执行的JavaScript字符串,等待后面的调用。最后可能会变成这个样子:
function _render() {
with (this) {
return __h__(
'ul',
{staticClass: "list"},
[
" ",
__h__('li', {class: item}, [String((msg))]),
" ",
__h__('li', {class: item}, [String((msg))]),
"",
__h__('li', {class: item}, [String((msg))]),
""
]
)
};
}
复制代码
整个Virtual DOM生成的过程代码中可简化为如下,有兴趣的同学可以去看具体对应的Vue源码,源码位置在src/compiler/index.js
export const createCompiler = createCompilerCreator(function baseCompile (
template: string,
options: CompilerOptions
): CompiledResult {
// 1.parse,模板字符串 转换成 抽象语法树(AST)
const ast = parse(template.trim(), options)
// 2.optimize,对 AST 进行静态节点标记
if (options.optimize !== false) {
optimize(ast, options)
}
// 3.generate,抽象语法树(AST) 生成 render函数代码字符串
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
})
复制代码
diff算法以及key的作用
在最初的diff算法其实是"不可用的",因为时间复杂度是O(n^3)。假设一个dom树有1000个节点,第一遍需要遍历tree1,第二遍遍历tree2,最后一遍就是排序组合成新树。因此这1000个节点需要计算1000^3 = 1亿次,这是非常庞大的计算,这种算法基本也不会用。
后面设计者们想出了一些方法,将时间复杂度由O(n^3)变成了O(n),那么这些设计者是如果实现的?这也就是diff算法的优势所在,也是平常我们所理解到一些知识:
- 1、只比较同一级,不跨级比较
- 2、tag不相同,直接删掉重建,不再深度比较
- 3、tag和key,两者都相同,则认为是相同节点,不在深度比较
这就是一个简单的diff。通过同层的树节点进行比较而非对树进行逐层搜索遍历的方式,所以时间复杂度只有 O(n)。
之前在Virtual DOM中讲到当状态改变了,vue便会重新构造一棵树的对象树,然后用这个新构建出来的树和旧树进行对比。这个过程就是patch。比对得出「差异」,最终将这些「差异」更新到视图上。patch的过程也是vue及react的核心算法,理解起来比较困难。先看一些简单的图形了解diff是如何比较新旧VNode的差异的。
-
场景1:更新删除移动
移动的场景在diff中应该是最基础的。要达到这样的效果。我们可以将b移动到同层的最后面或者把c移动到B前面再把D也移动到B前面,当然这是在引入了key的比对结果。如果没有key的话只会依次相互比较,将b ==> c、 c==> d、 d ==> b。然后在第三层中由于新建的c没有e、f因此会去新建e、f。为了让e、f得到复用,设key后,会从用key生成的对象oldKeyToIdx中查找匹配的节点。让算法知道不是删除节点而是移动节点,这就是有key和无key的作用。在数组中插入新节点也是同样的道理。
-
场景2:删除新建
我们可能期望将C直接移动到B的后边,这是最优的操作。但是实际的diff操作是移除c在创建一个c插入到b的下面,这就是同层比较的结果。如果在一些必要时可以手工优化,例如在react的shouldComponentUpdate生命周期中就拦截了子组件的渲染进行优化。
在简单的理解了diff算法实际操作的过程。为了让大家更好的掌握,因为这块还是比较复杂的。接下来将用伪代码的形式分析diff算法是如何进行深度优先遍历,记录差异, Vue的VDOM的diff算法借鉴的是snabbdom,不妨先从snabbdom Example入手
在vue中首先会对新旧两棵树进行深度优先的遍历,这样每个节点都会有一个唯一的标记。在遍历的同时,每遍历一个节点就会把该节点和新的树进行对比,有差异的话就会记录到一个对象里。
/* 创建diff函数,接受新旧量两棵参数 */
function diff (oldTree, newTree) {
var index = 0 //当前节点的标志
var patches = {} //用来记录每个节点差异的对象
dfsWalk(oldTree, newTree, index, patches) // 对两棵树进行深度优先遍历
return patches //返回不同的记录
}
function dfsWalk (oldNode, newNode, index, patches) {
var currentPatch = [] // 定义一个数组将对比oldNode和newNode的不同,记录下来
if (newNode === null) {
// 当执行重新排序时,真正的DOM节点将被删除,因此不需要在这里进行调整
} else if (_.isString(oldNode) && _.isString(newNode)) {
// 判断oldNode、newNode是否是字符串对象或者字符串值
if (newNode !== oldNode) {
//节点不同直接放入到数组中
currentPatch.push({ type: patch.TEXT, content: newNode })
}
} else if (oldNode.tagName === newNode.tagName && oldNode.key === newNode.key) {
// 节点是相同的,diff区分旧节点的props和子节点
// diff处理props
var propsPatches = diffProps(oldNode, newNode)
if (propsPatches) {
currentPatch.push({ type: patch.PROPS, props: propsPatches })
}
// diff处理子节点,如果有‘ignore’这个标志的。diff就忽视这个子节点
if (!isIgnoreChildren(newNode)) {
diffChildren(
oldNode.children,
newNode.children,
index,
patches,
currentPatch
)
}
} else {
// 节点不相同,用新节点直接替换旧节点
currentPatch.push({ type: patch.REPLACE, node: newNode })
}
}
function isIgnoreChildren (node) {
return (node.props && node.props.hasOwnProperty('ignore'))
}
/* 处理子节点diffChildren函数 */
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
oldChildren.forEach(function (child, i) {
var newChild = newChildren[i]
currentNodeIndex = (leftNode && leftNode.count) // 计算节点的标识
? currentNodeIndex + leftNode.count + 1
: currentNodeIndex + 1
dfsWalk(child, newChild, currentNodeIndex, patches) // 深度遍历子节点
leftNode = child
})
}
/* 处理子节点的props diffProps函数 */
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
}
// 暴露diff函数
module.exports = diff
patch过程
diff
Vue在挂载实例的时候,mountComponent方法中有个重点的函数:_update,该函数是Vue的一个私有实例方法,它的作用是将Vnode渲染成真实的DOM,定义在src/core/instance/lifecycle.js中,它内部主要是调用了vm.__patch__方法
patch
Vue在patch节点的时候会先判断新老节点是否是相同类型的节点,如果sameVNode为false,则直接销毁oldVnode,渲染newVnode;否则进入patchVnode方法
patchVnode
patchVnode接收六个参数:oldVnode,vnode,insertdVnodeQueue,ownerArray,index,removeOnly
patchVnode首先会判断是否是文本节点,如果是直接将文本内容替换就好,如果不是则开始对比children。
如果只有新节点有children,则执行addVnodes,添加新子节点
如果只有旧节点有children,则直接removeVnode,删除旧节点
如果新旧节点都有children,则执行updateChildren方法。
updateChildren
diff过程的实现主要是在updateChildren函数中。
- 虚拟DOM渲染真实DOM时会对新老VNode的开始结束位置进行标记,oldStartIdx,newStartIdx,oldEndIdx,newEndIdx
- 标记好节点后,进入到while循环中,该循环的退出条件是老节点或者新节点的开始位置大于终止位置
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
...
}
复制代码
- 循环过程中首先对新老节点的首尾节点进行特殊比较
- 首先当新老节点开始节点满足sameVnode时,直接patchVnode;同时新老节点开始索引+1
- 接下来比较新老节点的结束节点,如果sameVnode为true,同样直接patchVnode;同时新老节点结束索引-1
- 接下来老节点的开始节点与新节点的结束节点比较,如果sameVnode为true,说明oldStartVnode要移动到oldEndVnode后边(nextSibling)去了。先将这两个节点patchVnode,同时将真实的DOM节点通过insertBefore移动到oldEndVnode后边。老节点的开始索引要+1,但是由于老的开始节点移动到老的结束节点后边了,则对应的新节点的结束索引需要-1
- 最后一种比较就是老节点的结束节点与新节点的开始节点比较,满足sameVnode,说明老节点的oldEndVnode要移动到老节点的oldStartVnode之前。将这两个节点patchVnode之后,需要将当前的真实DOM移动到oldStartVnode前边,所以与之对应的,老节点的结束索引要-1,新节点的开始索引要+1
- 如果新老节点首尾的四种比较结果都不满足sameVnode,则开始根据key值查找是否有可复用的节点。
如果新节点具有key值,则开始查找事先已经建立好的以oldVnode为key,对应的index为value的哈希表;否则就在整个老节点树中遍历查找。这里也说明定义了key值的重要性。
从哈希表中找出与newStartVnode一致key的oldVnode,如果满足sameVnode,则patchVnode,同时将这个真实DOM节点移动到oldStartVnode对应的真实DOM前边;
如果在哈希表中没找到,则说明当前索引下的newVnode在oldVnode队列中不存在,无法节点复用,直接调用createElm创建一个新的dom节点放到当前newStartIdx的位置。
- 循环结束后,根据新老节点的数目不同,做相应的添加或删除。若新节点数目大于老节点则把多出的节点创建出来添加到真实DOM中,否则把多余的节点从真实DOM中删除。
如何判断两个节点是否可以复用
首先来回顾下Vue中Vnode都包含哪些属性,具体Vnode定义在src/core/vdom/vnode.js文件中,有兴趣可以去看。
export default class VNode {
tag: string | void;
data: VNodeData | void;
children: ?Array<VNode>;
text: string | void;
elm: Node | void;
ns: string | void;
context: Component | void; // rendered in this component's scope
key: string | number | void;
componentOptions: VNodeComponentOptions | void;
componentInstance: Component | void; // component instance
parent: VNode | void; // component placeholder node
// strictly internal
raw: boolean; // contains raw HTML? (server only)
isStatic: boolean; // hoisted static node
isRootInsert: boolean; // necessary for enter transition check
isComment: boolean; // empty comment placeholder?
isCloned: boolean; // is a cloned node?
isOnce: boolean; // is a v-once node?
asyncFactory: Function | void; // async component factory function
asyncMeta: Object | void;
isAsyncPlaceholder: boolean;
ssrContext: Object | void;
fnContext: Component | void; // real context vm for functional nodes
fnOptions: ?ComponentOptions; // for SSR caching
devtoolsMeta: ?Object; // used to store functional render context for devtools
fnScopeId: ?string; // functional scope id support
constructor() {
...
}
}
复制代码
sameVnode
sameVnode函数用来判断两个几点是否可以复用,要求首先新老节点的key必须相等,其次是标签要相同,接下来判断两个Vnode是否都具有data属性。其中input标签会特殊判断
function sameVnode (a, b) {
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}
复制代码
sameInputType
function sameInputType (a, b) {
if (a.tag !== 'input') return true
let i
const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type
const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type
return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB)
}
虚拟DOM定义的VNode对象的几个属性:
| option | value |
|---|---|
| tag | DOM标签 |
| elm | 对应的真实DOM节点 |
| data | 虚拟节点的一些数据信息,如style,class等,是VnodeData类型 |
| children | 当前节点包含的子节点 |
| ns | 当前节点的命名空间 |
| isComment | 是否是注释节点 |
| fnScopeId | 节点的scopeId |
| context | 当前节点的父虚拟节点上下文作用域 |
了解了这几个属性的含义,接下来具体看下怎么创建新的真实DOM元素节点。上一篇已经看到了创建新节点是调用createElm函数,来看下它的源码:
createElm
function createElm (vnode,insertedVnodeQueue,parentElm,refElm,nested,ownerArray,index) {
if (isDef(vnode.elm) && isDef(ownerArray)) {
// This vnode was used in a previous render!
// now it's used as a new node, overwriting its elm would cause
// potential patch errors down the road when it's used as an insertion
// reference node. Instead, we clone the node on-demand before creating
// associated DOM element for it.
vnode = ownerArray[index] = cloneVNode(vnode)
}
vnode.isRootInsert = !nested // for transition enter check
if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
return
}
const data = vnode.data
const children = vnode.children
const tag = vnode.tag
if (isDef(tag)) {
if (process.env.NODE_ENV !== 'production') {
...
}
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode)
setScope(vnode)
/* istanbul ignore if */
if (__WEEX__) {
...
} else {
createChildren(vnode, children, insertedVnodeQueue)
if (isDef(data)) {
invokeCreateHooks(vnode, insertedVnodeQueue)
}
insert(parentElm, vnode.elm, refElm)
}
if (process.env.NODE_ENV !== 'production' && data && data.pre) {
creatingElmInVPre--
}
} else if (isTrue(vnode.isComment)) {
vnode.elm = nodeOps.createComment(vnode.text)
insert(parentElm, vnode.elm, refElm)
} else {
vnode.elm = nodeOps.createTextNode(vnode.text)
insert(parentElm, vnode.elm, refElm)
}
}
复制代码
function insert (parent, elm, ref) {
if (isDef(parent)) {
if (isDef(ref)) {
if (nodeOps.parentNode(ref) === parent) {
nodeOps.insertBefore(parent, elm, ref)
}
} else {
nodeOps.appendChild(parent, elm)
}
}
}
复制代码
首先调用createComponent方法(后边介绍)来创建子组件,如果true则直接返回。
接下来判断是否具有tag,如果不具有则判断是不是注释节点,如果是则创建注释节点,否则说明是一个文本节点,将创建好的节点调用insert方法插入到父节点。
如果具有tag,创建一个elment占位元素给到节点的elm属性,为节点添加scopeId,设置样式作用域。接下来调用createChildren创建子元素。如果data不为空则调用invokeCreateHooks方法。
setScope
function setScope (vnode) {
let i
if (isDef(i = vnode.fnScopeId)) {
nodeOps.setStyleScope(vnode.elm, i)
} else {
let ancestor = vnode
while (ancestor) {
if (isDef(i = ancestor.context) && isDef(i = i.$options._scopeId)) {
nodeOps.setStyleScope(vnode.elm, i)
}
ancestor = ancestor.parent
}
}
// for slot content they should also get the scopeId from the host instance.
if (isDef(i = activeInstance) &&
i !== vnode.context &&
i !== vnode.fnContext &&
isDef(i = i.$options._scopeId)
) {
nodeOps.setStyleScope(vnode.elm, i)
}
}
复制代码
setScope主要是用来处理我们用的scoped CSS,先判断fnScopeId是否设置,如果设置了直接使用;否则就从父级上下文环境一层层查找。
createChildren
接下来看下createChildren做了什么:
function createChildren (vnode, children, insertedVnodeQueue) {
if (Array.isArray(children)) {
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(children)
}
for (let i = 0; i < children.length; ++i) {
createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
}
} else if (isPrimitive(vnode.text)) {
nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
}
}
复制代码
首先判断子节点是不是数组,如果是则遍历所有的虚拟子节点,依次递归调用createElm方法;否则判断是不是文本节点,直接调用appenChild添加到父节点后边。可以看出由于递归调用的缘故,子节点会先执行insert方法,所以整个Vnode树是先子后父插入的。
invokeCreateHooks
function invokeCreateHooks (vnode, insertedVnodeQueue) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, vnode)
}
i = vnode.data.hook // Reuse variable
if (isDef(i)) {
if (isDef(i.create)) i.create(emptyNode, vnode)
if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
}
}
复制代码
invokeCreateHooks函数首先for循环调用了所有的create钩子函数,然后把vnode 加入到insertedVnodeQueue中。
为啥不用index作为key
<ul>
<li>1</li>
<li>2</li>
</ul>
那么它的 vnode 也就是虚拟 dom 节点大概是这样的。
{
tag: 'ul',
children: [
{ tag: 'li', children: [ { vnode: { text: '1' }}] },
{ tag: 'li', children: [ { vnode: { text: '2' }}] },
]
}
首先响应式数据更新后,触发了 渲染 Watcher 的回调函数 vm._update(vm._render())去驱动视图更新,
vm._render() 其实生成的就是 vnode,而 vm._update 就会带着新的 vnode 去走触发 __patch__ 过程。
我们直接进入 ul 这个 vnode 的 patch 过程。
对比新旧节点是否是相同类型的节点:
1. 不是相同节点:
isSameNode为false的话,直接销毁旧的 vnode,渲染新的 vnode。这也解释了为什么 diff 是同层对比。
2. 是相同节点,要尽可能的做节点的复用(都是 ul,进入 )。
会调用src/core/vdom/patch.js下的patchVNode方法。
如果新 vnode 是文字 vnode
就直接调用浏览器的 dom api 把节点的直接替换掉文字内容就好。
如果新 vnode 不是文字 vnode
那么就要开始对子节点 children 进行对比了。(可以类比 ul 中的 li 子元素)。
如果有新 children 而没有旧 children
说明是新增 children,直接 addVnodes 添加新子节点。
如果有旧 children 而没有新 children
说明是删除 children,直接 removeVnodes 删除旧子节点
如果新旧 children 都存在(都存在 li 子节点列表,进入 )
那么就是我们 diff算法 想要考察的最核心的点了,也就是新旧节点的 diff 过程。
通过
// 旧首节点
let oldStartIdx = 0
// 新首节点
let newStartIdx = 0
// 旧尾节点
let oldEndIdx = oldCh.length - 1
// 新尾节点
let newEndIdx = newCh.length - 1
这些变量分别指向旧节点的首尾、新节点的首尾。
根据这些指针,在一个 while 循环中不停的对新旧节点的两端的进行对比,然后把两端的指针向不断内部收缩,直到没有节点可以对比。
在讲对比过程之前,要讲一个比较重要的函数:sameVnode:
function sameVnode (a, b) {
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
)
)
)
}
它是用来判断节点是否可用的关键函数,可以看到,判断是否是 sameVnode,传递给节点的 key 是关键。
然后我们接着进入 diff 过程,每一轮都是同样的对比,其中某一项命中了,就递归的进入 patchVnode 针对单个 vnode 进行的过程(如果这个 vnode 又有 children,那么还会来到这个 diff children 的过程 ):
-
旧首节点和新首节点用
sameNode对比。\ -
旧尾节点和新尾节点用
sameNode对比\ -
旧首节点和新尾节点用
sameNode对比\ -
旧尾节点和新首节点用
sameNode对比\ -
如果以上逻辑都匹配不到,再把所有旧子节点的
key做一个映射到旧节点下标的key -> index表,然后用新vnode的key去找出在旧节点中可以复用的位置。\
然后不停的把匹配到的指针向内部收缩,直到新旧节点有一端的指针相遇(说明这个端的节点都被patch过了)。
在指针相遇以后,还有两种比较特殊的情况:
-
有新节点需要加入。 如果更新完以后,
oldStartIdx > oldEndIdx,说明旧节点都被patch完了,但是有可能还有新的节点没有被处理到。接着会去判断是否要新增子节点。\ -
有旧节点需要删除。 如果新节点先patch完了,那么此时会走
newStartIdx > newEndIdx的逻辑,那么就会去删除多余的旧子节点。
为什么不要以index作为key?
节点reverse场景
假设我们有这样的一段代码:
<div id="app">
<ul>
<item
:key="index"
v-for="(num, index) in nums"
:num="num"
:class="`item${num}`"
></item>
</ul>
<button @click="change">改变</button>
</div>
<script src="./vue.js"></script>
<script>
var vm = new Vue({
name: "parent",
el: "#app",
data: {
nums: [1, 2, 3]
},
methods: {
change() {
this.nums.reverse();
}
},
components: {
item: {
props: ["num"],
template: `
<div>
{{num}}
</div>
`,
name: "child"
}
}
});
</script>
其实是一个很简单的列表组件,渲染出来 1 2 3 三个数字。我们先以 index 作为key,来跟踪一下它的更新。
我们接下来只关注 item 列表节点的更新,在首次渲染的时候,我们的虚拟节点列表 oldChildren 粗略表示是这样的:
[
{
tag: "item",
key: 0,
props: {
num: 1
}
},
{
tag: "item",
key: 1,
props: {
num: 2
}
},
{
tag: "item",
key: 2,
props: {
num: 3
}
}
];
在我们点击按钮的时候,会对数组做 reverse 的操作。那么我们此时生成的 newChildren 列表是这样的:
[
{
tag: "item",
key: 0,
props: {
+ num: 3
}
},
{
tag: "item",
key: 1,
props: {
+ num: 2
}
},
{
tag: "item",
key: 2,
props: {
+ num: 1
}
}
];
发现什么问题没有?key的顺序没变,传入的值完全变了。这会导致一个什么问题?
本来按照最合理的逻辑来说,旧的第一个vnode 是应该直接完全复用 新的第三个vnode的,因为它们本来就应该是同一个vnode,自然所有的属性都是相同的。
但是在进行子节点的 diff 过程中,会在 旧首节点和新首节点用sameNode对比。 这一步命中逻辑,因为现在新旧两次首部节点 的 key 都是 0了,
然后把旧的节点中的第一个 vnode 和 新的节点中的第一个 vnode 进行 patchVnode 操作。
这会发生什么呢?我可以大致给你列一下: 首先,正如我之前的文章**props的更新如何触发重渲染?** 里所说,在进行 patchVnode 的时候,会去检查 props 有没有变更,如果有的话,会通过 _props.num = 3 这样的逻辑去更新这个响应式的值,触发 dep.notify,触发子组件视图的重新渲染等一套很重的逻辑。
然后,还会额外的触发以下几个钩子,假设我们的组件上定义了一些dom的属性或者类名、样式、指令,那么都会被全量的更新。
- updateAttrs
- updateClass
- updateDOMListeners
- updateDOMProps
- updateStyle
- updateDirectives
而这些所有重量级的操作(虚拟dom发明的其中一个目的不就是为了减少真实dom的操作么?),都可以通过直接复用 第三个vnode 来避免,是因为我们偷懒写了 index 作为 key,而导致所有的优化失效了。
节点删除场景
另外,除了会导致性能损耗以外,在删除子节点的场景下还会造成更严重的错误,
假设我们有这样的一段代码:
<body>
<div id="app">
<ul>
<li v-for="(value, index) in arr" :key="index">
<test />
</li>
</ul>
<button @click="handleDelete">delete</button>
</div>
</div>
</body>
<script>
new Vue({
name: "App",
el: '#app',
data() {
return {
arr: [1, 2, 3]
};
},
methods: {
handleDelete() {
this.arr.splice(0, 1);
}
},
components: {
test: {
template: "<li>{{Math.random()}}</li>"
}
}
})
</script>
那么一开始的 vnode列表是:
[
{
tag: "li",
key: 0,
// 这里其实子组件对应的是第一个 假设子组件的text是1
},
{
tag: "li",
key: 1,
// 这里其实子组件对应的是第二个 假设子组件的text是2
},
{
tag: "li",
key: 2,
// 这里其实子组件对应的是第三个 假设子组件的text是3
}
];
有一个细节需要注意,正如我上一篇文章中所提到的**为什么说 Vue 的响应式更新比 React 快?** ,Vue 对于组件的 diff 是不关心子组件内部实现的,它只会看你在模板上声明的传递给子组件的一些属性是否有更新。
也就是和v-for平级的那部分,回顾一下判断 sameNode 的时候,只会判断key、 tag、是否有data的存在(不关心内部具体的值)、是否是注释节点、是否是相同的input type,来判断是否可以复用这个节点。
<li v-for="(value, index) in arr" :key="index"> // 这里声明的属性
<test />
</li>
有了这些前置知识以后,我们来看看,点击删除子元素后,vnode 列表 变成什么样了。
[
// 第一个被删了
{
tag: "li",
key: 0,
// 这里其实上一轮子组件对应的是第二个 假设子组件的text是2
},
{
tag: "li",
key: 1,
// 这里其实子组件对应的是第三个 假设子组件的text是3
},
];
虽然在注释里我们自己清楚的知道,第一个 vnode 被删除了,但是对于 Vue 来说,它是感知不到子组件里面到底是什么样的实现(它不会深入子组件去对比文本内容),那么这时候 Vue 会怎么 patch 呢?
由于对应的 key使用了 index导致的错乱,它会把
原来的第一个节点text: 1直接复用。原来的第二个节点text: 2直接复用。- 然后发现新节点里少了一个,直接把多出来的第三个节点
text: 3丢掉。
至此为止,我们本应该把 text: 1节点删掉,然后text: 2、text: 3 节点复用,就变成了错误的把 text: 3 节点给删掉了。
为什么不要用随机数作为key?
<item
:key="Math.random()"
v-for="(num, index) in nums"
:num="num"
:class="`item${num}`"
/>
其实我听过一种说法,既然官方要求一个 唯一的key,是不是可以用 Math.random() 作为 key 来偷懒?这是一个很鸡贼的想法,看看会发生什么吧。
首先 oldVnode 是这样的:
[
{
tag: "item",
key: 0.6330715699108844,
props: {
num: 1
}
},
{
tag: "item",
key: 0.25104533240710514,
props: {
num: 2
}
},
{
tag: "item",
key: 0.4114769152411637,
props: {
num: 3
}
}
];
更新以后是:
[
{
tag: "item",
+ key: 0.11046018699748683,
props: {
+ num: 3
}
},
{
tag: "item",
+ key: 0.8549799545696619,
props: {
+ num: 2
}
},
{
tag: "item",
+ key: 0.18674467938937478,
props: {
+ num: 1
}
}
];
可以看到,key 变成了完全全新的 3 个随机数。
上面说到,diff 子节点的首尾对比如果都没有命中,就会进入 key 的详细对比过程,简单来说,就是利用旧节点的 key -> index 的关系建立一个 map 映射表,然后用新节点的 key 去匹配,如果没找到的话,就会调用 createElm 方法 重新建立 一个新节点。
具体代码在这:
// 建立旧节点的 key -> index 映射表
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
// 去映射表里找可以复用的 index
idxInOld = findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
// 一定是找不到的,因为新节点的 key 是随机生成的。
if (isUndef(idxInOld)) {
// 完全通过 vnode 新建一个真实的子节点
createElm();
}
也就是说,咱们的这个更新过程可以这样描述: 123 -> 前面重新创建三个子组件 -> 321123 -> 删除、销毁后面三个子组件 -> 321。
发现问题了吧?这是毁灭性的灾难,创建新的组件和销毁组件的成本你们晓得的伐……本来仅仅是对组件移动位置就可以完成的更新,被我们毁成这样了。