vue源码之 虚拟dom 及 diff算法

前言
目标:写一篇关于虚拟dom及vue怎么去处理节点(diff算法)

目录:
1.虚拟dom
1.1 什么是虚拟dom
1.2 为什么要引入虚拟dom
1.3 vuejs中的虚拟dom
1.4 总结

2.patch
2.0 vnode是什么
2.1 diff比较方式
2.2 patch介绍
2.3 创建节点
2.4 删除节点
2.5 更新节点
2.6 更新子节点
2.7 源码&总结

1.1 、什么是虚拟dom

在web早期,页面交互效果比较简单,没有那么多复杂的状态需要管理,不需要频繁的去操作dom,js jq就能满足我们的需求。
随着时代发展,页面的功能越来越多,实现的需求越多,要维护的状态也越来越多,对dom的操作也越来越频繁。
vuejs 通过描述状态和dom之间的映射关系是怎么样的,就能将状态渲染成视图。状态可以是javascript总的任意类型。
通常程序在改变时,状态会不断发生变化,每当发生变化时,都会重新渲染。用简单粗暴的方式去解决,不关心状态发生了什么变化,也不关心在哪里更新了dom,只把所有的dom删了,重新生成一份dom,并将其输出到页面显示出来就行。
但是这种方式,会造成相当多的性能浪费。
(了解)不同框架对于这种问题的处理:
angular:脏检查,React:虚拟dom, vue1.0通过细粒度的绑定。

虚拟DOM的解决方法:通过状态生成一个虚拟节点树,然后使用虚拟节点数进行渲染。
在渲染之前,会使用新生成的虚拟节点树和上一次生成的虚拟节点树进行对比,只渲染不同的部分。

1.2、为什么要引入虚拟dom

vue1.0中,使用细粒度,因为粒子太细,每个绑定都有一个对应的watcher来观察状态的变化,会有一些内存开销以及一些依赖追踪的开销。
vue2.0中,选择中等粒度的解决方案引入虚拟dom。组件级别是一个watcher实例,就是说即便一个组件内10个节点使用了某个状态,其实也只有一个watcher在观察这个状态的变化。当这个状态发生变化时,只能通知到组件,然后组件内部通过虚拟dom去进行比对与渲染。

1.3 vuejs中的虚拟dom

在vuejs中,使用模版来描述状态与dom之间的映射关系。通过编译将模版转换成渲染函数(render),执行渲染函数就可以得到一个虚拟节点树,使用这个虚拟节点树就能渲染页面。
在这里插入图片描述

虚拟dom在vuejs中所做的事:

  • 提供与真是dom节点所对应的虚拟节点vnode
  • 将虚拟节点vnode和就虚拟节点oldVnode进行比对,然后更新视图

对两个虚拟节点进行比对是虚拟DOM中最核心的算法(即patch)

总结:之所以需要先使用状态生成虚拟节点,是因为如果直接用状态生成真实DOM,会有一定程度上的性能浪费。而先创建虚拟节点再渲染视图,就可以将虚拟节点缓存,然后使用新创建的虚拟节点和上一次渲染时缓存的虚拟节点进行对比,然后根据对比结果只更新需要更新的真实DOM节点,从而避免不必要的DOM操作,节省一定的性能开销。

2. patch

2.0 vnode

了解patch之前,先简单的了解下vnode
vnode:就是一个普通的javascript对象,可理解为节点描述对象。

// src/core/vdom/vnode.js
export default class VNode {
  constructor (
    tag?: string,
    data?: VNodeData,
    children?: ?Array<VNode>,
    text?: string,
    elm?: Node,
    context?: Component,
    componentOptions?: VNodeComponentOptions,
    asyncFactory?: Function
  ) {
    this.tag = tag // 当前节点标签名
    this.data = data // 当前节点数据(VNodeData类型)
    this.children = children // 当前节点子节点
    this.text = text // 当前节点文本
    this.elm = elm // 当前节点对应的真实DOM节点
    this.ns = undefined // 当前节点命名空间
    this.context = context // 当前节点上下文
    this.fnContext = undefined // 函数化组件上下文
    this.fnOptions = undefined // 函数化组件配置项
    this.fnScopeId = undefined // 函数化组件ScopeId
    this.key = data && data.key // 子节点key属性
    this.componentOptions = componentOptions // 组件配置项 
    this.componentInstance = undefined // 组件实例
    this.parent = undefined // 当前节点父节点
    this.raw = false // 是否为原生HTML或只是普通文本
    this.isStatic = false // 静态节点标志 keep-alive
    this.isRootInsert = true // 是否作为根节点插入
    this.isComment = false // 是否为注释节点
    this.isCloned = false // 是否为克隆节点
    this.isOnce = false // 是否为v-once节点
    this.asyncFactory = asyncFactory // 异步工厂方法 
    this.asyncMeta = undefined // 异步Meta
    this.isAsyncPlaceholder = false // 是否为异步占位
  }
}

vnode类型有:

  • 注释节点: 只有两个属性 isComment 是true,text为注释内容
  • 文本节点: tag data children为undefined, text不为空
  • 元素节点 有tag data children
  • 组件节点,有以下独有属性:componentOptions 组件节点的选项参数,componentInstance组件的实例,vuejs的实例。
  • 函数式节点:独有属性:functionalContext, functionalOptions
  • 克隆节点:将现有节点的属性赋值到新节点中,让新创建的节点和被克隆节点的属性保持一直。作用:优化静态节点和插槽节点。

vnode 和真实dom的区别:

<div>
    <p>123</p>
</div>
// 对应的virtual DOM(伪代码):

var Vnode = {
    tag: 'div',
    children: [
        { tag: 'p', text: '123' }
    ]
};
2.1 diff 比较方式

diff可是逐层比较的,如果第一层不一样那么就不会继续深入比较第二层了。

在这里插入图片描述
举个栗子:

<!-- 之前 -->
<div> <!-- 层级1 -->
 <p> <!-- 层级2 -->
 <b> aoy </b> <!-- 层级3 -->
 <span>diff</Span>
 </P>
</div>
<!-- 之后 -->
<div> <!-- 层级1 -->
 <p> <!-- 层级2 -->
 <b> aoy </b> <!-- 层级3 -->
 </p>
 <span>diff</Span>
</div>

我们可能期望将<span>直接移动到<p>的后边,这是最优的操作。但是实际的diff操作是移除<p>⾥的
<span>在创建⼀个新的<span>插到<p>的后边。因为新加的<span>在层级2,旧的在层级3,属于不同层级的⽐较

2.2 patch

虚拟DOM最核心的部分是patch,patch也可以叫patching算法,实际作用是在现有DOM上进行修改来实现视图的目的。
原因:DOM操作的执行速度远不如javascript的运算速度快。因此,把大量的DOM操作搬运到js中,使用patching算法来计算出真正需要更新的接单,最大限度地减少DOM操作,从而显著提升性能。本质上其实是使用js的运算成本来替换DOM操作的执行成本,而js的运算速度要比DOM快很多。

patch的目的其实是修改DOM节点,也可理解为渲染视图(对比两个Vnode的差异只是一部分)
对现有DOM上进行修改需要做三件事:
创建新增的节点;删除已经废弃的节点;修改需要更新的节点。

新增节点:当oldVnode不存在而vnode存在时,需要用vnode生成真是的DOM元素并将其插入到视图当中去。
删除节点:当oldVnode存在而Vnode不存在时,需要把他从dom中删除。
更新节点:当新旧两个节点时相同的节点时,需要对这两个节点进行比较细致的对比。

2.3 创建节点

事实上,只有三种类型的节点会被创建并插入到DOM中:元素节点,注释节点,文本节点。
判断是否为元素节点:判断tag,有该属性则为元素节点,接着就调用当前环境的createElement方法(浏览器环境下,document.createElement来创建真实的节点。当一个元素节点被创建成功后,接下来要做的就是将它插入到指定的父节点中,parentNode.appendChild,插入到指定的父节点中。如果元素节点有子节点,它的子节点也创建出来并插入到这个创建出来的节点下,创建接子节点的过程时一个递归过程。
判断是否为注释节点:没有tag,存在唯一标识isComment,createComment
判断是否为文本节点:createTextNode
以下创建接待你并渲染到视图的过程:

创建节点
vnode是元素节点?
创建元素节点
vnode是注释节点?
插入到指定父节点中
创建注释节点
创建文本节点
2.4 删除节点

oldVnode有,vnode没有的情况下,需要将元素从视图中删除

// 删除一组指定的节点
function removeVnodes (vnodes, startIdx, endIdx) {
	for (; startIdx <= endIdx; ++startIdx) {
		const ch = vnode[startIdx]
		if (isDef(ch)) {
			removeNode(ch.elm)
		}
	}
}
// 删除视图中单个节点
const nodeOps = {
	removeChild(node, child) {
		node.removeChild(child)
	}
}
function removeNode (el) {
	const parent = nodeOps.parentNode(el)
	if (isDef(parent)) {
		nodeOps.removeChild(parent, el)
	}
}

Q:为什么不直接使用parent.removeChild(child)删除节点,而是将这个节点操作封装成函数放在nodeOps中呢?
A:涉及跨平台渲染知识,跨平台渲染的本质是在涉及框架的时候,要让框架的渲染机制和DOM解耦。

2.5 更新节点

2.5.1 静态节点
更新节点时,先判断新旧两个虚拟节点是否为静态节点,是,则不需要更新操作,可以直接跳过更新节点的过程。静态节点指的是那些一旦渲染到界面上后,无论日后状态如何变化,都不会发生任何变化的节点。(比如无任何数据绑定、判断之类的)

2.5.2 新虚拟节点有文本属性
根据新节点是否有text属性,
1)若新节点有text属性,那不论之前旧的是什么,都用setTextContent(浏览器环境下是node.textContent)
2)如果之前的旧节点也有文本,且与新节点相同,那么不需要执行setTextContent。

2.5.3 新虚拟节点没有文本属性
那么他就是一个元素节点。元素节点通常有子节点,children属性,但也有可能没有子节点。存在两种情况
1)有children:两种情况,旧节点中是否也有children,
如果也有children,新旧两个虚拟节点的children进行进一步的详细的对比并更新。更新坑会移动某个子节点的位置,也可能会删除或新增某个节点,具体更新children过程在2.5 中详细介绍。
如果无children:说明旧节点,要么是一个空标签,要么是有文本的文本节点,如果是文本节点,那么文本清空让它变成空标签,然后将新虚拟节点中的children挨个创建成真是的dom元素节点并将其插入到视图中的DOM节点下面。
2)无children:新创建的,既没有text,也没children,说明这是个空节点。这是不管旧节点是什么,对视图进行操作,有什么删什么,最后达到视图中是空标签的目的。

2.6 更新子节点

对比两个子节点列表,首先需要做的事情是循环。循环newChildren,每循环到一个新姐爱你,就去就子节点列表中,找到和当前节点相同的那个旧子节点。如果找不到,说明当前子节点是由于状态改变而新增的节点,要惊醒创建接待你并插入视图的操作;如果找到了,就做更新操作,如果找到的旧子节点的位置跟新子节点的位置不同,则需要移动节点。

2.6.1 更新策略

针对新增、更改、移动、删除节点等操作进行讨论。
1)创建子节点
前面提到,新旧两个子节点列表是通过循环进行对比的。所以创建节点的操作是在循环体内执行的,其具体的实现是在oldChildren中寻找本次循环所指向的新子节点。如果没有找到说明是本次循环所指向的新子节点是一个新增节点。对于新增节点,需要执行创建节点的操作,并将新创建的节点插入到oldChildren中所有未处理节点(未处理就是没有进行温和更新操作的节点)的前面,当节点成功插入DOM,这一轮的循环。
Q:为什么插入到oldChildren的所有未处理节点的前面?
A : 如图,最上面的Dom节点是视图中真实的Dom节点。左下角是新创建的虚拟节点,右下角是旧的虚拟节点。下图表示已经对前两个子节点进行了更新,当前正在处理第三个节点。当右下角的虚拟子节点中找不到与左下角的第三个节点相同的节点时,证明他是新增的节点,这是需要创建节点并插入到DOM中,插入的位置是所有未处理节点的前面。
在这里插入图片描述
Q:插入到已处理节点的后面不行吗?
A:不行的,如果是新节点后面也是新节点呢,那么这两个新增节点的顺序会反了。因为我们使用虚拟节点进行对比,而不是真是的DOM节点做对比,所以左下角的和右下角的虚拟节点做对比,而右下角的节点中,表示已处理的节点只有两个,不包括我们新插入的节点,所以用插入到已处理节点后面这样的逻辑来插入节点,会插入一个错误的位置。

2)更新子节点,本质上是当一个节点同时存在与newChildren 和oldChildren中时需要执行的操作。
如果两个节点是同一个节点,并且位置相同,这种情况喜爱只需要进行更新节点的操作即可。若位置不一致,除更新真实节点外,还要对这个真实Dom节点进行移动节点的操作

3)移动子节点。
同一节点不同位置,以新虚拟节点的位置为基准进行移动。
通过node.insertBefore(),可以成功将一个已有节点到一个指定的位置。
把需要移动的节点移动到所有未处理节点的最前面。

4)删除子节点
当newChildren中的左右节点都被循环一边后,也就是循环结束后,如果OldChildren中还有剩余的没有被处理的节点,那么这些节点就是被抛弃,需要删除的节点。

2.6.2 优化策略

针对一些位置不变的或者说位置可以预测的节点,不需要循环来查找。只需要尝试相同位置的两个节点来对比是否是同一个节点;如果恰巧是同一个,直接进入更新节点的操作;如果不是,则用循环的方式来查找。
很大程度地避免循环oldChildren来查找节点,从而使执行速度得到很大的提升。
共有4种查找方式:
新前与旧前、新后与旧后、新后与旧前、新前与旧后

新前:newChildren 新虚拟节点中所有未处理的第一个节点。
新后:newChildren 新虚拟节点中所有未处理的最后一个节点。
旧前: oldChildren 新虚拟节点中所有未处理的第一个节点。
旧后: oldChildren 新虚拟节点中所有未处理的最后一个节点。

1)新前与旧前
若是同一节点,因为他们位置相同,所以只需要更新节点即可,如果不是,下一种查找方法。

2)新后与旧后
若是同一节点,因为位置也相同,所以只需要更新节点即可。

3)新后与旧前
如果是同一节点,因为他们位置不同,除了更新节点外,还需要执行移动节点的操作,移动位置为oldChidren中所有为处理节点的最后面。更新节点是以新虚拟节点为基准,子节点也不例外。
Q:为什么是oldChidren中所有为处理节点的最后面
A:当真实DOM子节点左右两侧已经有节点被更新,只有中间这部分节点未处理时,“新后”这个节点是未处理节点的最后一个节点。所以真实DOM节点移动位置时,需要移动到oldchildren中所有未处理节点的最后面。只有移动到未处理节点的最后面,他的位置才能与新后这个节点位置相同。

4)新前与旧后
这两个节点的位置不同,所以除了更新节点外的操作外,还需要移动节点的操作。移动位置到oldChildren中所有未处理节点的前面,与上面的新后与旧前的逻辑一样。
也就是说,已更新过的节点都不用管,因为更新过的节点无论是节点的内容或者节点的位置,都是正确的,更新完后面旧不用管了。所以,只需要在所有未更新的节点区间内进行移动和更新操作即可。

比较图解:
在这里插入图片描述
第一步:旧前新前:1比1 同,oldStartIdx++,newStartIdx++
第二步:旧前新前:2比5,不同, 旧后新后:6比6,同,oldEndIdx–,newEndIdx–,
在这里插入图片描述
第三步:旧前新前:2:5 不同,旧后新后:5:2,不同,旧前新后,2:2,同,oldEndIdx–,newEndIdx–,把2放到oldEndIdx后面
在这里插入图片描述
第四步:旧前新前:3:5 不同,旧后新后:5:7,不同,旧前新后,3:7,不同,旧后新前:5:5,同,oldEndIdx–,newStartIdx++,把5放到oldStartIdx前面
在这里插入图片描述
第五步:用四种方案比较,7在old Vnode中都没有,那就将7放到oldStartIdx前面,然后newEndIdx–, 此时newEndIdx<newStartIdx,那么比较结束,此时oldStartIdx和oldEndIdx之间(包括这两个)是删除的节点,将其删除即可
在这里插入图片描述
如果设置了key:
在第五步,不会去循环虚拟列表,而是在一个map中查找该key,然后移动。
算法复杂度为O(n)

没设置key:最好O(n),最坏的O(n^2)

2.6.3 哪些节点是未处理的

Q:怎么分辨哪些节点是处理过的,哪些是未处理过的?
A:逻辑都是在循环体内处理的,所以只要让循环条件保证只有未处理过的节点才能进入循环体,就能达到忽略已处理过的节点从而只对未处理节点进行对比和更新操作。

Q:从上面的策略看出,虚拟节点对比都是从最前或最后进行比较的,那怎么实现从两边到中间的循环呢?
A:准备四个变量oldStartIdx、oldEndIdx、newStartIdx、newEndIdx,分别表示旧虚拟节点的开始位置的下标,结束位置的下标,新虚拟节点开始位置的下标,结束位置的下标。开始位置所表示的节点被处理后,就向后移一个位置,结束位置的节点被处理后,就向前移动一个位置。
当开始位置大于等于结束为止,说明所有接待你都遍历过了

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
	// 做点什么
}

Q:怎么知道那个是新增的节点?
A:如果oldChidren先循环完了,newChidren中还有节点,那么这些节点就是新增的节点,即newStartIdx 到oldStartIdx这区间的节点就是新的。所以如果是newChidren先循环完毕,oldChildren还没,则表示有删除的节点,删除节点的区间也在oldStartIdx 到oldEndIdx

2.7 总结

贴下patch源码

function patch (oldVnode, vnode) {		// 参数:新节点 旧节点
    // some code
    if (sameVnode(oldVnode, vnode)) {	// 是否为同一个节点
        patchVnode(oldVnode, vnode)
    } else {
        const oEl = oldVnode.el 				// 当前oldVnode对应的真实元素节点
        let parentEle = api.parentNode(oEl)  // 父元素
        createEle(vnode)  // 根据Vnode生成新元素
        if (parentEle !== null) {
            api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
            api.removeChild(parentEle, oldVnode.el)  // 移除以前的旧元素节点
            oldVnode = null
        }
    }
    // some code 
    return vnode
}

function sameVnode (a, b) {
  return (
    a.key === b.key &&  // key值
    a.tag === b.tag &&  // 标签名
    a.isComment === b.isComment &&  // 是否为注释节点
    // 是否都定义了data,data包含一些具体信息,例如onclick , style
    isDef(a.data) === isDef(b.data) &&  
    sameInputType(a, b) // 当标签是<input>的时候,type必须相同
  )
}
patchVnode (oldVnode, vnode) {
    const el = vnode.el = oldVnode.el
    let i, oldCh = oldVnode.children, ch = vnode.children
    if (oldVnode === vnode) return
    if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
        api.setTextContent(el, vnode.text)
    }else {
        updateEle(el, vnode, oldVnode)
        if (oldCh && ch && oldCh !== ch) {
            updateChildren(el, oldCh, ch)
        }else if (ch){
            createEle(vnode) //create el's children dom
        }else if (oldCh){
            api.removeChildren(el)
        }
    }
}

patchVnode 这个函数做了以下事情:

  • 找到对应的真实dom,称为el
  • 判断Vnode和oldVnode是否指向同一个对象,如果是,那么直接return
  • 如果他们都有文本节点并且不相等,那么将el的文本节点设置为Vnode的文本节点。
  • 如果oldVnode有子节点而Vnode没有,则删除el的子节点
  • 如果oldVnode没有子节点而Vnode有,则将Vnode的子节点真实化之后添加到el
  • 如果两者都有子节点,则执行updateChildren函数比较子节点,这一步很重要

更新子节点:

updateChildren (parentElm, oldCh, newCh) {
    let oldStartIdx = 0, newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    let oldKeyToIdx
    let idxInOld
    let elmToMove
    let before
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (oldStartVnode == null) {   	// 对于vnode.key的比较,会把oldVnode = null
            oldStartVnode = oldCh[++oldStartIdx] 
        }else if (oldEndVnode == null) {
            oldEndVnode = oldCh[--oldEndIdx]
        }else if (newStartVnode == null) {
            newStartVnode = newCh[++newStartIdx]
        }else if (newEndVnode == null) {
            newEndVnode = newCh[--newEndIdx]
        }else if (sameVnode(oldStartVnode, newStartVnode)) {
            patchVnode(oldStartVnode, newStartVnode)
            oldStartVnode = oldCh[++oldStartIdx]
            newStartVnode = newCh[++newStartIdx]
        }else if (sameVnode(oldEndVnode, newEndVnode)) {
            patchVnode(oldEndVnode, newEndVnode)
            oldEndVnode = oldCh[--oldEndIdx]
            newEndVnode = newCh[--newEndIdx]
        }else if (sameVnode(oldStartVnode, newEndVnode)) {
            patchVnode(oldStartVnode, newEndVnode)
            api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
            oldStartVnode = oldCh[++oldStartIdx]
            newEndVnode = newCh[--newEndIdx]
        }else if (sameVnode(oldEndVnode, newStartVnode)) {
            patchVnode(oldEndVnode, newStartVnode)
            api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
            oldEndVnode = oldCh[--oldEndIdx]
            newStartVnode = newCh[++newStartIdx]
        }else {
           // 使用key时的比较
            if (oldKeyToIdx === undefined) {
                oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
            }
            idxInOld = oldKeyToIdx[newStartVnode.key]
            if (!idxInOld) {
                api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                newStartVnode = newCh[++newStartIdx]
            }
            else {
                elmToMove = oldCh[idxInOld]
                if (elmToMove.sel !== newStartVnode.sel) {
                    api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                }else {
                    patchVnode(elmToMove, newStartVnode)
                    oldCh[idxInOld] = null
                    api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
                }
                newStartVnode = newCh[++newStartIdx]
            }
        }
    }
    if (oldStartIdx > oldEndIdx) {
        before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
        addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
    }else if (newStartIdx > newEndIdx) {
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
}

这个函数的作用:

  • 将Vnode的子节点Vch和oldVnode的子节点oldCh提取出来
  • oldCh和vCh各有两个头尾的变量StartIdx和EndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldCh和vCh至少有一个已经遍历完了,就会结束比较。

Q:在上面的循环中一开始就先判断oldStartVnode oldEndVnode是否存在,如果不存在则直接跳过本次循环,进行下一轮的循环,(也就是说,如果这个节点不存在,则跳过这个节点,处理下一个节点)
A:主要是为了处理旧节点已经被移动到其他位置的情况。移动节点时,真正移动的时真实的DOM节点。移动后,为了防止后续重复处理同一节点,旧虚拟节点就会被设置成undefined,用来标记这个节点已经被处理并且移动到其他位置。

使用key:
在vuejs的模版中,如果渲染时使用了key,这个属性可以标示一个节点的唯一ID,vue官方非常推荐使用这个属性。在前面提到,更新子节点时,需要从oldChildren中循环去找一个节点。vue会去建立key 与index索引的对应关系时,就会去生成一个key对应这一个节点下标这样一个对象。也就是如果在节点设置了属性key,那么在oldchildren中找到相同节点时,可以直接通过key拿到下标,从而获取key,这样就不需要根据循环来查找节点。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值