Vue源码终笔-VNode更新与diff算法初探

  写完这个就差不多了,准备干新项目了。

  确实挺不擅长写东西,感觉都是罗列代码写点注释的感觉,这篇就简单阐述一下数据变动时DOM是如何更新的,主要讲解下其中的diff算法。

  先来个正常的html模板:

    <body>
        <div id=‘app‘>
            <div v-for="item in items">{{item}}</div>
            <div @click=‘click‘>click me!</div>
        </div>
    </body>
    <script src=‘./vue.js‘></script>
    <script>
        new Vue({
            el: ‘#app‘,
            data: {
                items: [1]
            },
            methods: {
                click: function() {
                    this.items.push(2);
                }
            }
        })

  页面上有一个通过v-for渲染的div,还有一个按钮,点击按钮时会让div数量+1。

  

  首先需要提到的是,每一次渲染DOM,都会保存一份当前虚拟DOM的副本挂载到_vnode属性上,如图:

  点击前,整个VNode结构为:根节点及3个子节点,子节点均包含2个div标签和一个空白文本节点,div包含对应的文本节点。

  点击后,由于vue劫持了部分数组方法,所以会进入自定义的push方法中,将弹入的新元素进行广播,过程就不看了。

  完成数组添加后,会生成一个新的render函数与新的VNode,diff算法就是比较新旧VNode的差异,通过最小的变化操作渲染新的DOM。

  讲VNode的diff算法之前,有一个小点先讲一下:如何判断当前VNode可复用?

  销毁一个DOM节点并创建一个新的再插入是消耗非常大的,无论是DOM对象本身的复杂性还是操作引起的重绘重排,所以虚拟DOM的目标是尽可能复用现有DOM进行更新。

  其中涉及的概念就是新的VNode能否在旧的基础上修改并复用呢?有一个函数就是做这个判断的:

    function sameVnode(a, b) {
        return (
            // key来源于v-for或者自定的:key属性
            a.key === b.key &&
            a.tag === b.tag &&
            a.isComment === b.isComment &&
            isDef(a.data) === isDef(b.data) &&
            sameInputType(a, b)
        )
    }

  该判断有5重标准:

  (1)key:key属性如果没有设置默认是undefined,当且仅当v-for的列表渲染中会给节点加一个唯一的key,形式如图:,key不一样的节点不进行复用,官方文档也有说明设置key属性可以强制重新生成一个新DOM。

  (2)tag:复用的节点必须保证标签名一致,毕竟没有更改tag名的API

  (3)isComment:注释与普通的DOM不是一个次元,所以需要判断

  (4)isDef(*.data):这个涉及属性的更新,如果一个节点没有任何属性,即data为undefined,与一个有data属性的节点进行更新不如直接渲染一个新的

  (5)sameInputType:这个主要是input标签type属性异同判断,不同的type相当于不同的tag

  如果均满足,可以判定该节点可复用。

  前面说了,每一个更改数据源,会生成一个新的VNode,来与旧的VNode进行比较,节点间的比较无非是判断是否可复用,再进行属性置换。

  而diff算法主要是针对子节点的更新,即两个数组之间的异同比较与更新。

  一个数组的变化无非3个状态:增、删、改,但是其中增删会涉及数组索引与对应元素的变动,总体来讲还是比较复杂的。

  源码中有一个函数专门处理子节点比较,整体如下:

    function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
        // var... 
        while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
            // 旧VNode不存在
            if (isUndef(oldStartVnode)) {
                // ...
            } else if (isUndef(oldEndVnode)) {
                // ...
            } else if (sameVnode(oldStartVnode, newStartVnode)) {
                // ...
            } else if (sameVnode(oldEndVnode, newEndVnode)) {
                // ...
            } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
                // ...
            } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
                // ...
            } else {
                // ...
            }
        }
        if (oldStartIdx > oldEndIdx) {
            // ...
        } else if (newStartIdx > newEndIdx) {
            // ...
        }
    }

  第一次看还是比较懵逼的,主路线while循环中有7重判断,分别对应7种情况。

  分解本例中的情况,不贴代码,尝试画个图:

  

  对比新旧VNode,可以看出新的VNode在索引0的后面插入了一个新的tag

  接下来通过updateChildren函数进行比较,有很多的变量,这里还需要一个图:

  在函数中有8个变量,其中4个旧VNode,4个新VNode,分别是一一对应的,解释一半就行了:

    var oldStartIdx = 0;
    var newStartIdx = 0;
    var oldEndIdx = oldCh.length - 1;
    var oldStartVnode = oldCh[0];
    var oldEndVnode = oldCh[oldEndIdx];
    var newEndIdx = newCh.length - 1;
    var newStartVnode = newCh[0];
    var newEndVnode = newCh[newEndIdx];

  (1)oldStartIdx => 从前往后的旧VNode数组索引,初始化时为0 => 简称为前索引

  (2)oldStartVnode => 对应索引的旧VNode元素 => 简称为前元素

  (3)oldEndIdx => 从后往前的旧VNode数组索引,初始化为children的数组长度 => 简称为后索引

  (4)oldEndVnode => 对应索引的旧Vnode元素 => 简称为后元素

  后面的阐述全部用简称,不然太难讲了,并且新VNode的数组简称newCh,旧VNode的数组简称oldCh

  另外4个变量只是将old更换为new,并对应新VNode的索引与元素。

  

  接下来是一个大while循环,终止条件是前索引大于后索引(newCh或oldCh):

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isUndef(oldStartVnode)) {
            // ...
        } else if (isUndef(oldEndVnode)) {
            // ...
        } else if (sameVnode(oldStartVnode, newStartVnode)) {
            // ...
        } else if (sameVnode(oldEndVnode, newEndVnode)) {
            // ...
        } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
            // ...
        } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
            // ...
        } else {
            // ...
        }
    }

  由于有几种情况我模拟不出来,只能大概过一下。

1、isUndef(oldStartVnode)、isUndef(oldEndVnode)

  前两种是oldCh前元素oldCh后元素不存在,我能模拟的情况是当oldCh中没有元素时,会出现这种情况。

  这时只是单纯加前索引加1或者后索引减1,而oldCh长度此时为0,会立即跳出while循环,进入下一步。

2、sameVnode(a,b)

  下面的的4种情况都是判断节点是否可复用,然后进行更新。其中对比的情况有4对:

  oldCh前元素 => newCh前元素

  oldCh后元素 => newCh后元素

  oldCh前元素 => newCh后元素

  oldCh后元素 => newCh前元素 

  取第一种情况来说,如果比较通过,说明oldCh前元素可以被复用,随即调用patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)来对DOM进行更新,由于tag是不变的,可以直接对DOM进行各种API调用,比如说事件更改,只要remove旧事件,add新事件就行,这里只是DOM对象的属性更改,不会影响到DOM的增删。

  当patch完毕后,会将oldCh前索引newCh的前索引加1,并更新对应的元素,然后进入下一轮循环。

  画一轮图解释:

  

  此时第一个子节点已经更新完毕,然后重新开始对比,如果oldCh与newCh的索引1处也可复用,会再次更新并加1,直到前索引大于后索引时,说明所有可能的比较都进行完毕。

  这里的4种比较没有必要重复过一遍,如果是前索引就加1,后索引就减1。

3、else{...}

  最后一种情况是需要强制更新元素时才会有的情况,比如:

    <body>
        <div id=‘app‘>
            <div v-if="!vIfIter" key=‘o‘>old Ele1</div>
            <div v-if="vIfIter" key=‘n‘>new Ele</div>
            <div @click=‘click‘>click me!</div>
        </div>
    </body>
    <script src=‘./vue.js‘></script>
    <script>
        new Vue({
            el: ‘#app‘,
            data: {
                vIfIter: false
            },
            methods: {
                click: function() {
                    this.vIfIter = true;
                }
            }
        })
    </script>

  此时,由于设置了单独的key值,所以div被标记为不可复用,跳过了所有判断进入了else阶段:

    // 这里将旧VNode中剩余的元素key值作为对象输出
    if (isUndef(oldKeyToIdx)) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
    }
    // 判断新VNode中是否存在可复用的元素
    idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null;
    // 不存在就创建一个新的插入DOM中
    if (isUndef(idxInOld)) {
        // New element
    }
    // 存在
    else {
        elmToMove = oldCh[idxInOld];
        if (sameVnode(elmToMove, newStartVnode)) {
            // 更新VNode
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
            // 把旧的VNode置空 此处会触发到while循环的前两个判断
            oldCh[idxInOld] = undefined;
            // 移动更新后的VNode
            canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm);
            newStartVnode = newCh[++newStartIdx];
        }
        // 同样的key值不同的tag 创建新DOM插入
        else {
            // same key but different element. treat as new element
        }
    }

  简单来讲还是可复用就复用,不可复用创建新DOM插入。

  最后来看看while循环跳出来的语句,其实很简单:

    // VNode数量增加了
    if (oldStartIdx > oldEndIdx) {
        // 如果VNode是中间插入就会存在refElm
        // 否则refElm为null 调用insertBefore会将DOM插入父元素尾部
        refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
        addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
    }
    // 减少了
    else if (newStartIdx > newEndIdx) {
        // 移除多出来的DOM节点
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }

  至此,所有的分析完了,上面的案例有兴趣可以自己跑跑。

  

  不容易啊,写完了。。。已经入行5个月,由于没有什么好项目练手,只能看源码提升基本功,接下来可能很长时间不写博客了。(反正也没人看,啊哈哈哈哈~)

  (定个小目标,Codewars刷到3kyu,加油!)

时间: 2024-11-08 10:18:12

Vue源码终笔-VNode更新与diff算法初探的相关文章

vue源码解读(一)Observer/Dep/Watcher是如何实现数据绑定的

欢迎star我的github仓库,共同学习~目前vue源码学习系列已经更新了5篇啦~ https://github.com/yisha0307/... 快速跳转: Vue的双向绑定原理(已完成) 说说vue中的Virtual DOM(已完成) React diff和Vue diff实现差别 Vue中的异步更新策略(已完成) Vuex的实现理解 Typescript学习笔记(持续更新ing) Vue源码中闭包的使用(已完成) 介绍 最近在学习vue和vuex的源码,记录自己的一些学习心得.主要借鉴

Vue源码阅读笔记,持续更新

/ / Vue.js v2.1.3 源码阅读记录 使用的文件为使用es2015的本地文件 2018年4月20日 14:06:30 */ 第一章,Vuejs的整体架构 1. 入口 入口处使用一个闭包(function (global,factory) {factory()})(this,factory): 其中factory是工厂的意思,它的内部实现是一个工厂函数,其中直接声明的function为私有成员. 2. 生命周期的理解 理解vue的生命周期对通读vue源码的效率有较好的帮助,它的生命周期

VUE源码解析心得

解读vue源码比较好奇的几个点: 1.生命周期是如何实现的 2.如何时间数据监听,实现双向绑定的 =======================华丽的分割线======================================================== 1. 官方图解如https://cn.vuejs.org/v2/guide/instance.html#生命周期图示,beforeCreate -> 观察数据变化 + 事件初始化  -> created -> el tem

Vue源码探究-虚拟DOM的渲染

Vue源码探究-虚拟DOM的渲染 在虚拟节点的实现一篇中,除了知道了 VNode 类的实现之外,还简要地整理了一下DOM渲染的路径.在这一篇中,主要来分析一下两条路径的具体实现代码. 按照创建 Vue 实例后的一般执行流程,首先来看看实例初始化时对渲染模块的初始处理.这也是开始 mount 路径的前一步.初始包括两部分,一是向 Vue 类原型对象上挂载渲染相关的方法,而是初始化渲染相关的属性. 渲染的初始化 下面代码位于vue/src/core/instance/render.js 相关属性初始

[Vue源码]一起来学Vue双向绑定原理-数据劫持和发布订阅

有一段时间没有更新技术博文了,因为这段时间埋下头来看Vue源码了.本文我们一起通过学习双向绑定原理来分析Vue源码.预计接下来会围绕Vue源码来整理一些文章,如下. 一起来学Vue双向绑定原理-数据劫持和发布订阅 一起来学Vue模板编译原理(一)-Template生成AST 一起来学Vue模板编译原理(二)-AST生成Render字符串 一起来学Vue虚拟DOM解析-Virtual Dom实现和Dom-diff算法 这些文章统一放在我的git仓库:https://github.com/yzsun

[Vue源码]一起来学Vue模板编译原理(二)-AST生成Render字符串

本文我们一起通过学习Vue模板编译原理(二)-AST生成Render字符串来分析Vue源码.预计接下来会围绕Vue源码来整理一些文章,如下. 一起来学Vue双向绑定原理-数据劫持和发布订阅 一起来学Vue模板编译原理(一)-Template生成AST 一起来学Vue模板编译原理(二)-AST生成Render字符串 一起来学Vue虚拟DOM解析-Virtual Dom实现和Dom-diff算法 这些文章统一放在我的git仓库:https://github.com/yzsunlei/javascri

Vue源码(下篇)

上一篇是mount之前的添加一些方法,包括全局方法gloal-api,XXXMixin,initXXX,然后一切准备就绪,来到了mount阶段,这个阶段主要是 解析template 创建watcher并存入Dep 更新数据时更新视图 Vue源码里有两个mount 第一个 // src/platform/web/runtime/index.js Vue.prototype.$mount = function ( el?: string | Element, hydrating?: boolean

Vue源码(上篇)

某课网有个488人名币的源码解读视频看不起,只能搜很多得资料慢慢理解,看源码能知道大佬的功能模块是怎么分块写的,怎么复用的,已经vue是怎么实现的 资料来自 vue源码 喜欢唱歌的小狮子 web喵喵喵 Vue.js源码全方位深入解析 恰恰虎的博客 learnVue 最后四集视频 总文件目录 scripts:包含构建相关的脚本和配置文件.作者声明一般开发不需要关注此目录 dist:构建出的不同分发版本,只有发布新版本时才会跟新,开发分支的新特性不会反映在此 packages:包含服务端渲染和模板编

Vue源码之响应式原理(个人向)

浅谈响应式原理 关于响应式原理,其实对于vue这样一个大项目来说肯定有很多细节和优化的地方,在下水平精力有限,不能一一尝试探索,本文仅以将响应式的大致流程个人向的梳理完毕为目的. 对于响应式主要分为三大部分来分析,1.响应式对象:2.依赖收集:3.派发更新. 最后将是个人的分析. 1.响应式对象 (Object.defineProperty) 我们先从初始化数据开始,再介绍几个比较核心的方法. 1.1.initState 文件位置:src/core/instance/state.js 在Vue的