随着时代的发展,页面上的功能越来越多,程序中需要维护的状态越来越多,DOM操作也越来越频繁
我们发现像之前那样使用jQuery或原生js来开发页面,那么操作DOM的代码占据大多数,程序中的状态也难以进行管理,这被称为命令式操作DOM,虽然简单实用,但是却难以进行维护。
当我们开始使用三大主流框架Vue.js、Angular和React时,他们都是声明式地操作DOM,我们通过描述状态与DOM之间的映射关系,就可以将状态转换为视图,甚至我们根本不需要手动操作DOM
我们的关注点应该聚焦在状态维护上,而DOM操作其实是可以省略的
当某个状态发生改变时,只更新与它相关的DOM节点,为了解决这个问题,主流框架都有着自己的一套解决方案,Angular中使用脏检查的流程,React中使用的是虚拟DOM,Vue1.0使用细粒度的绑定
虚拟DOM的解决方式是通过状态生成一个虚拟节点树,然后使用虚拟节点树进行渲染,渲染之间会将新旧虚拟节点树进行对比,只渲染不一样的地方
使用虚拟节点进行DOM操作有效提高了性能
虚拟DOM在Vue.js中只做了两件事:
两个虚拟节点进行对比是虚拟DOM中最核心的算法patch,它可以判断出哪些节点发生了变化,从而进行节点更新操作
Vue.js中存在一个VNode类,可以用来实例化不同类型的vnode实例,而不同类型的vnode实例各自代表着不同的DOM元素
先来看一下VNode的定义源码:
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 (
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions,
asyncFactory?: Function
) {
this.tag = tag
this.data = data
this.children = children
this.text = text
this.elm = elm
this.ns = undefined
this.context = context
this.fnContext = undefined
this.fnOptions = undefined
this.fnScopeId = undefined
this.key = data && data.key
this.componentOptions = componentOptions
this.componentInstance = undefined
this.parent = undefined
this.raw = false
this.isStatic = false
this.isRootInsert = true
this.isComment = false
this.isCloned = false
this.isOnce = false
this.asyncFactory = asyncFactory
this.asyncMeta = undefined
this.isAsyncPlaceholder = false
}
// DEPRECATED: alias for componentInstance for backwards compat.
/* istanbul ignore next */
get child (): Component | void {
return this.componentInstance
}
}
vnode可以理解为节点描述对象,它描述了应该怎样去创建一个真实的DOM节点,例如:tag表示节点的名称,text表示文本节点的文本,children表示子节点~
vnode代表的是一个真实的DOM元素,所有的真实DOM节点都使用vnode创建并插入到页面中。
在vue.js中会对vnode进行缓存,当有新的vnode时会与旧的vnode进行对比,只更新发生变化的节点,以节省性能,这时vnode最重要的一个作用
vnode的类型有以下几种:
创建注释节点:
export const createEmptyVNode = (text: string = '') => {
const node = new VNode()
node.text = text
node.isComment = true
return node
}
注释节点只有两个有效属性: text
、isComment
对应vnode应该是这样的:
{
text: '注释节点',
isComment: 'true'
}
//
创建过程:
export function createTextVNode (val: string | number) {
return new VNode(undefined, undefined, undefined, String(val))
}
文本节点被创建时只有一个text属性,因此对应vnode应该是:
{
text: 'Hello World'
}
克隆节点是将现有节点的属性复制到新的节点中,让新创建的节点属性与克隆节点保持一致实现克隆效果。
克隆节点的作用是优化静态节点和插槽节点(slot node)
以静态节点为例,当组件内的某个状态发生改变时,当前组件会通过虚拟DOM重新渲染视图,静态节点因为他的内容不会改变,所以除了首次渲染外其他时候都不需要重新生成新的vnode,此时使用创建克隆节点的方法将vnode进行克隆,使用克隆节点进行渲染,这样就不需要重新执行渲染函数生成新的静态节点,也就提升了一定程度的性能
克隆节点的创建过程:
export function cloneVNode (vnode: VNode): VNode {
const cloned = new VNode(
vnode.tag,
vnode.data,
// #7975
// clone children array to avoid mutating original in case of cloning
// a child.
vnode.children && vnode.children.slice(),
vnode.text,
vnode.elm,
vnode.context,
vnode.componentOptions,
vnode.asyncFactory
)
cloned.ns = vnode.ns
cloned.isStatic = vnode.isStatic
cloned.key = vnode.key
cloned.isComment = vnode.isComment
cloned.fnContext = vnode.fnContext
cloned.fnOptions = vnode.fnOptions
cloned.fnScopeId = vnode.fnScopeId
cloned.asyncMeta = vnode.asyncMeta
cloned.isCloned = true
return cloned
}
从代码可以看出,创建克隆节点时只需要将现有节点的全部属性复制到新节点中即可,与源节点的区别是克隆节点的isCloned属性为true
元素节点存在四个属性:
<p><span>aaaspan><span>bbbspan>p>
这是一个真实的元素节点
对应Vnode应该时这样的:
{
children: [VNode, VNode],
contextL: {...},
data: {...},
tag: "p",
...
}
组件节点与元素节点类似,它有两个独特的属性
函数式组件与组件节点类似,它独有的属性是:functionalContext
和functionalOptions
虚拟DOM最核心的部分就是patch,它可以将vnode渲染成真实的DOM
patch也被称为pathching算法,在对真实DOM进行渲染时,它会对比新旧vnode有什么不同,然后根据对比结果找出需要更新的节点
patch不是暴力替换节点,而是在现在DOM上进行修改来达到渲染视图的目的,渲染的过程基本如下:
首次渲染时(此时页面内部没有节点,不存在oldVnode),我们面临的是不存在oldVnode的情况,这是我们只需要使用vnode直接创建元素并渲染视图就可以了
而当vnode被创建后,oldVnode存在且与vnode完全不一样的时候,会以vnode为标准来进行视图渲染,此时vnode是一个全新的节点,而oldVnode是一个被废弃的节点,
这时我们需要的就是使用vnode创建一个新的DOM节点,用这个节点去替换oldVnode所对应的节点
事实上只有三种类型的节点会被创建并插入到DOM中:元素机欸但、注释节点和文本节点
要判断vnode是否是元素节点,只需要判断它是否拥有tag属性,如果一个vnode据哟itag属性,那么旧认为他是元素节点,这时就会调用当前环境下的createElement
(浏览器中时调用document.createElement
)来创建真实的元素节点,元素节点被创建后,需要将它渲染到视图中
这个渲染过程也是比较简单的,只需要调用当前环境下的appendChild
(浏览器中调用parentNode.appendChild
)就可以将一个元素节点插入到指定的父节点中,如果这个父节点已经被渲染到视图中,那么元素节点插入后会被自动渲染
其实创建节点的过程中还需要创建他的子节点,子节点的创建过程是一个递归过程,vnode中的children属性保存了当前节点的所有子虚拟节点(child virtual node)对children递归创建,对每一个子节点都执行一遍创建节点的过程,这样就会渲染出一个完整的DOM结构
除去元素节点外,还有注释节点和文本节点需要创建,注释节点的特性就是属性isComment为true,所以通过对这个属性的判断旧可以判断vnode是不是一个注释节点,如果是一个注释节点,就会调用当前环境下的createComment
(浏览器下document.createComment
)来创建真实的注释节点并插入到指定父节点中
如果是文本节点,就会调用当前环境的createTextNode
方法(浏览器下document.createTextNode
)来创建真实的文本节点并将其加入到指定的父节点中
在新增节点中提到过,当有一个vnode对oldVnode进行替换时根据vnode创建的新节点插入到就节点的旁边,然后oldValue对应的旧节点会被删除,这样就完成了替换的过程
源码中是这么写的:
function removeVnodes (vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (isDef(ch)) {
if (isDef(ch.tag)) {
removeAndInvokeRemoveHook(ch)
invokeDestroyHook(ch)
} else { // Text node
removeNode(ch.elm)
}
}
}
}
就是删除vnodes数组中从startIdx指定位置到endIdx指定位置的内容,removeNode用于删除视图中的单个节点,而removeVnodes用于删除一组指定的节点
const nodeOps = {
removeChild(node, child) {
node.removeChild(child)
}
}
function removeNode (el) {
const parent = nodeOps.parentNode(el)
// element may have already been removed due to v-html / v-text
if (isDef(parent)) {
nodeOps.removeChild(parent, el)
}
}
nodeOps的封装是为了更好地进行跨平台渲染,这里的逻辑就是将当前的元素从它的父节点中删除,noideOps是对节点操作的封装
下图是一个替换文字的简单例子,视图中的文本节点包含的文字是“我是文字”,而当状态发生变化时,将文本改成了“我是文字2“,这时根据改变后的状态生成了新的vnode,然后将vnode与oldVnode进行对比,发现他们是同一个节点,深层次对比后,发现文字发生了变化,最后将真实DOM中的文本改成了vnode中的文字”我是文字2“
patch的工作流程大致如下图所示:
静态节点指的是那些一定那渲染到界面上,无论状态如何改变,都不会发生变化的节点
<p>静态节点p>
这就是一个静态节点,即无论状态怎样变化,这个节点都不会受到影响而进行重新渲染,它永远都不需要重新渲染
当新旧两个虚拟节点不是静态节点并且拥有不同属性时可以根据vnode是否拥有text属性来进行不同的更新
如果新的vnode拥有text属性那么不论之前的就节点的子节点是什么,都可以直接调用setTextContent
方法(浏览器下node.setTextContext
)来将视图中DOM节点的内容改为虚拟节点
当然如果之前的oldVnode中也拥有text属性那么就不需要使用setTextContent
来设置相同的文本,只需要直接把真实DOM节点的内容改成新vnode的文本即可
如果vnode中不存在text属性,那么他就是一个元素节点,元素节点的更新通常会根据它是否有子节点而有所区别
当vnode有children属性时,如果旧节点有children属性,那么旧需要对新旧节点的children进行详细的对比你并更新
如果旧虚拟节点没有children属性,那么说明旧的虚拟节点是一个空标签,要么就是一个文本节点,如果是文本节点那么就需要将其清空,然后根据新的vnode的children挨个创建真实的DOM元素节点并插入到视图的DOM中
当vnode既没有text属性也没有children属性时,说明这个节点是一个空节点,那么这时oldVnode中如果存在子节点就将子节点删除,如果存在文本就将文本删除,直到剩余空标签为止
更新子节点大致分为4种操作:更新节点、新增节点、删除节点、移动节点
对比两个子节点列表(children)首先就是循环,循环newChildren(新子节点列表),每循环到一个新的子节点,就去oldChildren(旧子节点列表)中寻找与之相对应的节点,如果找不到就说明这个子节点是因为状态改变而新增的节点,因此需要创建节点并插入视图,如果找到了相应的节点,那么就执行更新操作
更新策略主要包括:新增节点、更新节点、移动节点、删除节点
如果我们在newChildren中发现了一个节点同时存在于oldChildren中时我们就需要对这个节点进行更新操作
如果两个节点同处于两个子节点列表的相同位置,那么这时我们只需要对两个节点进行正常的节点更新操作
但是如果两个节点处理两个列表的不同位置,那么此时就要进行我们的下一步错做移动节点
如上文所述,当与newChildren中的新节点对应节点处于oldChildren的不同位置时,我们就需要进行移动子节点
移动的基本策略就是将真实DOM中的对应节点移动到newChildren中该节点对应的位置
通过Node.insertBefore()
方法,我们可以成功地将一个已有节点移动到一个指定的位置
而这个指定位置其实就是newChildren中所有未处理节点的第一个位置
当我们在旧子节点列表中没有找到当前子节点列表中的节点时,我们需要创建一个新的节点并且插入到oldChildren中所有未处理节点的前面。节点成功插入DOM后这一次循环也就结束了
可定有人不理解为什么插入到所有未处理节点的前面而不是插入到已处理节点的后面,可以举例说明一下:
假如说有以下DOM、newChildren、oldChildren:
DOM ======> [【已处理】,【已处理】,【未处理】,【未处理】]
newChildren => [【已处理】,【已处理】,【新节点】,【未处理】]
oldChildren => [【已处理】,【已处理】,【未处理】,【未处理】]
此时newChildren中的【新节点】在oldChildren中找不到对应的节点,因此会创建新的节点,并且插入到DOM节点的第三个位置,此时:
DOM ======> [【已处理】,【已处理】,【新节点】,【未处理】,【未处理】]
这样看感觉怎么说都是正确的,因为确实新节点添加到了已处理节点的后面,也可以说时添加到了未处理节点的前面
但是请看下一个例子:
DOM ======> [【已处理】,【已处理】,【已处理】,【未处理】,【未处理】]
newChildren => [【已处理】,【已处理】,【已处理】,【新节点】]
oldChildren => [【已处理】,【已处理】,【未处理】,【未处理】]
此时按照添加到已处理节点后面的逻辑思想,下一步操作应该时这样的:
DOM ======> [【已处理】,【已处理】,【已处理】,【新节点】,【未处理】,【未处理】]
但是事与愿违,其实真实按照这种逻辑的操作是这样的:
DOM ======> [【已处理】,【已处理】,【新节点】,【已处理】,【未处理】,【未处理】]
我们会发现在我们的逻辑中本应该被添加到第四位的【新节点】被添加到了第三个位置,为什么呢?
答: 原因是我们之前说过更新节点过程中对应newChildren循环搜索是否有同一节点的是oldChildreno但是在旧子节点列表中只有两个已处理节点,因此如果添加到已处理节点后面的话,就是应该添加到第三位,所以在DOM中也会添加到【已处理】的前面
基于上述情况,我们可以发现真正的创建操作是将新的节点添加到所有未处理节点的前面
这样我们也发现我们仅仅添加了节点,但是并没有对节点进行删除操作,因此我们下面就来说说子节点的删除操作
删除子节点,本质上就是删除那些oldChildren中存在但是newChildren中不存在的节点
举个例子:
DOM ======> [【已处理】,【已处理】,【已处理】,【未处理】,【未处理】]
newChildren => [【已处理】,【已处理】,【已处理】,【新节点】]
oldChildren => [【已处理】,【已处理】,【未处理】,【未处理】]
oldChildren中两个【未处理】节点其实都是应该删除的节点,也就是废弃的节点,
也就是说当newChildren循环一遍以后,如果oldChildren中还有没有处理的节点,那么这些节点就时被废弃且应该删除掉的节点
上述四种操作都是需要的,但不一定都是必须的,通常情况下,并不是所有的子节点的位置都会发生移动,一个节点列表中总会有那么几个节点位置是不变的,那么我们也不必总是循环查找。
那我们是不是可以更快、更加精准的直到要对比的oldChildren中对应节点的位置呢?或者说我们能不能预测以下这个节点可能处于什么位置,这就是策略更新。
假设有这样的场景:
我们仅仅更新了列表中某个数据的内容,那么这时我们newChildren中节点对应的位置是不是应该与oldChildren中对应节点所在的位置相同呢?
是的,我们可以尝试这样的操作:当我们循环到newChildren中的一个节点是,我们先判断oldChildren中对应位置的节点是否与这个节点向对应,如果对应那么就可以直接进行节点更新,如果不对应,我们再进行循环查找,
这样很大程度上避免了每次都循环oldChildren来查找节点,这样大大提升了执行的速度
而查找方式可以分为4种:
新前:newChildren中所有未处理节点的第一个节点,newStartVnode
新后:newChildren中所有未处理节点的最后一个节点,newEndVnode
旧前:oldChioldren中所有未处理节点的第一个节点,oldStartVnode
旧后:oldChildren中所有未处理节点的最后一个节点,oldEndVnode
意思就是我们尝试对比“新前”与“旧前”是否是同一个节点,如果是那就不需要执行移动操作,只需要对这个节点进行更新即可
与上一个相同,只需要使用“新后”与“旧后”对比,如果是那就不需要执行移动操作,只需要对这个节点进行更新即可
当我开始理解这个概念的时候直接弄错了“新后”与旧前的概念,建议看到这里重新看一下上面关于这四个概念的解释
当我们对比“新后”与“旧前”,发现他们是对应节点后,我们应该在更新DOM的同时将节点移动到oldChildren所有未处理节点的最后面
那么问题来了:为什么要移动到oldChildren所有未处理节点的最后面
**答:**我们都知道更新节点时以新虚拟节点未基准的,而我们知道“新后”是newChildren所有未处理节点的最后一个,因此在对应移动时我们需要将真实DOM中的这个节点移动到最后
但是“移动到真实节点的最后面”和“移动到oldChildren所有未处理节点的最后面“有区别吗?
答案是:有的!
假设newChildren、oldChildren和DOM中的首尾节点都已经处理完毕了,那么此时我们对比“新后”与“旧前”,发现他们一致,这时我们如果将DOM中的节点移动到DOM节点的最后,就会发现位置不匹配,DOM中这个节点在之前尾部【已处理】节点的后面,而newChildren中这个节点在尾部【已处理】节点的前面,位置不匹配,因此才需要移动到oldChildren中所有未处理节点的最后面
”新前“与”旧后“的处理原则与上面的”新后”与“旧前“的处理原则时一致的,这里就不细说了,举一反三! -
当以上四种方式都没有找到相同节点时,我们再使用循环的方式去搜索节点,看能否找到,这样就减少了很多的循环操作,有效提高了性能
如果直接让我们来想这个功能的话,其实正常情况下我们直接使用一次循环,从头到尾肯定能保证只有未处理的节点能够进入循环,这样肯定没有错,但是我们有优化策略在先,当我们使用优化策略后,已经处理的节点可能并不在前面甚至可能在未处理节点后面,这时该怎么判断呢?
我们发现上面几种策略都是在列表前后进行操作的,因此我们只要从两边向中间进行循环就可以的,这也就是Vue中采取的策略。
循环策略:
首先定义4个变量:oldStratIdx、oldEndIdx、newStartIdx和newEndIdx
分别记录了oldChildren和newChildren循环开始的位置和循环结束的位置
当开始位置的节点被处理后,就将开始下标后移一位,结束位置的节点被处理后,将结束位置下标前移一位,这样就能保证从两边向中间循环
结束循环的判断条件是这样的:当开始位置大于等于结束位置时就说明所有节点已经遍历过了
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
//内部循环
}
我们可以发现当oldChildren或者newChildren中有一个循环完毕就会退出循环,也就是时可能存在无法覆盖其全部节点对的情况,为什么呢?没有覆盖的节点怎么处理呢?
细细思考一下就会发现,循环是能够找出差异的,这不也正是我们循环的目的吗?
当oldChildren先循环结束时,newChildren中多出来的节点就都是新增的节点,也就是需要新创建的节点
当newChildren先循环结束时,oldChildren中剩余的节点就都是需要删除的,这时就不需要进行循环比对,直接删除就可以了.
当然我们都知道使用Vue渲染列表时,推荐使用属性key,这时因为使用key与index索引建立关系后,相当于拥有了唯一ID,这时查找节点不需要进入循环,只需要直接更新对应位置的节点就可以了
以上就是Vue的虚拟DOM的知识,在虚拟DOM中,最重要的技术就是:patch,虚拟DOM的应用有效提高了性能,减少了可见的 DOM操作,对开发有很大的帮助
这是自更新博客后第一次写这么长对的文章(貌似除了论文还没写过这么多字),受益匪浅,推荐几个受益良多的书籍与博客吧
所有文章首发于我的个人博客幻尘の屋