Vue 原理篇 - 响应式原理、批处理更新、Diff 算法

1,781 阅读15分钟

响应式原理

Vue2 实现响应式的核心是通过 ES5 的 Object.defineProperty,这是浏览器中一个无法 shim 的特性,所以Vue.js 不能兼容 IE8 及以下浏览器。

Object.defineProperty

它用于定义一个对象的属性描述符。Vue 中主要是利用它来定义属性的 getter 和 setter。

let user = {
  name: "John",
  surname: "Smith"
};

Object.defineProperty(user, 'fullName', {
  get() {
    return `${this.name} ${this.surname}`;
  },
  
  set(value) {
    [this.name, this.surname] = value.split(" ");
  }
});

alert(user.fullName); // John Smith

for(let key in user) alert(key); // name, surname

当我们访问该属性的时候,会调用其 getter;当我们修改该属性的时候,会调用其 setter。这样,在对其值进行读取、修改的时候,我们就可以设置一层拦截,进行某些操作。

单单使用 Object.defineProperty 对属性进行封装其实并没有什么用,真正有用的是:当数据变更时,能及时通知对应的视图更新,而这需要先知道这个数据被哪些视图所依赖了。所以核心流程是依赖收集派发更新

依赖收集

我们之所以要收集依赖,是为了在数据变化时,可以方便地通知依赖该数据的视图进行更新或者执行相应的副作用函数。例如:

<template>
  <h1>{{ title }}</h1>
</template>
<script>
  /* Vue 中的 watch */
   watch: {
    // 如果 `title` 发生改变,这个函数就会运行
    title: function (newTitle, oldTitle) {
      console.log('title 改变了!!!');
    }
  },
</script>

把用到 title 属性的地方收集起来,当 title 发生变化时,只需要把收集到的依赖循环通知一遍就可以了。那么应该在哪里收集依赖呢?

首先得思考一个问题,Vue 如何才能知道 template 或者 watch 中依赖了哪些变量呢?执行它们。执行的时候,会读取 title 的值,也就会触发 title 的 getter。我们可以在 getter 中做拦截,收集依赖于 title 的 template 和 watch。

那么依赖应该收集在哪里呢?收集到 dep 中,用于统一管理依赖、收集依赖、删除依赖、向依赖发送通知等。Dep 类实现如下:

export default class Dep {
  static target: ?Watcher;
  subs: Array<Watcher>;
  
  constructor () {
    this.subs = []
  }
  
  addSub (sub: Watcher) {
    this.subs.push(sub)
  }
  
  removeSub (sub: Watcher) {
    remove(this.subs, sub)
  }
  
  depend () {
    if (Dep.target) {
      this.addSub(Dep.target);
    }
  }
  
  notify () {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

Dep 类有一个静态属性 target,target 用来保存当前执行的 watcher。当执行 template 中的代码时,target 就是 template 对应的 watcher;当执行 watch 中的代码时,target 就是 watch 对应的 watcher。它的作用是为读取到的数据添加正确的依赖。

data 中每一个数据都有与之对应的 dep,其 subs 属性用来保存依赖,即保存 watcher。当某一个数据变化时,在它的 setter 中会调用 dep.notify 方法,调用每一个 watcher 的 update 方法去通知对应的地方进行更新。

Vue 中每一个数据都会被 defineReactive 处理。defineReactive 方法就是利用 Object.defineProperty,将数据变成响应式的,接下来看一下 defineReactive 方法的实现:

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
) {
  if(typeof val === 'object') {
    new Observer(val)
  }
  
  const dep = new Dep()

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      dep.depend()
      return value
    },
    set: function reactiveSetter (newVal) {
      if(val === newVal) {
        return
      }
      val = newVal
      dep.notify()
    },
  })
}

此时代码非常明了,在 getter 中收集依赖,在 setter 中派发更新。

前面说到,Dep.target 指向的就是 watcher,收集依赖就是在收集 watcher,那 watcher 到底是什么呢?

watcher 是观察者,当数据进行变更时,通知的就是 watcher,由 watcher 去执行相应的操作,例如:重新渲染模版、执行副作用函数。

Watcher 类的结构如下:

class Watcher {
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
  ) {
    this.vm = vm
    this.cb = cb
    this.getter = parsePath(expOrFn)
    this.value = this.get()
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
    }
  }

  get () {
    Dep.target = this
    const vm = this.vm
    const value = this.getter.call(vm, vm)
    Dep.target = null
    return value
  }

  update() {
    const oldValue = this.value
    this.value = this.get()
    this.cb.call(this.vm, this.value, oldValue)
  }
}

构造 watcher 实例时,就可以将该 watcher 添加到对应数据的依赖数组中。因为在 Watcher 构造函数中,会调用 get 方法去执行对应的函数,将其中用到的数据都读一遍,这肯定会触发对应数据的 getter。

在 get 方法中,首先将 Dep.target 赋值为当前的 watcher,然后再调用 this.getter 方法。这个 getter 方法就是为了执行对应的函数,在函数里面去读取数据,触发数据的 getter。而在数据的 getter 中,会执行 dep.depend 方法,将 Dep.target(也就是当前的 watcher)加入到该数据的依赖数组中。执行完函数之后,释放 Dep.target。

这里的 Dep.target 保存了当前的 watcher,因为当前正在执行的函数是被当前的 watcher 所观察的,而我们会给当前函数所读取到的数据都添加当前的 watcher 作为依赖。这样就保证了每一个 watcher 都能添加到其使用的数据的依赖数组中。

当所有的 template、watch、computed 等等执行完之后,它们所使用到的数据均会被读取,触发 getter,继而将 template、watch、computed 等等对应的 watcher 添加到对应数据的依赖数组中。这样就完成了依赖收集。

前面提到 Vue 会对每一个数据调用 defineReactive 方法,将其变成响应式数据。那具体是如何实现的呢?

Vue 封装了一个类 Observer,用于将一个数据中所有属性变成响应式数据,它和 defineReactive 的区别是:defineReactive 只会将一个数据的某个属性变成响应式。

class Observer {
  constructor(value) {
    this.value = value
    if(!Array.isArray(value)) {
      this.walk(value)
    }
  }
  
  walk(obj) {
    Object.keys(obj).forEach(key => {
      defineReactive(obj, key, obj[key])      
    })
  }
}

在 walk 中,遍历对象的每一个属性,并调用 defineReactive 将这个属性变成响应式。而在 defineReactive 中,如果这个属性值是一个对象,则会继续构造 observer,将这个属性值对象中每一个属性变成响应式。通过这样的递归过程,就可以将深层数据也变成响应式数据。

defineReactive (obj, key, val) {
  if(typeof val === 'object') {
    new Observer(val)
  }
  // ...
}

派发更新

收集完依赖之后,派发更新就比较简单了。只需要在触发数据 setter 的时候,通知每一个收集到的依赖,进行更新就可以了。

defineReactive (obj, key, val) {
  // ...
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    // ...
    set: function reactiveSetter (newVal) {
      if(val === newVal) {
        return
      }
      val = newVal
      dep.notify()
    },
  })
}

这里最主要的就是调用 dep.notify,它的实现如下:

class Dep {
  // ...
  notify () {
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

subs 数组中存的是一个个的 watcher,通知 watcher 进行更新,实际上就是调用它的 update 方法。

update 方法实现如下:

class Watcher {
  constructor (vm, expOrFn, cb,) {
    this.vm = vm
    this.cb = cb
    this.getter = parsePath(expOrFn)
    this.value = this.get()
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
    }
  }

  get () {
    Dep.target = this
    const vm = this.vm
    const value = this.getter.call(vm, vm)
    Dep.target = null
    return value
  }

  update() {
    const oldValue = this.value
    this.value = this.get()
    this.cb.call(this.vm, this.value, oldValue)
  }
}

它调用了 get 方法和 cb 函数。

get 方法又会收集一次依赖,它会执行 getter 函数。对于 template watcher 来说,它的 getter 中会执行对应的 render 方法,重新渲染。

cb 函数就是数据变更时,需要执行的回调函数。例如:我们之前注册了 watch 属性 title,当 title 变更时,就会执行 console.log('title 改变了!!!')。当然了,tamplate 的 cb 函数是空的。

批处理更新

首先看一个例子:

<template>
  <div>
    姓名: {{ name }}
    年龄: {{ age }}
    <button @click="handleClick" >点击</button>
  </div>
</template>

<script>
  export default {
    data(){
      return {
        age:0,
        name:''
      }
    },
    methods:{
      handleClick(){
        this.name = 'alien'
        this.age = 18
      }
    }
  }
</script>

当点击按钮之后,会触发 name 和 age 的更新,分别触发 name 和 age 属性的 setter。按理来说,应该会触发两次 render 函数,组件会更新两次。但实际是如此吗?

只会触发一次组件更新。Vue 内部通过批量更新机制,让组件只会更新一次。这样能减少许多不必要的性能开销。

宏任务和微任务

Vue 的批量更新机制是通过微任务来实现的,探究 Vue 批处理更新之前,先来温习一下浏览器的事件循环

一轮事件循环会分别执行宏任务和微任务,每一轮事件循环结束,若浏览器决定需要渲染,则会先执行 requestAnimationFrame 注册的任务(这个先不讨论),再将使用权移交到渲染线程。

在一轮事件循环中,先从宏任务队列中取出一个宏任务执行,例如:script 标签中代码的执行、一次用户事件、setTimeout/setInterval 注册的宏任务、MessageChanel 等等。执行完宏任务之后,再将微任务队列中所有的微任务执行完,直到微任务队列为空。常见的微任务有:Promise 注册的微任务、queueMicrotask、MutationObserver、node 环境下的 process.nextTick 等等。

简单来说,事件循环的执行过程如下:

  • 执行宏任务队列中第一个宏任务
  • 执行所有的微任务,直到微任务队列为空
  • 若浏览器决定渲染,则执行下面的步骤:
    • 执行 requestAnimationFrame 队列中的任务
    • 渲染线程渲染下一帧
  • 开启下一次事件循环

宏任务每次只执行一个;而微任务每次都会全部执行完,直到微任务队列为空。

:requestAnimationFrame 任务的执行既不同于宏任务每次只执行一个,也不同于微任务每次需要清空队列。它的执行方式是:将执行之前注册的所有 requestAnimationFrame 任务执行完,如果在执行 requestAnimationFrame 任务的时候注册了新的 requestAnimationFrame 任务,则会被放到下一帧渲染之前执行。而在微任务执行过程中,如果注册了新的微任务,则会在本轮事件循环中执行完。

基于宏任务、微任务实现批量更新

前面实现的派发更新比较简单,为了更好地理解,并没有实现批量更新的功能,Vue 实现的派发更新远比这个要复杂。

前面在 setter 中进行拦截,我们是直接更新视图或者执行对应的副作用函数。而实现批量更新则不会立即执行更新任务,而是先把更新任务放入一个待更新队列 updateQueue 里面。待当前任务执行完,再去统一执行完 updateQueue 里面所有的任务,这样就能实现批量更新。

Vue 将执行批量更新的函数调度在微任务队列中,也就是每触发一次 setter,就将对应的更新任务放入 updateQueue,如果存在重复的更新任务,则不放入 updateQueue。当本次宏任务执行完之后,再执行微任务队列中的批量更新函数,统一更新视图。

在 Vue 中,将任务添加到微任务队列中,则是通过 nextTick 实现的,它的实现非常简单:

const p = Promise.resolve() 
/* nextTick 实现,用微任务实现的 */
export function nextTick(fn?: () => void): Promise<void> {
  return fn ? p.then(fn) : p
}

本质上,它就是利用 promise.then 将任务注册到微任务队列中。

事实上,之前实现的 Watcher 中的 update 方法是非常简洁的,它并没有处理批量更新,现在加上批量更新的逻辑再来看看:

class Watcher {
  // ...
  update () {
    if (this.computed) {
      // A computed property watcher has two modes: lazy and activated.
      // It initializes as lazy by default, and only becomes activated when
      // it is depended on by at least one subscriber, which is typically
      // another computed property or a component's render function.
      if (this.dep.subs.length === 0) {
        // In lazy mode, we don't want to perform computations until necessary,
        // so we simply mark the watcher as dirty. The actual computation is
        // performed just-in-time in this.evaluate() when the computed property
        // is accessed.
        this.dirty = true
      } else {
        // In activated mode, we want to proactively perform the computation
        // but only notify our subscribers when the value has indeed changed.
        this.getAndInvoke(() => {
          this.dep.notify()
        })
      }
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
}

这里的 update 会根据 watcher 的不同状态,执行不同的逻辑。在一般组件更新的场景中,会走到 queueWatcher 的逻辑。它的实现如下:

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

这里最主要的逻辑是:将 flushSchedulerQueue 通过 nextTick 注册到微任务队列中。当然这个逻辑在一次事件循环中只会执行一次,将 waiting 变为 true 之后就不会再进入这个逻辑了。

通过名字也可以知道 flushSchedulerQueue 就是刷新队列的,它会将之前存放在 updateQueue 中的更新任务都执行一次,也就是 watcher.run 方法。

function flushSchedulerQueue () {
  flushing = true
  let watcher, id
  
  queue.sort((a, b) => a.id - b.id)
  
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    watcher.run()
  }
}

在刷新队列之前,首先对队列中的 watcher 进行排序,确保先创建的 watcher 先更新,父组件应该先于子组件更新。父组件的 watcher 在子组件的 watcher 之前创建,其 id 会小于子组件的 watcher,所以可以用 id 来排序。

最主要的逻辑还是更新,也就是执行每个 watcher 的 run 方法。它的实现如下:

class Watcher {
  run () {
    if (this.active) {
      this.getAndInvoke(this.cb)
    }
  }

  getAndInvoke (cb: Function) {
    const value = this.get()
    if (
      value !== this.value ||
      isObject(value) ||
      this.deep
    ) {
      const oldValue = this.value
      this.value = value
      this.dirty = false
      cb.call(this.vm, value, oldValue)
    }
  }
}

首先调用 this.get 获取它最新的值。对于 template watcher,在 this.get 方法中,会调用其 render 方法,重新渲染组件。之后如果满足条件,则会执行相应的回调函数 cb,并把新值和旧值传给 cb。这样当我们 watch 的属性变化的时候,就能拿到其变化前后的值了。

Diff 算法

不管是依赖收集还是派发更新,最终都需要将变更展示在视图上,接下来就是了解 Vue 更新 DOM 的过程了。它也被称作 patch,通过修补原来的 DOM,实现试图的更新。

那么如何才能知道哪些 DOM 需要修改、哪些 DOM 需要删除、哪些 DOM 需要新增、哪些 DOM 不变呢?

这就是 Diff 算法所做的事情。

虚拟 DOM

在介绍 Diff 算法之前,首先需要了解一下虚拟 DOM。虚拟 DOM 是一个对象,一个用来描述真实 DOM 的对象。例如如下真实 DOM 结构:

<ul id="list">
    <li class="item">a</li>
    <li class="item">b</li>
    <li class="item">c</li>
</ul>

对应的虚拟 DOM 结构为:

{
  tagName: 'ul', // 标签名
  props: { // 属性
    id: 'list'
  },
  children: [ // 标签子节点
    {
      tagName: 'li', props: { class: 'item' }, children: ['a']
    },
    {
      tagName: 'li', props: { class: 'item' }, children: ['b']
    },
    {
      tagName: 'li', props: { class: 'item' }, children: ['c']
    },
  ],
}

Vue 中会维护两颗虚拟 DOM 树,通过新旧虚拟 DOM 树的对比,来判断需要如何更新真实的 DOM。

例如,当我们触发更新,将最后一个 li 标签里面的值由 'c' 变为 'cc' 之后,对应的虚拟 DOM 结构就会变为:

{
  tagName: 'ul', // 标签名
  props: { // 属性
    id: 'list'
  },
  children: [ // 标签子节点
    {
      tagName: 'li', props: { class: 'item' }, children: ['a']
    },
    {
      tagName: 'li', props: { class: 'item' }, children: ['b']
    },
    {
      tagName: 'li', props: { class: 'item' }, children: ['cc']
    },
 ]
}

之后 Vue 会使用 Diff 算法计算两棵树的差异,判断哪些节点可以复用,哪些节点需要删除,需要新增哪些节点,并在 Diff 算法结束之后,进行对应的操作更新视图。

相比于暴力替换整个视图,Vue 进行了额外的 Diff 操作,以此来计算需要更新的部分:

这部分多出来的 Diff 算法,是为了用 js 计算的开销来换取大量真实 DOM 的操作开销,而 js 的计算速度要比 DOM 的操作速度快很多。这样一来,当我们更新视图中的少部分数据时,就可以复用大量的 DOM 节点而无需重新生成了。综合大多数视图更新的场景,使用虚拟 DOM 会比暴力替换整个视图的方式性能要好。

节点更新的几种情况

首先对于新旧虚拟 DOM 的对比,只会发生同层级之间,不能层级之间的虚拟 DOM 是不会复用的。这样可以大大减少 Diff 算法的时间复杂度。

Vue 中节点的类型分为三种:元素节点、文本节点、注释节点。

如果某个节点拥有 tag 属性,则是元素节点;否则就判断它是否有 isComment 属性,如果有 isComment 属性,则是文本节点;否则就是注释节点。

对于 Diff 算法,Vue 秉承的理念很简单:

  • 前提:对同层级的新旧虚拟 DOM 节点进行对比;
  • 若旧虚拟 DOM 中不存在而新虚拟 DOM 中存在,则新增节点;
  • 若旧虚拟 DOM 中存在而新虚拟 DOM 中不存在,则删除节点;
  • 若旧虚拟 DOM 和新虚拟 DOM 中都存在,则更新节点。

新增节点比较简单,一般在首次挂载的时候会新增节点,因为首次挂载时,不存在旧的虚拟节点,所有新的虚拟节点都需要生成。其次,如果新的虚拟节点不能复用旧的虚拟节点时,也会重新生成节点,并删除旧的节点。

删除节点也比较简单,当旧的虚拟节点没有对应的新虚拟节点时,会删除它。新的虚拟节点不能复用旧的虚拟节点时,会删除旧的节点,并重新生成节点。

更新节点则相对来说比较复杂,分为如下三种情况:

  • 静态节点:

首先,在进行 Diff 的过程中,先判断该节点是否为静态节点。如果是静态节点,则结束该节点的 Diff;否则继续进行之后的判断。

Vue 在编译模版的时候会将静态节点标记,并在进行 Diff 算法的时候,跳过对静态节点的对比。因为静态节点永远不会变化,也就没有进行 Diff 的必要了。

  • 有文本属性:

当新虚拟节点有文本属性,并且和旧虚拟节点的文本属性不一样时,则直接将视图中 DOM 节点的内容修改为新虚拟节点文本属性的值。

  • 无文本属性:

若新虚拟节点无文本属性,并且没有 children,则说明它是一个空节点,需要将旧虚拟节点中的所有子节点或者文本删除。

若新虚拟节点无文本属性,但是有 children,则需要进行下面子节点的 Diff 过程。

子节点的更新

子节点的更新一般涉及到这四种情况:新增节点、删除节点、更新节点、移动节点。

首先需要对新旧子节点列表进行对比,循环新子节点列表,每循环一个新子节点,就去旧子节点列表中寻找对应的旧子节点。

如果找不到新子节点对应的旧子节点,就说明该新子节点是新增的节点,需要根据新子节点生成真实的 DOM 节点,并插入适当的位置。

如果找到了新子节点对应的旧子节点,则先看新旧子节点是否在列表中的相同的位置,不在相同的位置就需要移动旧子节点对应的真实 DOM 至适当的位置,并进行更新(同上节节点的更新一样)。

如果新子节点列表遍历完了,旧子节点列表中还有未处理的节点,说明这些未处理的节点经过这次更新后需要删除,则将这些节点全部从视图中删除。

既然需要遍历新子节点列表,去寻找对应的旧子节点,到底是如何遍历,如何寻找的呢?

Vue 采用了从两边往中间的方式遍历新子节点,分别设置两个指针指向新子节点的两端:newStartIdx = 0newEndIdx = newChildren.length - 1。同样地,旧子节点也有两个指针:oldStartIdx = 0oldEndIdx = oldChildren.length - 1。每次比较的时候,均采用如下顺序进行比较:

  • 新前与旧前
  • 新后与旧后
  • 新后与旧前
  • 新前与旧后

如果比较的两个节点相对应,则先判断需不需要移动位置,若两个节点位置不同则需要移动位置。之后再对这两个节点进行 Diff,若需要更新的话再进行更新。

如果经过上面的四次比较,并没有找到对应的节点,则生成一个哈希表,将旧子节点以它的 key 为 key,它的 index 为 value 存入一个 Map 中,方便根据 key 来找到新子节点对应的旧子节点。如果当前新子节点有对应的旧子节点,就更新位置再将对应的两个节点进行 Diff,没有就在视图中插入新前节点。

那么这里怎么判断两个新旧节点是否对应呢?Vue 中使用一个函数 sameVnode 来判断两个节点是否对应(可复用):

function sameVnode (a, b) {
  return (
    a.key === b.key &&
    a.tag === b.tag &&
    a.isComment === b.isComment &&
    isDef(a.data) === isDef(b.data) &&
    sameInputType(a, b)
  )
}

判断两个节点是否可复用须满足如下几个条件:

  • 两个节点的 key 相同
  • 两个节点的 tag 相同,即标签名相同
  • 都是注释节点,或者都不是注释节点
  • 是否 data 都有定义
  • 如果是 input 标签,则要求它们的 type 相同

input 标签中,不同的 type 会有不同的表现形式。如:<input type="text" /><input type="button" /><input type="checkbox" /><input type="file" />等等。

function sameInputType (a, b) {
  if (a.tag !== 'input') return true
  let i
  const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type
  const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type
  return typeA === typeB
}

当上述几个条件均满足后,Vue 才会考虑复用旧的节点,即调用 patchVnode 函数:

function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
  /* 两个VNode节点相同则直接返回 */
  if (oldVnode === vnode) {
    return
  }
  /**
   *  如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),
   *  并且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次),
   *  那么只需要替换elm以及componentInstance即可。
   */
  if (isTrue(vnode.isStatic) &&
      isTrue(oldVnode.isStatic) &&
      vnode.key === oldVnode.key &&
      (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))) {
    vnode.elm = oldVnode.elm
    vnode.componentInstance = oldVnode.componentInstance
    return
  }
  let i
  const data = vnode.data
  if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
    /*i = data.hook.prepatch,如果存在的话,见"./create-component componentVNodeHooks"*/
    i(oldVnode, vnode)
  }
  const elm = vnode.elm = oldVnode.elm
  const oldCh = oldVnode.children
  const ch = vnode.children
  if (isDef(data) && isPatchable(vnode)) {
    /* 调用update回调以及update钩子 */
    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)
  }
  /* 如果这个VNode节点没有text文本时 */
  if (isUndef(vnode.text)) {
    if (isDef(oldCh) && isDef(ch)) {
      /* 新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren */
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
    } else if (isDef(ch)) {
      /* 如果老节点没有子节点而新节点存在子节点,先清空elm的文本内容,然后为当前节点加入子节点 */
      if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
    } else if (isDef(oldCh)) {
      /* 当新节点没有子节点而老节点有子节点的时候,则移除所有ele的子节点 */
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) {
      /* 当新老节点都无子节点的时候,只是文本的替换,因为这个逻辑中新节点text不存在,所以直接去除ele的文本 */
      nodeOps.setTextContent(elm, '')
    }
  } else if (oldVnode.text !== vnode.text) {
    /* 当新老节点text不一样时,直接替换这段文本 */
    nodeOps.setTextContent(elm, vnode.text)
  }
  /* 调用postpatch钩子 */
  if (isDef(data)) {
    if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
  }
}

patchVnode 函数的主要逻辑如下:

  • 新旧节点均有 children,调用 updateChildren,对它们的子节点进行 Diff
  • 新节点有 children 而旧节点没有,清空旧节点下的文本内容,并在真实 DOM 中加入新子节点
  • 旧节点有 children 而新节点没有,清空旧节点下的所有子节点
  • 新旧节点均无 children,更新对应真实 DOM 下的文本

前面说的 Diff 算法,主要体现在新旧子节点的对比,即:updateChildren 函数中。

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  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, elmToMove, refElm

  // removeOnly is a special flag used only by <transition-group>
  // to ensure removed elements stay in correct relative positions
  // during leaving transitions
  const canMove = !removeOnly

  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)) {
      /*前四种情况其实是指定key的时候,判定为同一个VNode,则直接patchVnode即可,分别比较oldCh以及newCh的两头节点2*2=4种情况*/
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
      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)
      canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      /*
          生成一个key与旧VNode的key对应的哈希表(只有第一次进来undefined的时候会生成,也为后面检测重复的key值做铺垫)
          比如childre是这样的 [{xx: xx, key: 'key0'}, {xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}]  beginIdx = 0   endIdx = 2  
          结果生成{key0: 0, key1: 1, key2: 2}
        */
      if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      /*如果newStartVnode新的VNode节点存在key并且这个key在oldVnode中能找到则返回这个节点的idxInOld(即第几个节点,下标)*/
      idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null
      if (isUndef(idxInOld)) { // New element
        /*newStartVnode没有key或者是该key没有在老节点中找到则创建一个新的节点*/
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
        newStartVnode = newCh[++newStartIdx]
      } else {
        /*获取同key的老节点*/
        elmToMove = oldCh[idxInOld]
        /* istanbul ignore if */
        if (process.env.NODE_ENV !== 'production' && !elmToMove) {
          /*如果elmToMove不存在说明之前已经有新节点放入过这个key的DOM中,提示可能存在重复的key,确保v-for的时候item有唯一的key值*/
          warn(
            'It seems there are duplicate keys that is causing an update error. ' +
            'Make sure each v-for item has a unique key.'
          )
        }
        if (sameVnode(elmToMove, newStartVnode)) {
          /*Github:https://github.com/answershuto*/
          /*如果新VNode与得到的有相同key的节点是同一个VNode则进行patchVnode*/
          patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
          /*因为已经patchVnode进去了,所以将这个老节点赋值undefined,之后如果还有新节点与该节点key相同可以检测出来提示已有重复的key*/
          oldCh[idxInOld] = undefined
          /*当有标识位canMove实可以直接插入oldStartVnode对应的真实DOM节点前面*/
          canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm)
          newStartVnode = newCh[++newStartIdx]
        } else {
          // same key but different element. treat as new element
          /*当新的VNode与找到的同样key的VNode不是sameVNode的时候(比如说tag不一样或者是有不一样type的input标签),创建一个新的节点*/
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
          newStartVnode = newCh[++newStartIdx]
        }
      }
    }
  }
  if (oldStartIdx > oldEndIdx) {
    /*全部比较完成以后,发现oldStartIdx > oldEndIdx的话,说明老节点已经遍历完了,新节点比老节点多,所以这时候多出来的新节点需要一个一个创建出来加入到真实DOM中*/
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
  } else if (newStartIdx > newEndIdx) {
    /*如果全部比较完成以后发现newStartIdx > newEndIdx,则说明新节点已经遍历完了,老节点多余新节点,这个时候需要将多余的老节点从真实DOM中移除*/
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
  }
}

当新子节点和旧子节点都没遍历完时,进入循环。依次进行四次比较,看新旧子节点首尾是否能复用,能复用则进行如下操作:

  • 新前与旧前:调用 patchVnode 函数深入对比,并将 oldStartIdx 和 newStartIdx 加一
  • 新后与旧后:调用 patchVnode 函数深入对比,并将 oldEndIdx 和 newEndIdx 减一
  • 新后与旧前:调用 patchVnode 函数深入对比,将 oldStartVnode 移动到 oldEndVnode 后面,并将 oldStartIdx 加一、newEndIdx 减一
  • 新前与旧后:调用 patchVnode 函数深入对比,将 oldEndVnode 移动到 oldStartVnode 前面,并将 newStartIdx 加一、oldEndIdx 减一

如果经过四次比较,并没有找到可复用的节点,则生成一个哈希表 createKeyToOldIdx,以旧子节点的 key 为 key,旧子节点的 index 为 value,根据新前子节点的 key 找到对应的旧子节点。

如果新前子节点有可复用的旧子节点:

  • 调用 patchVnode 函数深入对比,将对应的旧子节点移动到 oldStartVnode 前面,并将 newStartIdx 加一

如果上述 5 种情况都找不到可复用的节点:

  • 调用 createElm 创建一个新的节点,并将其插入到 oldStartVnode 前面,将 newStartIdx 加一

重复上述操作,直到新子节点或者旧子节点遍历完。这时候也有两种情况:

  • 旧子节点遍历完:说明剩下未遍历完的新子节点是新增的,循环 newStartIdx 至 newEndIdx 的节点并创建
  • 新子节点遍历完:说明剩下未遍历完的旧子节点是多余的,循环 oldStartIdx 至 oldEndIdx 的节点并删除