之前在做h5活动的时候,遇到了一个关于vue中列表渲染的bug。当然,bug是我自己写的,和vue没有半毛钱关系。不过在解决bug的过程中,对vue的patch diff的过程进行了一番研究。
在探究过程中,涉及到了vue列表渲染的key的研究,以及vue渲染函数及生命周期的执行过程分析。
bug的由来及重现
场景是这样的:
1. 用vue的v-for做列表渲染。列表中有图片和文字。
2. 点击按钮,会往列表数据的最前面增加一条数据。
3. 图片为了做onerror的处理,我自己封装了一个image的组件。
然而,在点击按钮后,数据发生了变化,但是图片显示却发生了错位,即首项的图片并没有正确更新,而是直接显示的数据变化前的第一条数据的图片。demo如下:
1 | // App.vue |
上述代码,当点击按钮增加数据之前,是这么显示的:
而点击了addData按钮之后,会在列表的最前面插入一条测试数据(详见loadImg函数),此时显示结果是这样的:
但是,增加的那条addMsg的logoUrl是这样的:
按正常展示(或者说我们想让展示的结果)来说,改变后数据的第一个图片上面的”狮子头”图片,然而显示展示的还是我自己的头像…
bug分析
首先来分析下执行过程:
增加该组件的渲染watcher到data中的list。
首先,当vue通过$mount进行渲染时,此时生成了渲染watcher。而渲染watcher在执行时,访问到了data中的list。此时,触发了list的getter函数,该渲染watcher被添加到了list的依赖收集器dep中。当list变化时,触发其dep.notify方法,进而执行到渲染watcher的update方法,也就是vm._update(vm._render(), hytrating)函数,该组件会进行重新渲染。
list变化触发组件执行渲染watcher的update方法,进行重新渲染。在渲染过程中,会经过vm._update(vm._render(), hydrating) -> vm._update -> vm.update(preVnode, vnode) -> patch -> patch -> patchVnode。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27function createPatchFunction(backend) {
return function patch (oldVnode, vnode, hydrating, removeOnly) {
// 如果新vnode不存在,旧的存在,调用钩子函数销毁旧的vnode
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
let isInitialPatch = false
const insertedVnodeQueue = []
if (isUndef(oldVnode)) {
// 如果旧的vnode不存在,创建vnode
// empty mount (likely as component), create new root element
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
} else {
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 会进入到这里
// 真正的update,新旧vnode对比更新
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
// 略去
}
}在patchVnode的过程中,进行patch diff,完成dom节点和vnode的比较更新过程。而这一步,就是问题的所在了。首先我们先看下这时候的oldvnode和vnode都是什么(仅看和列表相关的)。
从vnode的对比结果可以看到,新的vnode已经多了一个children。此时看一下list的第一项是什么。
可以看到,数据已经是我们添加过后的数据了。
接下来我们再来看看变化过后的列表对应的dom。
从vnode和list以及dom来看,数据已经更新了,但是并没有真正应用到dom节点上。
那么问题就在patchVnode上了。对于patchvnode的过程,单纯的文字解释也很难懂,在这里推荐阅读黄轶大佬的解读文章vue组件更新。
在Vue文档中,当列表渲染时,官方推荐我们为列表中的项指定一个唯一的key值。这个key值用于在patchVnode时作为判断sameVnode的重要依据。当key值相同且满足其它相关条件(在代码中会解释)时,新旧vnode便可以判定为sameVnode。这也就意味着旧的vnode所对应的dom节点可以被重用。然后把符合sameVnode新旧vnode再次进行patchvnode,在patchvnode中完成相关dom节点属性的更新,从而实现了vnode到真实dom的改动。
下面我们来分析下具体的执行过程。
1 | // 判断samevnode,除了key相同,还要求两个vnode的tag, isComment, inputType相同并且data同为有定义或无定义;对于异步占位符vnode,暂时先不做分析。 |
对于updateChildren的过程,先大致说下它的更新过程:
1 | function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { |
updateChildren的过程大致分析完了,下面回到我们这个bug上:
当数据增加之后,旧列表vnode的children为3个,新列表vnode的children为4个。然后进行updateChildren。
首先进行新旧vnode的children的起始元素比较。这个时候,由于key是index,而起始元素的index都为0,而且其它判断samevnode的条件也符合,因此进入到patchvnode的过程中。
这个时候,新旧列表的第一个vnode的数据发生了变化。新的logoUrl,nickname,desc被更新到了对应的dom节点上。然而,这时候,mt-image组件收到了一个新的src属性值,但是它在内部对新传递过来的src没有做任何处理,也没有任何的观察者(watcher)收集了src。而对props中src的处理,只在mt-image组件创建并执行mounted之后才会进行,在这里这个mt-image组件被复用了,因此并不会执行对src的处理,导致内部img标签上的src依然为之前的src,所以此时就出现了图片展示异常问题。
bug的解决方案
原因找到了,如何修复呢?在这里,我一共总结了三种解决方案:
1. 在mt-image组件中对props的src进行观察,即在watch中观察src,当src发生变化后,执行loadImage函数。
1 | // image.vue |
该方法实质上是在组件内部生成了一个user watcher。在mt-image初始化的时候,会对watch中的配置项生成相应的watcher。
下面我们来理一下src的watcher实例、data中relSrc和mt-image组件的渲染watcher以及props中的src之间的关系。
当mt-image在执行vm._update(vm._render(), hydrating)的过程中,会访问到data中的relSrc, 进而触发了relSrc的依赖收集。relSrc的依赖收集器dep将渲染watcher加入到它的subs中。当relSrc变化时,触发relSrc的set,进而调用其依赖收集器的notify方法,触发组件渲染watcher的重新执行。
接下来说说props中src的变化如何引起img的src属性变更,分为两步:
前提条件:在mt-image组件实例化时,组件会执行_init方法,接着会调用到initState,进而调用initProps和initWatch方法。initProps通过defineReactive对props中的src做数据劫持,initWatch方法会遍历
组件配置的watch中的每一项,并生成对应的user watcher。在生成src的user watcher时,会触发对props中src的访问,进而该user wather被添加到props中src的依赖收集器中。当src发生变化时,会触发该user watcher的update方法,进而执行配置的回调函数。
在执行函数时,当图片加载成功后会进入onload中,此时会对relSrc重新赋值,进而触发relSrc的set, 从而调用之前第一步中添加的渲染watcher进行dom节点的更新操作。
2. 去掉mt-image组件,直接用img标签代替
1 | // app.vue |
这种方法的处理原理就是让img回到正常的更新流程中,和其同级的span一起在patchVnode中被更新。具体的更新操作发生在patchVnode中执行cbs.update时。在这里就不做过多介绍了。
3. 给list增加一个列表项唯一的id值,列表循环时key为唯一的id值
1 | // app.vue 只列出改动点, mt-image无变化 |
这种方法的原理可以从patchVnode和updateChildren中找到答案。
更换了key值为列表项唯一id时,就大不一样了。当新增msg到列表项最前面后,在接下来的updateChildren时,进行新旧children的第一项对比。而新vnode的children的第一项的id为4,在updateChildren的匹配过程中,未匹配到任何能复用的节点,于是这时候新增加的列表数据项就会被当作新节点创建,之后再进行后续的操作.
由于codesandbox最近打开比较慢,就暂时不提供线上demo了,全部代码在上边都有贴出来。
那今天的分析就到这里了,下期再见。