源码解析二vue异步更新策略虚拟domdiff算法

38次阅读

共计 10575 个字符,预计需要花费 27 分钟才能阅读完成。

学习目标

异步的批量更新策略概念
  1. 异步: 侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。
  2. 批量: 如果同一个 watcher 被多次触发,只会被推入到队列中一次。去重对于避免不必要的计算 和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列执行实际工作。
  3. 异步策略:Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 或 setImmediate,如果执行环境都不支持,则会采用 setTimeout 代替。
虚拟 DOM 概念

概念:虚拟 DOM(Virtual DOM)是对 DOM 的 JS 抽象表示,它们是 JS 对象,能够描述 DOM 结构和关系。应用 的各种状态变化会作用于虚拟 DOM,最终映射到 DOM 上。
diff 算法的更新,是边 diff 算法,边更新;一旦发现不一样了就进行更新;而不是计算后统一个更新

体验虚拟 DOM

vue 中虚拟 dom 基于 snabbdom 实现,安装 snabbdom 并体验
snabbdom 的实现与讲解,因为不是重点所以直接写好了,在 git 上,有详细的注释:https://github.com/speak44/sn…

源码环境

 "name": "vue",
  "version": "2.6.11",

异步的批量更新策略源码分析

要了解更新,就需要从 defineReactive 的方法开始分析,因为每次更新都在里面进行处理

路径:src/core/observer/index.js

export function defineReactive (){  
 Object.defineProperty(obj, key, {get(....){......},  
 set(....){.....  
 // 通知更新,大小管家都会去执行  dep.notify()},  
 }  
}
dep.notify() 方法

路径:src/core/observer/dep.js

notify () { .....   
 // 循环遍历,sunbs 就是所有的 watcher 实例,for (let i = 0, l = subs.length; i < l; i++) {subs\[i\].update() // 去访问所有的 watcher 实例的 update 方法}   
}
watcher 的 update 方法

路径:src/core/observer/watcher.js

update () {  
/\* istanbul ignore else \*/  
// 给计算属性用的  
 if (this.lazy) { // compueted 会定义一个 lazy 
 this.dirty = true  
 } else if (this.sync) { // 强制同步更新时会配置 sync
 this.run()} else {  
 // watcher 入队,值发生变化的是时候不是直接更新,而是放在队列里面
 queueWatcher(this) // 把自己作为参数传进去  
 }  
}
queueWatcher(this) 方法

路径:src/core/observer/scheduler.js

export function queueWatcher (watcher: Watcher) {  
 // 去重,如果连续修改三遍,比如  
 // this.conner=1;  
 // this.conner=2;   
 // this.conner=3;// 连续修改三遍,不会全都都放在 watcher 队列里面,而是进行一个去重。// 只会进入队列一次;所以需要把 id 拿出来,进行判断。一个组建一个 watcher;当前的 watcher 放在队列一次就可以,不关心怎么改值,只用最后一个的值;值会用最后一次的值,但是队列的 id 只有一个,上面的更改对应的一个 id;过程随便改,用最后一次更新的值。const id = watcher.id  
 // 如果队列里面不存在。在放到队列里面 
 if (has\[id\] == null) {has\[id\] = true  
 if (!flushing) {queue.push(watcher)  
 } else {.......}  
 // queue the flush  
 if (!waiting) { // 如果没有正在执行的 watcher 就去执  
 waiting = true  
 ..........  
 // 异步执行 flushSchedulerQueue  
 nextTick(flushSchedulerQueue)  
 }  
}
nextTick
  • 概念:下一时刻执行的回调,通常用来访问最新的 dome 状态,当执行 cb 的时候,所有的 watcher 都更新完了。
  • nextTick 的核心:放到微任务队列里面。
  • 源码解释:利用微任务机制;就是将 cb 函数 利用 promise 放到微任务的队列后面;所有的 watcher 都更新完了。早去执行
  • 使用:this.$nextTick(()=>{console.log(p.innerHTML)}) // 事件处理机制 同步任务 -> 微任务 -> 异步任务

路径:src/core/util/next-tick.js

// 将 cb 函数放回调队列队尾 
export function nextTick (cb?: Function, ctx?: Object) {  
 let _resolve  
 callbacks.push(() => {  
 // 回调函数错误处理。try...catch  
 // if ...else 的处理只是将回调函数入队,而没有进行执行  
 if (cb) {  
 try {cb.call(ctx)  
 } catch (e) {handleError(e, ctx, 'nextTick')  
 }  
 } else if (_resolve) {_resolve(ctx)  
 }  
 })  
 if (!pending) {  
 pending = true  
 // 异步执行的函数
 timerFunc()}  
 .......   
}
timerFunc()

路径:src/core/util/next-tick.js

// 上个文件的回调函数数组  
const callbacks = []  
  
let timerFunc  
  
// 判断 如果当前的环境支持 promise,就用 promise 的方法去安排异步方法去执行 
if (typeof Promise !== 'undefined' && isNative(Promise)) {const p = Promise.resolve() // 微任务  
 timerFunc = () => {p.then(flushCallbacks)   
 .......  
 }  
 isUsingMicroTask = true  
} else if (!isIE && typeof MutationObserver !== 'undefined' && (  
 .......  
 timerFunc = () => {counter = (counter + 1) % 2  
 textNode.data = String(counter)  
 }  
 // 如果不支持 promise 用 setImmediate  
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {  
 // Fallback to setImmediate.  
 // Technically it leverages the (macro) task queue,  
 // but it is still a better choice than setTimeout.  
 timerFunc = () => {setImmediate(flushCallbacks)  
 }  
} else {  
 // Fallback to setTimeout.  
 // 最后的选择是 settimeout,这是最终的选择,实在是不支持只能这样。timerFunc = () => {setTimeout(flushCallbacks, 0)  
 }  
}
flushCallbacks()

路径:src/core/util/next-tick.js

// 刷新回调函数的数组 
function flushCallbacks () {  
 pending = false  
 const copies = callbacks.slice(0)  
 callbacks.length = 0  
 // 遍历并执行  
 for (let i = 0; i < copies.length; i++) {copies[i]()}  
}
flushSchedulerQueue()

路径:src/core/util/next-tick.js

// 将 watcher 放在一个数组里面返回  
const queue: Array<Watcher> = [];  
function flushSchedulerQueue () {currentFlushTimestamp = getNow()  
flushing = true  
let watcher, id  
  
// Sort queue before flush.  
// 刷新前对队列排序。// This ensures that:  
// 这确保:// 1. Components are updated from parent to child. (because parent is always  
// 一。组件从父级更新到子级。(因为父母总是  
// created before the child)  
// 在子对象之前创建)// 2. A component's user watchers are run before its render watcher (because  
// 2. 组件的用户观察程序在其呈现观察程序之前运行(因为  
// user watchers are created before the render watcher)  
// 在渲染观察程序之前创建用户观察程序)// 3. If a component is destroyed during a parent component's watcher run,  
// 三。如果组件在父组件的监视程序运行期间被破坏,// its watchers can be skipped.  
// 它的观察者可以被跳过。// queue 的来源是,上面定义的 const queue: Array<Watcher> = []; 由 watcher 所组成的数组  
queue.sort((a, b) => a.id - b.id)  
  
// do not cache length because more watchers might be pushed  
// as we run existing watchers  
for (index = 0; index < queue.length; index++) {  
    // 每次拿一个 watcher  
    watcher = queue[index]  
    if (watcher.before) {watcher.before()  
    }  
    id = watcher.id  
    has[id] = null  
    //watcher 的操作方法是 run 方法执行的 
    watcher.run() 
    .......  
}
run()

路径:src/core/observer/watcher.js

//Scheduler job interface.  
// 调度程序作业接口。// Will be called by the scheduler.  
// 将由调度程序调用。run () {if (this.active) {  
    // 核心是执行了 get 方法。如果当前 awtcher 是 render watcher  
    // 此 get 会是 updateConment() 
    // 由此见的 wathcer 来帮助组建更新的,watcher 让 updateConment 重新执行了,之后 render 函数先执行,然后是 update**  
    const value = this.get() //value 是 updateConment 的返回值  
        if(.....){......}else(....){....}  
    }  
}

虚拟 DOM 源码分析

首先了解

patch 的实现

首先进行树级别比较,首先是整棵树的根节点开始比较;原则:同层比较,深度优先;可能有三种情况: 增删改。
new VNode 不存在就删: 将 old vnode 进行一个删除操作
old VNode 不存在就增: 老的不存在就是新增
都存在就执行 diff 执行更新: 都存在就是更新

patch

路径:src/core/vdom/patch.js

\\ Virtual DOM patching algorithm based on Snabbdom by  
基于 Snabbdom 的虚拟 DOM 修补算法  
.........  
\\modified by Evan You (@yyx990803)  
\\ 由 Evan You 修改(@yyx990803).........    
  
return function patch (oldVnode, vnode, hydrating, removeOnly) {  
 // 新的不存在,删除  
 if (isUndef(vnode)) {if (isDef(oldVnode)) invokeDestroyHook(oldVnode)  
 return  
 }  
 let isInitialPatch = false  
 const insertedVnodeQueue = []  
 // 老的不存在,新增  
 if (isUndef(oldVnode)) {// empty mount (likely as component), create new root element  
 isInitialPatch = true  
 createElm(vnode, insertedVnodeQueue)  
 } else {  
 // 两者都存在,进行 diff 算法
 const isRealElement = isDef(oldVnode.nodeType)  
 if (!isRealElement && sameVnode(oldVnode, vnode)) {  
 // patch existing root node  
 //diff 算法发生的位置,比较两棵树  
 // 从 patchVnode 的传入参数就可以看到,oldVnode, vnode .... ;   // 为什么组件要提供一个根节点,不能并排写;从算法层面来说,不希望出现多根的情况;只能有一个根节点。必须是单根的,才能往下进行递归。patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)  
 } else {.....}  
 }  
}
patchVnode

比较两个 VNode,
包括三种类型操作:

  • 属性更新(style,或者 class 发生变化;)
  • 文本更新(innerText 发生变化)
  • 子节点更新(Children 的增删改变化)

具体规则如下:

  1. 新老节点均有 children 子节点,则对子节点进行 diff 操作,调用 updateChildren
  2. 如果新节点有子节点而老节点没有子节点,先清空老节点的文本内容,然后为其新增子节点。
  3. 当新节点没有子节点而老节点有子节点的时候,则移除该节点的所有子节点。
  4. 当新老节点都无子节点的时候,只是文本的替换。
patchVnode()

路径:src/core/vdom/patch.js

// 单节点比较  
function patchVnode (  
 oldVnode,  
 vnode,  
 insertedVnodeQueue,  
 ownerArray,  
 index,  
 removeOnly  
) {// 一个节点一个节点的比较  
 // 获取两个节点孩子节点数组  
 const oldCh = oldVnode.children // 老 -old 孩子节点  
 const ch = vnode.children // 新 -new 孩子节点  
 // 属性更新  
 if (isDef(data) && isPatchable(vnode)) {for (i = 0; i < cbs.update.length; ++i) cbs.update\[i\](oldVnode, vnode)  
 if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)  
 }  
 // 内容比较  
 // 新节点没文本, 可以理解为,有 text 文本内容 或者有子节点 
 if (isUndef(vnode.text)) { // 没有定义文本,就会有 children 节点
 // 都有孩子  
 if (isDef(oldCh) && isDef(ch)) {if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)  
 } else if (isDef(ch)) {  
 // 新的有孩子,老得没有
 if (process.env.NODE\_ENV !== 'production') {checkDuplicateKeys(ch)  
 }  
 if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')  
 // 所以需要批量的增加  
 addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)  
 } else if (isDef(oldCh)) { // 老的有孩子,新的没有   
 // 批量删除   
 removeVnodes(oldCh, 0, oldCh.length - 1)  
 } else if (isDef(oldVnode.text)) { // 都没有孩子  
 nodeOps.setTextContent(elm, '') // 把老的文本内容清掉  
 }  
 } else if (oldVnode.text !== vnode.text) { // 文本节点处理 
 // 文本节点更新 
 nodeOps.setTextContent(elm, vnode.text)  
 }   
}
updateChildren:重排操作

updateChildren 主要作用是用一种较高效的方式比对新旧两个 VNode 的 children 得出最小操作补丁。执行一个双循环是传统方式,vue 中针对 web 场景特点做了特别的算法优化

updateChildren

路径:src/core/vdom/patch.js

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {  
 // 前后四个游标 四个节点 oldStartVnode oldEndVnode newStartVnode newEndVnode
    let oldStartIdx = 0  
    let 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, idxInOld, vnodeToMove, refElm  
 ............  
 // 循环条件:开始游标必须小于等于结束游标 
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {  
        // 前两个条件是游标调整
        if (isUndef(oldStartVnode)) {oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left  
        } else if (isUndef(oldEndVnode)) {oldEndVnode = oldCh[--oldEndIdx]  
        } else if (sameVnode(oldStartVnode, newStartVnode)) {  
            // 两个开头比较  
            patchVnode(oldStartVnode, newStartVnode,insertedVnodeQueue, newCh, newStartIdx)  
            oldStartVnode = oldCh[++oldStartIdx]  
            newStartVnode = newCh[++newStartIdx]  
        } else if (sameVnode(oldEndVnode, newEndVnode)) {  
            // 两个结尾 
            patchVnode(oldEndVnode, newEndVnode,insertedVnodeQueue, newCh, newEndIdx)  
            oldEndVnode = oldCh[--oldEndIdx]  
            newEndVnode = newCh[--newEndIdx]  
            patchVnode(oldEndVnode, newEndVnode,insertedVnodeQueue, newCh, newEndIdx)  
            oldEndVnode = oldCh[--oldEndIdx]  
            newEndVnode = newCh[--newEndIdx]  
        } else if (sameVnode(oldStartVnode, newEndVnode)) { 
            // Vnode moved right  
            // 老的开头和新的结尾比较  
            patchVnode(oldStartVnode, newEndVnode,insertedVnodeQueue, newCh, newEndIdx)  
            canMove && nodeOps.insertBefore(parentElm,oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))  
            oldStartVnode = oldCh[++oldStartIdx]  
            newEndVnode = newCh[--newEndIdx]  
        } else if (sameVnode(oldEndVnode, newStartVnode)) {         // Vnode moved left  
            // 老的结束和新的比较  
            patchVnode(oldEndVnode, newStartVnode,insertedVnodeQueue, newCh, newStartIdx)  
            canMove && nodeOps.insertBefore(parentElm,oldEndVnode.elm, oldStartVnode.elm)  
            oldEndVnode = oldCh[--oldEndIdx]  
            newStartVnode = newCh[++newStartIdx]  
        } else {  
            // 都没有匹配到,从新的开头拿一个,然后去老的数组中查找  
            if (isUndef(oldKeyToIdx)) oldKeyToIdx =createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)  
            idxInOld = isDef(newStartVnode.key)  
            ? oldKeyToIdx[newStartVnode.key]  
            : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)  
            // 没找到就创建 
            if (isUndef(idxInOld)) { // New element  
 createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)  
            } else {  
                // 找到了 
                vnodeToMove = oldCh[idxInOld]  
                if (sameVnode(vnodeToMove, newStartVnode)) {  
                    // 先打补丁  
                    patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)  
                    oldCh[idxInOld] = undefined  
                    // 移动到队首 
                    canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)  
                } else {  
                    // same key but different element. treat as new element  
                    // 很少情况,就是 key 相同,元素不同 
                    createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)  
                }  
            }  
       }  
 }  
 // 上面的循环结束了,老得先结束,批量新增  
 if (oldStartIdx > oldEndIdx) {refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm  
     addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)  
 } else if (newStartIdx > newEndIdx) {  
    // 新的先结束,批量删除  
    removeVnodes(oldCh, oldStartIdx, oldEndIdx)  
 }  
}
sameVnode(): 用来对比两个节点是否相同

路径:src/core/vdom/patch.js

function sameVnode (a, b) {  
    return (  
    // key 的作用:判断两个 vnode 是否相同的判断条件之一
    // key 的工作方式:如果不设置呢?undefined===undefined;那么就为 true;vue 就会很做很无用的工作  
    // 当前 key 是否一样  key 相同也是需要比较的  
        a.key === b.key && (  
            (  
                // 当前标签是否一样  
                // 一般情况下,tag  key 都是保持一致的,因为是 for 循环便利出来的  
                a.tag === b.tag &&  
                // 不能是注释 
                a.isComment === b.isComment &&  
                // 标签不能变 id class 等
                isDef(a.data) === isDef(b.data) &&  
                // input 类型 type 还需要一致 
                sameInputType(a, b)  
            ) || (isTrue(a.isAsyncPlaceholder) &&  
                a.asyncFactory === b.asyncFactory &&  
                isUndef(b.asyncFactory.error)  
            )  
        )  
    )  
}

学习资料:

  • 思维导图:https://www.processon.com/vie…

总结

关异步更新这块,可以重点看下 nextTick 这部分,因为面试的时候会用到,其实就是将创建的任务放在微任务的队尾,所以不在这里去访问 innerHtML 可以拿到数据。面试的时候也会问到

关于虚拟 dom,可以重点看下 patch 的实现,以及这个 key,我们总是被问到这个 key 是干嘛的,源码中也有介绍,上面的文档也有说明。其实就是去
判断两个 vnode 是否相同的判断条件之一,并不是唯一条件,这个要明确了,只有是要加 key,一是为了减少 vnode 在比较时候的无用功,消耗性能;二是在存在倒序的情况的比较来判断。

正文完
 0