Skip to content
On this page

组件更新

我们之前分析Vue实现组件化挂载的源码分析,知道了组件是怎么一步一步创建到挂载到真实的DOM中。现在,我们结合Vue的响应式原理,看看当状态发生变化时,组件是怎么进行更新操作的。

一个例子

其实,Vue的虚拟DOM的更新是模仿snabdom实现的,对于两个节点的对比过程基本一样。所以对于diff算法的分析,可以参考VirtualDOM 的简单实现,我们用一个例子来跑下整个过程:

html
// App.vue
<template>
  <div class="app">
    <p>I am App</p>
    <Hello :list="list"></Hello>
    <button @click="handleClick">change</button>
  </div>
</template>

<script>
import Hello from './Hello';

export default {
  name: 'app',
  data() {
    return {
      list: ['A', 'B', 'C', 'D']
    };
  },
  methods: {
    handleClick() {
      this.list.reverse().splice(2, 0, 'E');
    }
  },
  components: {
    Hello
  }
};
</script>

Hello组件:

html
// Hello.vue
<template>
  <ul>
    <li v-for="(item, index) in list" :key="index">{{ item }}</li>
  </ul>
</template>

<script>
export default {
  props: ['list']
};
</script>

这个例子App组件传递一个list作为props给Hello组件,并且显示ABCD。当我们点击按钮的时候,列表的渲染内容会改变,最终渲染成DCEBA。你或许有个疑问,就是list是App组件的状态,当它改成的时候只会触发App的渲染watcher,那Vue是怎么通知到子组件,并让他也触发渲染watcher的呢?

props的监听

我们知道Vue组件会对data状态在初始化时进行响应式处理,其实对prop也进行了getter/setter的设置,并且发生在data初始化之前,先来看下组件状态的初始化顺序:

js
export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

初始化状态的顺序是props,methods,data,computedwatch,这个顺序是必要的。因为我们在data中能用到prop,在watch中能监听到所有状态,所以它必须在最后。在initProps方法就是对props的处理:

js
function initProps (vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {}
  const props = vm._props = {}
  // cache prop keys so that future props updates can iterate using Array
  // instead of dynamic object key enumeration.
  const keys = vm.$options._propKeys = []
  const isRoot = !vm.$parent
  // root instance props should be converted
  if (!isRoot) {
    toggleObserving(false)
  }
  for (const key in propsOptions) {
    keys.push(key)
    // 获取props对应key的值
    const value = validateProp(key, propsOptions, propsData, vm)
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== 'production') {
      const hyphenatedKey = hyphenate(key)
      if (isReservedAttribute(hyphenatedKey) ||
          config.isReservedAttr(hyphenatedKey)) {
        warn(
          `"${hyphenatedKey}" is a reserved attribute and cannot be used as component prop.`,
          vm
        )
      }
      defineReactive(props, key, value, () => {
        if (!isRoot && !isUpdatingChildComponent) {
          warn(
            `Avoid mutating a prop directly since the value will be ` +
            `overwritten whenever the parent component re-renders. ` +
            `Instead, use a data or computed property based on the prop's ` +
            `value. Prop being mutated: "${key}"`,
            vm
          )
        }
      })
    } else {
      defineReactive(props, key, value)
    }

    if (!(key in vm)) {
      proxy(vm, `_props`, key)
    }
  }
  toggleObserving(true)
}

这段代码主要是循环props,通过validateProp方法获取对应prop的值并且会校验类型,最后调用defineReactive将其转为响应式的,并且把props代理到实例上。propsData是属性的数据对象,它是在创建组件节点是就已经处理好了,在createComponent方法中:

js
// 从vnode的data中提取props数据
const propsData = extractPropsFromVNodeData(data, Ctor, tag)

所以在我们例子的Hello组件中list发生变化,会触发自身的渲染watcher。现在就来看看App是怎么通知到Hello组件的。

组件虚拟节点更新

当我们list发生变化,会通知App组件的渲染watcher,然后进行render后更新操作。和处理挂载不同是,在执行vm._update()时候,因为我们之前vnode是存在的,所以会执行下面代码:

js
// src/core/instance/lifecycle.js

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { 

  const prevVnode = vm._vnode

  if (!prevVnode) {
    // ...
  } else {
    // 状态发生变化,对比新老节点
    vm.$el = vm.__patch__(prevVnode, vnode)
  }

}

然后调用patch方法,这个时候的oldVnode不是真实的DOM节点并且和vnode是同一个节点,所以会走下面的逻辑:

js
const isRealElement = isDef(oldVnode.nodeType) // 是否为dom节点,首次挂载为true
if (!isRealElement && sameVnode(oldVnode, vnode)) {
  // patch existing root node
  // 同一个节点进行diff
  patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}

sameVnode方法判断两个虚拟节点是否同一个节点:

js
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)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

首先两个节点的key必须全等,如果没有设置两个undefiend也是成立的。然后如果是同步的节点,判断tag,isComment相等,并且是同一种类型的input。如果是异步节点,判断两个异步工厂函数是否相等。我们例子中App组件的是同一个节点,所以会调用patchVnode方法进行两个节点的对比:

js
function patchVnode (
  oldVnode,
  vnode,
  insertedVnodeQueue,
  ownerArray,
  index,
  removeOnly
) {
  if (oldVnode === vnode) {
    return
  }

  if (isDef(vnode.elm) && isDef(ownerArray)) {
    vnode = ownerArray[index] = cloneVNode(vnode)
  }

  const elm = vnode.elm = oldVnode.elm

  if (isTrue(oldVnode.isAsyncPlaceholder)) {
    if (isDef(vnode.asyncFactory.resolved)) {
      hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
    } else {
      vnode.isAsyncPlaceholder = true
    }
    return
  }

  // 静态节点的处理
  if (isTrue(vnode.isStatic) &&
    isTrue(oldVnode.isStatic) &&
    vnode.key === oldVnode.key &&
    (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
  ) {
    vnode.componentInstance = oldVnode.componentInstance
    return
  }

  let i
  const data = vnode.data
  // 组件占位节点调用prepatch钩子
  // 更新组件实例的props和listener等,触发组件的prop的setter
  if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
    i(oldVnode, vnode)
  }

  const oldCh = oldVnode.children
  const ch = vnode.children
  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)
  }
  if (isUndef(vnode.text)) {
    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)
  }
  // 调用postpatch钩子
  if (isDef(data)) {
    if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
  }
}

这个方法主要分成3部分,第一部分是调用prepatch钩子,前提是这个vnode是一个组件占位节点:

js
let i
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
  i(oldVnode, vnode)
}

这带代码就是通知子组件进行更新的关键。我们来看下prepatch钩子函数的定义:

js
prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
  const options = vnode.componentOptions
  const child = vnode.componentInstance = oldVnode.componentInstance
  updateChildComponent(
    child,
    options.propsData, // updated props
    options.listeners, // updated listeners
    vnode, // new parent vnode
    options.children // new children
  )
}

prepatch钩子的作用是修改子组件实例的propslistenersslot。在updateChildComponent函数中有这么一段代码:

js
// update props
if (propsData && vm.$options.props) {
  toggleObserving(false)
  const props = vm._props
  const propKeys = vm.$options._propKeys || []
  for (let i = 0; i < propKeys.length; i++) {
    const key = propKeys[i]
    const propOptions: any = vm.$options.props // wtf flow?
    props[key] = validateProp(key, propOptions, propsData, vm)
  }
  toggleObserving(true)
  // keep a copy of raw propsData
  vm.$options.propsData = propsData
}

很显然,这个时候我们的Hello组件的list已经修改成了新的状态。由于Vue对prop也进行了响应式监听,所以这个时候Hello组件的渲染watcher会被通知,并且在App的渲染watcher执行完后再执行。也就是说父组件先patch更新DOM完后子组件才会更新。

子组件更新

因为我们组件的patch过程是一个深度优先遍历的过程,当父组件更新完后,子组件才开始自己的patch流程,并且执行的工作和父组件一样。在我们例子中,由于list状态发生变化,所以会重新渲染。在渲染过程中,会调用updateChildren方法进行子节点的对比更新:

js
if (isDef(oldCh) && isDef(ch)) {
  if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} 

因为updateChildren方法和snabdom实现的原理一致,就不做分析。我们来结合例子看下整个过程:

第一步,oldStartIdx和newEndIdx都是A节点,在进行A节点patch后把移动到为处理节点的后面,也就是oldEndIdx节点的后面:

第二步,发现oldStartIdx和newEndIdx都是B节点,过程和第一步一样:

第三步,oldEndIdx和newStartIdx都是D节点,更新后把D节点移动后未处理节点的前面,也就是oldStartIdx的前面:

第四步,oldStartIdx和newStartIdx都是C节点,不用进行移动

第五步,这时候发现oldStartIdx已经大于oldEndIdx,也就是说如果新的节点还有为处理,那么都是新增的节点,对应我们的E节点。这个时候把所有未处理的节点插入到newEndIdx后面一个节点的前面,也就是把E插在B节点的前面:

总结

到此,结合前面的文章,我们就从源码的角度分析完Vue组件状态的改变到视图更新的全部流程,可以总结成下面一张图: