坏蛋Dan
知乎@坏蛋Dan
发布时间:2024.1.4

前言

昨天我们分析了一小部分patch过程,主要是在分析unmount做了什么

坏蛋Dan:vue runtime源码分析学习——day6:patch打补丁part1:注销旧节点

今天我们继续往下分析


根据不同类型做不同处理

这里开始针对不同type做不同处理

processText

如果是第一次patch,那么n1必然是null,此时直接执行hostInsert

我们来看下这个hostCreateText

实际上它就是document.createTextNode[1]这个api

hostInsert方法实际上就是Node.insertBefore[2]这个api

那么这里的逻辑就很简单了,由于是第一次patch,即没有dom可以使用,所以调用hostCreateText创建一个textNodedom,然后赋值给n2.el,然后通过parent将它插入到anchor的前面。

这个anchor就是前面我们获取的nextHostNode,也就是n1所在位置的下一个节点。

而如果不是第一次patch,这个时候直接复用n1dom,因为它俩都是文本节点,没必要重新创建一个,浪费性能。如果文本节点的内容不同,则将新节点的文案赋值给这个domhostSetText方法实际上就是赋值处理。

这里你可能有些疑惑,这里不用担心两个dom的类型不一样吗?前面只是判断n2type而已。实际上昨天分析的内容就是在处理这个问题,当俩type不同的时候是直接注销n1的。

此时n1会因为没有引用被垃圾回收


processCommentNode

这个方法是用来处理注释节点的。

注释节点也是有必要保留的,因为比如v-if条件不满足的时候就会变成一个注释节点占位,占位的目的就是为了保证dom的位置的正确。

扯远了,回到代码中。

  • hostCreateComment就是document.createComment[3]

hostInsert前面说过了,这里就不多说了。

然后是如果不是第一次patch, 新节点直接复用旧节点的dom,因为注释节点并不支持动态。


Static VNode

静态的节点只需要渲染一次,当然,如果是开发阶段热更新,那另说。

那么什么情况下会是staticNode呢?

通过createStaticVNode创建的

我们先来看下mountStaticNode

直接就是调用hostInsetStaticContent方法

这个方法执行有两种场景,一种是innerHTML的方式,另一种还是insertBefore

我们编译阶段的时候有分析过stringifyStatic,如果静态节点达到了某个阈值,那么将这块节点stringify,然后runtime的时候直接innerHTML插入。具体分析可以去看我之前的文章。

这里有个点很有趣,这里是直接通过一个节点早就创建好的templatedom,然后将我们的staticString直接innerHTML插入到它里面,然后再从它里面拿。这样就用创建documentFragment[4]了。

然后我们再来看下patchStaticNode方法

因为热更新的原因,所以并不能只渲染一次。

如果新旧的staticNode不同内容不同,直接就将旧的移除,然后插入新的。这里可以直接判断的原因是children就是一串字符串,所以可以直接比较。

如果新旧的dom字符串都一样,那么直接复用n1的。

removeStaticNode: 这个就不看代码了,就是在移除旧的所有n1的内容,包括它的anchor


processFragment

如果是开发模式热更新,就需要将patchFlagoptimizeddynamicChildren都置为false,强制所有的节点都渲染。

  • fagmentSlotScopeIds是用于css :slotted selector的,简单的说就是支持子组件对slot中的dom样式做修改,这在2.x是无法实现的,因为slot的内容被视作是父组件里的一部分。

如果是第一次patch,则将两个空文本节点插入到这个fragment的前后,作为锚点anchor。注意这俩anchor和我们之前通过nextSideHost获取的anchor是不同的。

mountChildren方法我们之前说过了,渲染这个fragment

我们来看下patchBlockChildren

这里简单的说就是在diff两个dynamicChildren,也就是diff一块区域block,选择parent然后递归patch处理。

旧节点的dom可能没有,因为它可能是一个被Suspense包裹的error状态的async component,这种场景是不会渲染的。

在旧节点确实被渲染的情况下,如果旧的节点类型是fragment | 和新的节点类型不一致 | 是自定义组件或者是Teleport,这个时候的parent则是旧节点的parentNode

这三种场景是一定会使用到parentNode的。

如果不是,那么直接用传入的父节点的container,为什么呢?因为这些剩下的类型不一定会用到这个parentNode,不过为了避免拿到的是null,还是得绑定上一个。(这一段先mark,暂时未看懂这么做的缘由)。

回到processFragment里面,那么什么情况下会进入到这个patchBlockChildren里面呢?

首先这个fragment得是stable的,然后新旧的节点都需要有dynamicChildren也就是被跟踪的子孙节点。

我们前面说的v-for是基本没有可能的,除非你这个for的目标是一个静态的,比如数字10或者一个对象。

而如果不是,还有两种场景

第一种,没有绑定key

第二种,有key

但是v-for里面的item还是有必要判断下的,因为它们自己可能就是一个dynamicChildren,比如用<template>包裹的div,它自身就是一个fragment,这个时候就很有必要进去到里面去diff了。

又比如同<template>里的子项超出两个,这个时候会单独给它搞个fragment。我们前面分析编译阶段的时候有说过这种情况,3.x虽然默认会保留template标签,但是如果你这个template是搭配v-if或者v-for等,那么这个时候你的这个template就会被移除,如果这个template里面超出两个项,那么就会有问题,所以这个时候就需要一个fragment包裹起来。

我们再来看下traverseStaticChildren方法

这个方法看名字就知道是用来遍历静态节点的,那么为什么要遍历静态节点呢?

热更新就不用多说了,开发阶段基本都得full patch

然后如果是新的节点带有一个key或者是组件的根节点,那么它有可能会被移动。所以为了确保移动的位置是正确的,新节点需要继承旧节点的el,也就是对应的dom

这里需要注意的是注释节点也是需要继承的,因为它们还有别的作用,并不能丢失,在这个过程中如果丢失很有可能导致后面节点移动失败。

回到processFragment方法中,最后是处理v-forstable的场景,当然,并非只有v-for这一种情况,还有可能是手动的fragment

前面有说过v-for如果不是stable的,那么就有两种情况,一种是绑定了key的,另一种自然就是没有key的。

由于我们的代码是经过编译之后才会有这俩场景,所以这个时候我们的v-foritem都是block,所以不用做跟踪处理。

这个时候会调用patchChildren方法,我们来看下这个方法。

带有key的执行patchKeyedChildren(也有可能只有部分itemkey),如果没有则执行patchUnkeyedChildren

  • patchKeyedChildren:放下面分析,这里的diff应该是大家最熟悉的patch阶段。
  • patchUnkeyedChildren:这个方法就不用分析了,由于新旧的节点组里的元素都没有key,所以可以直接一把梭,多的插,少的删。

如果不是上面两种场景,那么就是普通的场景,旧的节点可能是文本,数组或者啥也没有。

  • 文本:如果新的节点是文本,且旧的节点类型是Array,那么这个时候直接把旧的数组整组移除了。这个unmountChildren就不看代码了,相信大家也都知道是在干什么的。然后暴力判断俩是否相同,直接就覆盖,因为只要是不同那就一定需要文本覆盖。hostSetElementText就不多说了。
  • 数组:如果前后两个都是数组,那么就按照patchKeyedChildren来算,如果新的不是数组,那就说明新的是空节点,直接将旧的allin删除。
  • 啥也没有:如果旧的是文本,而新的啥也没有,这个时候直接将节点赋值为空文本。如果新的是一个数组,那么这个时候直接渲染。

processElement

这个方法很简单,如果是第一次patch,直接调用mountElement将新的 vnode渲染即可。

而如果不是,则调用patchElement分析两个的不同。

  • mountElement这个方法我们放到下面去分析,看名字就知道是将vnode转换成真实dom然后渲染到浏览器上
  • patchElement也是放到下面去分析。简单的说就是对比新旧俩节点内容的不同,然后patch。这里面基本是在做prop的处理,因为到这里两个节点的类型、key等都确定是相等的了。不相等的老早就被拉出去patch了。

processComponent

代码很好理解。如果是keep-alive包裹下的组件,交给keep-alive处理。

如果不是,判断此时新的节点是否为空,为空执行mountComponent,不为空则调用updateComponent

  • mountComponent:由于篇幅问题,这里就放到后面文章中去分析。
  • updateComponent:同上。

处理suspense

跳过,后面单独分析


处理teleport

同上


patchKeyedChildren

就是下面的diff算法,这里就不多说了


mountElement

  • hostCreateElement:这个方法我们就不看代码了,就是document.createElement

如果是文本节点,将文案赋值给这个dom

如果是这个节点数据类型是数组,遍历递归调用patch它们。

这里有一点需要注意,数组这个是先mount子节点,这么做是防止父节点中有对子节点的依赖拿不到的问题,比如props

如果存在指令@vue:xx,则触发,这个之前有说过了,这里就不多说了。不过这里是触发created

然后是处理props

如果这个props不是value或者保留字段就调用hostPathProp方法

我们来看下这个patchProp方法

patchProp

这个方法看名字就知道是在给vnode的属性转换成真实dom的属性。

  • patchClass

这个代码很好懂,不过这里还有一个和transition相关的,并且也是最核心的,如果我们的dom是包裹在transition里的,那么dompatch的时候就会加上这些样式。

  • patchStyle:来看下代码

这个方法也很好懂,如果新的值不是一个字符串,那么就肯定是一个对象,这个时候迭代这个对象,一个一个调用setStyle处理。

而如果之前的style还在,并且也是一个对象,迭代这个旧对象,如果这个旧对象的key对应的新对象中的数值是null,那么就直接将这个样式给去掉。

如果不能再新的值,那就直接remove整个style即可。

而如果这个新的值是一个字符串,那么这个时候将原来stylecssText覆盖为最新的。

如果元素存在_vod这个属性,那么表示这个元素有一个v-show指令。此时的display属性不能由style控制,它得由我们的handle来控制,所以需要做覆盖处理,将原来的值覆盖最新的style里的display来保持原来的效果。

我们再来看下setStyle方法

这个方法也很好懂,如果传进来的是一个null,直接将值赋值为空字符串。

而如果是一个数组,那么这个时候将这个值递归调用setStyle挨个处理。

而如果名字开始带有--,说明这个是一个css变量,得通过setProperty](CSSStyleDeclaration.setProperty() - Web APIs | MDN (mozilla.org))来加上,因为这个是自定义的样式名。

而一般的指令都会带上自动的前缀,相当于postcssautoprefixer做的事情。

如果你这个样式还带有!important,那么也会给你加上。

然后我们回到patchProp方法中。

isOn方法我们就不看代码了,就是判断你这个是不是v-on绑定的指令的。

如果有,那么需要过滤v-model的指令,那个不再需要处理了,因为编译阶段我们已经把它变成了prop + emit的效果,其它调用patchEvent方法绑定。

我们来看下patchEvent方法做了什么。

这里有个点我忘了说了,我们在编译阶段时会把v-bind的指令都给加上on开头,比如v-bind:click会变成onClick,所以这个方法中实际上是在处理事件绑定,那么为什么要过滤v-model呢?因为编译阶段我们把v-model拆解为prop + emit的方式,而emit出来的名字就是onUpdate:,所以这个时候就需要过滤处理。

而这些事件都放到了el._vei里面,这个是一个对象。后面就叫这个_vei里的项为触发器了。

如果这个触发器之前就有了,那么将新的事件传覆盖旧的即可。

而如果之前不存在,那么久加到这个触发器集合上。并且给这个元素加上监听上这个事件。

而如果之前存在事件,但是现在不存在了,那么这个时候就去掉这个事件监听。

我们再来看下这个createInvoker方法做了什么。实际上看名字就知道是创建一个触发器。

这个方法里面涉及到了事件循环机制和异步问题。

这里的注释我们来看下:

内部单击事件触发patch,事件处理程序在修补程序期间附加到外部元素,并再次触发。发生这种情况是因为浏览器在事件传播之间触发微任务刻度。 vue3template模式不再有这个问题,但理论上对于手写渲染函数来说(也就是通过h函数来写的)仍然是可能的。解决方案:在附加处理程序时保存时间戳,并将时间戳附加到 Vue 首次处理的任何事件(以避免不一致的事件时间戳实现或从 iframe 触发的事件,例如 #2513) 仅当传递给它的事件被触发时,处理程序才会触发。

简单地说就是可能存在该事件在patch阶段二次触发的问题,所以这里给加上时间戳用来避免这个问题。

其它没啥好说的了。

然后我们再来看下这个patchStopImmediatePropagation方法做了什么。

这里就是重写stopImmediatePropagation方法, 遇到冒泡的给它绕过,然后触发事件。

然后回到我们的patchProp方法中。我们继续往下看。

如果是是以.开头或者^开头的key,或者是需要通过setAttribute才能插入的prop,那么就调用patchDOMProp方法。

.开头的属性我们在编译阶段就遇到过,v-bind.prop语法糖写法。

^开头我暂时不清楚是什么场景,有些眼熟。。。有知道的大佬可以评论里说一下,不胜感激!

我们再来看下shouldSetAsProp方法

方法很简单,就是知道这个key是否是需要通过setAttribute才能插入的。

不过在开始分析之前,我们有一个之前一直疏忽掉的点要补充,那就是propertyattribute的区别。

从翻译的角度来说,它俩确实都叫"属性"。但是实际上它们是有区别的。

MDN中对它俩的解释就可以看出来:

  • attribute:An attribute extends an HTML or XML element, changing its behavior or providing metadata.An attribute always has the form name="value" (the attribute's identifier followed by its associated value).
  • property:A JavaScript property is a member of an object that associates a key with a value. A JavaScript object is a data structure that stores a collection of properties.

简单地说:一个是针对HTML Element的,而另一个则是针对于Object,在我们这个环境中指的是DOM即文档对象模型,当然,也是个对象。

svg的场景我们就不分析了。

如果是spellcheckdraggable或者translate,则返回false。这三个属性都是global attribute并且都是枚举类型。由于它们接收的值都是布尔值,而我们的值是字符串,这就会导致我们给他一个'false'会被强制转换为布尔值true。所以这里的视它仨作attribute

和它们类似的contentEditable就完全没问题,因为它是一个dom(我个人猜测背后的实现原理类似video标签,有可能是shadow DOM)。

  • form属性也得视作attribute,因为在form元素上是只读的。
  • listinput标签中也得是attribute
  • textarea同上。
  • 原生事件如果它的值也是字符串,那么也得视作是attribute

最后放回对应的key,这个keyproperty

然后我们回到patchProp方法中,来看下patchDOMProp方法。

如果是innerHTML或者textContent,那么这个时候批量移除之前的节点,然后将属性插入到el中。这个应该是仅针对于svg的。

而如果属性名是value且标签名不为PROGRESS且标签名不包含-符号,一般是自定义元素之类的。先保留原来的值,如果新的值和旧的不相同或者这个标签是OPTION,那么就将新的值覆盖原来的值。

如果新的值为null,直接removeAttribute处理。

而如果属性名不是value

先是判断值是否为空,如果为空,判断就的值是否存在,并且判断它的类型。如果类型为boolean,那么就将这个新的空值从字符串类型转换为布尔值。而如果是字符串类型并且新的值是null,那么就新的值转换为空字符。然后标记为needRemove。如果是数字,直接赋值为0,同时标记为needRemove

兼容性代码老规矩就不分析了。

最后来到新值覆盖旧值阶段,当然,这个阶段是有可能失败的,因为有的可能是readonly等。如果存在需要remove的属性就直接调用element.removeAttribute

然后我们回到patchProp方法中。

剩下的场景中还有一种需要特殊处理的,那就是<input type="checkbox">何种类型的。它有俩特殊属性:true-value:false-value。需要将_trueValue/_falseValue赋值为新的值。

最后就是常规的属性处理了,调用patchAttr方法,将它们插入到element里的属性中去。

最后再来简单的看下这个patchAttr方法做了什么。

注意这里是在处理attribute,而不是propertyproperty前面已经处理过了。

兼容性的跳过

这个specialBooleanAttr自然就是值是boolean类型的。

然后就是set入值,如果是需要插布尔类型值的直接传空字符,这样就会被判定为false

那么这个patchProp方法就说完了,这个方法就和它的名字一样,在将vnode的属性插入到真实dom里。


然后回到我们的mountElement方法中。

上面分析patchProp是针对key不是value的场景,而keyvalue需要特别处理。

在 DOM 元素上设置value的特殊情况:

  • 它可以对顺序敏感(例如,应该在最小/最大之后设置,#2325,#4024)
  • 需要强制 (#1471)

#2353 建议添加另一个渲染器选项来配置它,但属性影响是如此有限,值得对其进行特殊大小写。这里是为了降低复杂性。(特殊大小写也不应该影响非 DOM 渲染器)

简单地说,就是value这个字段在set的时候需要注意顺序,它会受别的属性值影响。

所以这里干脆把它留到最后了。

接着触发vnode自身的生命周期方法:onVNodeBeforeMount

然后给这个dom插入scopeId

之后触发dom自己的beforeMount

接着触发@vue:beforeMount注册的回调。

我们来看下这个setScopeId做了什么。

ok,没啥好看的。

然后开始插节点到container里。

执行完毕之后开始触发@vue:mounted和节点自身的mounted

这里需要注意,如果是组件创建的过程中的生命周期函数,触发顺序是先触发child的生命周期回调,而后才触发父组件中通过@vue:xxx监听的生命周期。

而注销的时候则相反,是先触发父组件中监听的,然后才触发子组件自己的。


patchElement

这个方法里面的一些方法前面都有说到过,所以这里就不多说了。

  • toggleRecurse:代码就不看了,就是禁止递归触发update回调。

mountElement不同的是,这里触发的是beforeUpdateonUpdated这两个生命周期方法。

这里又有一段hmr相关的代码,如果是开发阶段热更新,需要进行full diff,也就是不能skip等操作。

如果存在需要跟踪的子孙节点,这里就调用patchBlockChildren处理它们,这个方法前面说过了,这里就不多说了。

如果该节点的patchFlag &gt; 0,说明是可以跟踪的属性。

如果需要full diff,那么就调用patchPropsdiff匹配。这种一般是带有动态key的场景。

另外FULL_PROPSCLASSSTYLE以及PROPS互斥。

而如果不需要,就处理几个可能会引起变得的即可。

PatchFlags.CLASS表示class中有动态的。

同样PatchFlags.STYLE表示style中有动态的。

hostPatchProp就是patchProp方法,这里就不多说了。

PatchFlags.PROPS表示元素的classstyle没有动态,但是有动态的props

有些绕了,实际上搞个例子就很简单了,比如这样::[foo]="bar"。这种就是动态的,得在runtime的时候才能确定下来。

记住尽量不要用动态的,这样会有性能问题。

回到我们的代码中,对于PROPSFULL_PROPS来说,基本上就是full diff了,其中PROPS会对比新旧的值,不相同或者属性名是value时才会去patchProp

  • PatchFlags.TEXT表示这个节点带有动态的文本。这个时候由于n1n2type必然相等,所以这里直接就可以判断两个children也就是字符串是否相同就可以判断是否需要更新。

而如果patchFlag &lt; 0也就是HOISTED或者BAIL

如果此时optimizedfalse,基本上就是热更新的情况下这个字段才是false。前面好像没说过optimized这个字段的作用,实际上这个字段是用来控制是否需要skip等操作的。

如果optimizedfalse,并且dynamicChildrennull,这个时候需要full diff

最后再触发updated以及@vue:onUpdated="xxx"


diff算法

这里的diff算法相信大家都比较熟悉了,这个是vue面试八股文基本绕不过的点。

我们今天来跟着源码分析下到底是怎么做的。

例子模拟

在开始分析之前,我们需要搞一个例子,这样可视化方便分析。

现在我们有一组旧的节点集合[a, b, c, d];

新的具体情况具体分析

1. 从前往后同步

这里有点需要注意,while的条件是按最短的那个来的,也就是短桶规则。

此时我们模拟新的节点集合是[a, b, d, e]

这个时候则是:

先是从前往后,如果类型和key有一点不同就直接跳过,如果相同就递归调用patch


2. 从后往前同步

和上面的逻辑一样,不过这次是从后往前。

模拟例子:[a, b, d]

你可能会有些担心这里和前面从前往后同步的节点patch重复了。

其实完全没必要担心,观察下我们的这个i,它并没有被重置,也就是说它是整个patch过程共用的一个索引,而我们的这个第二步从后往前同步的操作则是为了第一步执行完了还有遗漏的节点

我们再来看个例子

模拟的例子还是[a, b, d]

可以看到每部分patch的内容是不同的,这里是并集,也就是ABD都被patch了。

另外还有一点就是patch阶段第一部分:判断两个节点是否引用的同一块内存空间,如果是则直接return,所以完全没必要担心。

这里遗漏的也可能是新节点,因为我们是根据短桶效应来实现的。所以哪个短,哪个先跑完。剩下的就被遗漏了。


3. 常规序列 + mount(common sequence + mount)

在开始分析之前,我们需要先确认下我们的三个标志位:

假设我们这里的例子是 旧的:[a, b, c, d],而新的: [a, b, d],那么最短的就是新的那组。e1表示旧的数组length - 1,初始值是3; e2表示新的数组length - 1,初始值是2;而i的初始值为0

  • i:从前往后第一次匹配到不相同节点的索引值,最大值是e2 + 1即新的数组后面几个元素被删掉了的场景。在这里在上面的这个假设中的值是2,也就是就数组c和新数组d匹配不上,break之后的值。
  • e1:在我们的这个假设里,走完第二步之后值是2。它的最小值是i - 1
  • e2:在我们的这个假设里,走完第二步之后值是1。它的最小值是i - 1

e1e2都为最小值的场景既是没有发生顺序上的变化。

不过需要注意,可能存在-1的场景,比如旧:[a, b, c, d],新:[e, a, b, c, d],此时i0,而在执行第二步的时候由于后面两个是一样的,导致多执行了一次让e1变成了-1

为什么要分析这三个标志位之间的关系呢?因为我们需要清晰知道这三个字段的逻辑,这样分析起来才能通顺。

看完上面的几个图,大家应该发现了我们前面两步并没有将它们彻底处理完毕,会有节点被遗漏。比如旧的[a, b, c, d],新的[a, b, c, d, e]或者[e, a, b, c, d],这个时候e会被遗漏。

来看下代码

e1小于i的会是什么场景呢?

只有头或者尾部新增了节点的场景,也就是这个例子: 旧的[a, b, c, d],新的[a, b, c, d, e]或者[e, a, b, c, d]

所以此时的旧节点数组全都匹配完毕了,剩下的都是新增的节点放在头或者尾。这个时候只需要patch ie2之间的内容即可。

总结下:我们第三步就是把头尾任意一方新增了节点的场景给处理了。

注意,这里只处理任意一方加上,而不是头尾都有,第三步还无法处理这个问题。


4. 常规序列 + unmount(common sequence + unmount)

这个就好理解了,第三步是旧节点被匹配完了,现在变成新节点被匹配完了,还剩下头或者尾任意一处地方还剩下一些旧节点需要被移除。

第四步也无法处理头尾两头都有移除的场景

注意,这里可以看出我们的每一步都不是必要的,有可能不符合条件被跳过。

在进入下一步分析之前,我们来稍微总结下我们现在可以处理哪些场景:

  1. 节点集合中多个连续节点被删除,注意是连续。执行逻辑是第一步 + 第二步。例子:旧的[a, b, c, d],新的[a, d]
  2. 节点集合中多个连续节点被替换,注意是连续。执行逻辑是第一步 + 第二步 + 第三步。例子:[a, b, c, d],新的[a, b, e, f, c, d]
  3. 节点列表集合中头/尾连续节点新增/删除,注意是连续:
  • 头连续新增:执行逻辑是第二步 + 第三步。例子:旧的[a, b, c, d],新的[e, f, a, b, c, d]
  • 尾连续新增:执行逻辑是第一步 + 第三步。例子:旧的[a, b, c, d],新的[a, b, c, d, e, f]
  • 头连续删除:执行逻辑是第二步 + 第四步。例子:旧的[a, b, c, d],新的[c, d]
  • 尾连续删除:执行逻辑是第一步 + 第四步。例子:旧的[a, b, c, d],新的[a, b]

现在还不能处理:

  1. 头尾都有新增/删除或者头尾一方删除一方新增的场景或者头尾有修改(即删除 + 新增)的场景。例子:
  • 头尾都有新增:旧的[a, b, c, d],新的[e, a, b, c, d, f]
  • 头尾都有删除:旧的[a, b, c, d],新的[b, c]
  • 头尾一方删除一方新增:旧的[a, b, c, d],新的[b, c, d, e]
  • 头/尾(包括头 + 尾)有修改:旧的[a, b, c, d],新的[e, b, c, f]
  • 列表中间不连续删除/新增,例子:
  • 不连续删除:旧的[a, b, c, d, e],新的[a, c, e]。
  • 不连续新增:旧的[a, b, c, d],新的[a, e, b, f, c, g, d]
  • 不连续新增 + 删除(即修改):旧的[a, b, c, d, e],新的[a, c, f, d, e]

总结下,如果新旧任意一方在前面两步中被匹配完了,也就是e1/e2 &lt; i的情况,那么这个时候我们就可以处理。反之则不行。

5. 不规则序列(unknown sequence)

代码稍微有些长。

我们一块一块来分析

5.1 为新的节点创建key:indexmap

这里直接给e2一组数组,创建一个key: indexmap,也就是keyToNewIndexMap

这里还有一个我们非常常见的warn,那就是key值重复的时候。

这个keyToNewIndexMap表示的是新数组中存在旧数据的节点(不包括前面四步patch处理过的)的key和它所在的位置index


5.2 遍历旧节点组,尝试patch匹配到的节点以及移除匹配不到的节点

  • patched:这个字段是用来记录当前到底搞到了多少个节点了,如果超出新数组里剩余的数量,那旧数组中的遍历剩下的节点就可以直接删除了。
  • toBePatched:它表示剩余的需要被patch的节点个数,由于索引是从0开始,所以这里给它加1。
  • moved:这个字段表示存在有节点被移动了位置。
  • maxNewIndexSoFar:这个应该是用来跟踪是否有节点被moved了。
  • newIndexToOldIndexMap:用来表示最长的稳定子序列,也就是删除/新增的连续的子序列最长的长度。它的作用相当于Map,这里需要注意oldIndex都是+ 1的,如果其中一个元素的oldIndex0,那么就表示这个节点是新增的,没有与之对应的旧节点。

先给newIndexToOldIndexMap填充toBePatched0,表示这些节点都是需要被patch的。

然后循环旧节点数组中还没有识别过的节点,is1开始,也就是第五步之前的i的值,而结束的位置是e1

如果patched大于toBePatched,那么就将剩下循环中的旧节点移除,因为此时新的数组早已经处理完毕了。

接着如果旧节点的key不是null,那么这里就拿来和keyToNewIndexMap里的新节点的key做匹配,然后赋值给newIndex,这里就是在通过key匹配新旧节点。

这个keyToNewIndexMap前面说过它的类型是Map表示新的数组中依旧保留的旧节点(不包括前面四步patch处理过的)的key以及它的位置index

如果匹配到了,那这个节点就可以保留,并且它最新的位置也就是index也已经知道了,就不用费劲脑子去知道它在新的数组里的状态是移动还是其它场景了。

而如果key不存在,那么表示这个旧节点之前就没有设置key(这是一种不好的习惯)这个时候只能尽力去匹配了,如果newIndexToOldIndexMap里还有元素是0,那就表示这个节点并没有被匹配过,可能是这个没有设置key的旧节点的对应节点。

当然,也有可能不是,所以这里还需要进一步判断,调用isSameVNodeType来判断下这俩节点是否是同一个type,这里已经尽力了,可能还是有问题,所以我们开发的时候得带上key就是这个原因。

而如果旧节点的key不为null,但是却在新的里面也匹配不到,那么这个节点就可以被抛弃了,直接调用unmount方法将它移除。

如果newIndex不为undefined,那么就表示这个旧的节点有对应的新的节点,且这个newIndex就是这个旧节点在新节点里的位置。此时将newIndexToOldIndexMap里的newIndex索引对应的元素标记为i + 1,表示这个旧节点已经心有所属了。

如果newIndex大于等于maxNewIndexSoFar,那么就将newIndex的值赋值给maxNewIndexSoFar,表示这个子序列是连续的,而如果不是,那么将moved标志位置为true,表示这个序列断开了。

然后再调用patch方法将这俩新旧节点进行打补丁处理。

5.3 move 和 mount, 仅当节点们被moved时创建最长的稳定子序列

  • getSequence:这个方法我们就不看了,简单的说就是一个计算最长递增子序列的算法,简单的举个例子:0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15的最长递增子序列是0, 2, 6, 9, 11, 15.,当然并不一定只有一个,这个是其中一个。具体可以看:https://en.wikipedia.org/wiki/Longest_increasing_subsequence

这一步就是在处理新的节点,由于前面5.2中我们是按顺序自增匹配的,因为里面可能存在没有key的旧节点找到新节点的场景,所以从后往前找命中还没有patch的新增节点的概率更大一些。

如果找到newIndexToOldIndexMap里面为0的元素,那就代表这个元素对应的下标值在新数组中对应的节点是新增的还没有被patch

这个时候将它patch掉,那么到这里,我们的diff就完全匹配完了。

这里还有一个场景,那就是movedtrue,且这个节点不是新增节点的时候。

如果此时没有子序列或者当前遍历的这个节点不在这个子序列里的时候,调用move方法。

我们这个图里的例子最长递增子序列只有一个元素,所以不为0的时候就满足这个场景。

如果movedtrue,那么就表示这里有节点是被移动了位置的。

而获取最长稳定递增子序列是为了避免没必要的move,因为,递增的顺序保证了新增节点里它们的顺序还是一致的。

我们来看下move方法做了什么。


move

前面如果movedtrue,那么就表示这里有节点是被移动了位置的。

而获取最长稳定递增子序列是为了避免没必要的move,因为,递增的顺序保证了新增节点里它们的顺序还是一致的。

  • vnode:这里的vnode是新数组里的。
  • MoveType:一个枚举类型,有三个变体,在这里传入的是REORDER的变体,因为它们是被移动的旧节点。
  • 如果是组件,递归调用move,将组件的子节点传入。
  • 如果是suspense,调用它自己的move方法,不过经过前面的分析,这里大家应该也都知道了,suspensemove实际上就是当前这个move方法,不过做了一些自己的操作再调用的mvoe。这里还是不分析,后面我们分析suspense的时候再说。
  • Teleport的同上
  • 如果是Fragment,先将他插入到container之前。然后递归调用move插入它的子节点。递归完了,再插入anchor,这个anchor一般是一个空文本节点,用来表示当前的fragment结束的位置。
  • 如果是static的节点,那么这个时候调用moveStaticNode的方法,这个方法就不多说了,也是跟着锚点插。
  • Transition这里也不分析了,到时也是单独分析。
  • 普通节点直接插。

所以这个方法实际上就是在将vnode对应的dom插入到页面上去。


总结

这里大家应该都注意到了vue是边diffrender的,有些框架则是先diff完之后再render。两者各有各的优点,前者代码量肯定少了很多,而后者比较好维护。

另外还有一点, diff是在patch函数执行那一刻的时候就开始了,而不是上面提到的那个diff算法,实际上上面说的diff算法只是一小块diff逻辑。

这块内容很杂并且由于是runtime代码,所以我这里也没有处处去debug调试看数据过程和调用栈。这里肯定有些地方分析的不到位甚至是错误的,如果大佬你发现了,务必指出,不胜感激。

如果觉得对你有帮助,也请点个赞,谢谢~

参考

  1. ^document.createTextNode https://developer.mozilla.org/en-US/docs/Web/API/Document/createTextNode
  2. ^Node.insertBefore https://developer.mozilla.org/en-US/docs/Web/API/Node/insertBefore
  3. ^document.createComment https://developer.mozilla.org/en-US/docs/Web/API/Document/createComment
  4. ^documentFragment https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment

编辑于 2023-03-08 15:55・IP 属地广东