响应式更新精确到组件级别

Vue 对于响应式属性的更新,只会精确更新依赖收集的当前组件,而不会递归的去更新子组件,这也是它性能强大的原因之一

例子

<template>
    <div>
        {{ msg }}
        <ChildComponent />
    </div>
</template>

当我们触发this.msg = 'hello, world'的时候,会触发组件的更新,视图的重新渲染。但是<ChildComponent />这个组件其实是不会重新渲染的,这是 Vue 刻意而为之的

React 的更新粒度

React 在类似的场景下是自顶向下的进行递归更新,也就是说,Reach 中的子组件不管嵌套了多少层,所有层次都会递归更新。因此 React 创造了 Fiber,创造了异步渲染(Fiber 就是把一个长时任务进行分片,并且维护每一个分片的数据结构)

React 遵从的是不可变的设计思想,永远不在原对象上修改属性,所以基于Object.definePropertyProxy的响应式依赖收集机制就无法使用。由于没有响应式依赖收集,React 只能递归的把所有子组件都重新渲染一遍(除了 memo 和 shouldComponentUpdate 等优化手段),然后再通过diff算法决定要更新哪部分的视图,这个递归的过程叫做reconciler,性能上很灾难

Vue 的更新粒度

Vue 的每个组件都有自己的渲染watcher,它掌管了当前组件的视图更新,但是并不会掌管子组件的更新

源码中,在patch的过程中,当组件更新到ChildComponent的时候,会走到patchVnode方法,下面看源码了解该方法做了什么事情

patchVnode

1. 执行 vnode 的 prepatch 钩子,然后会进入到updateChildComponent方法

注意,只有组件vnode才会有prepatch这个生命周期

prepatch(oldVnode, vnode) {
    const options = vnode.componentOptions
    // 这个 child 就是子组件的 vm 实例,也就是平常用的 this
    const child = vnode.componentInstance = oldVnode.componentInstance
    updateChildComponent(
        child,
        options.propsData,
        options.listeners,
        vnode,
        options.children
    )
}

可以看到做了以下 3 步

  • 更新 props
  • 更新绑定事件
  • 对于 slot 做一些更新

2. 如果有子节点的话,对子节点进行更新

对子节点的vnode利用diff算法来更新(具体算法本文略过)。到这里patchVnode就结束了

3. 如果有子组件

那么在diff的过程中,只会对component上声明的propslisteners等属性进行更新,不会深入到组件内部进行更新,到这里patchVnode就结束了

props 的更新如何触发重新渲染

在组件初始化props的时候,会走到initProps方法,此方法主要是将props也变成响应式数据

const props = (vm._props = {})
for (const key in propsOptions) {
    // 经过一系列验证 props 合法性的流程后
    const value = validateProp(key, propsOptions, propsData, vm)
    // props 中的字段也被定义成响应式了
    defineReactive(props, key, value)
}

msg传给子组件的时候,会被保存在子组件实例的_props上,并且被定义成了响应式数据,而子组件的模板中对于msg的访问是被代理到_props.msg上的,只要子组件在模板里读取了这个属性,也就能精确收集到依赖

当父组件发生重渲染的时候,会重新计算子组件的props,具体是在updateChildComponent方法中(在上面的prepatch方法的最后,会进入到该方法)

if (propsData && vm.$options.props) {
    toggleObserving(false)
    // 注意 props 被指向了 _props
    const props = vm._props
    const propKeys = vm.$options._propKeys || []
    for (let i = 0; i < propKeys.length; i++) {
        const key = propKeys[i]
        const propOptions = vm.$options.props
        // 这里触发了对于 _props.msg 的依赖更新
        props[key] = validateProp(key, propOptions, propsData, vm)
    }
    toggleObserving(true)
    vm.$options.propsData = propsData
}

vm.$forceUpdate本质上就是触发了渲染watcher的重新执行,与修改一个响应式的属性触发更新的原理是一样的,都是调用了vm._watcher.update(),只是提供了一个便捷的 api,在设计模式中叫做门面模式

官网的 api 文档中所说

vm.$forceUpdate:迫使 Vue 实例重新渲染,注意它仅仅影响实例本身和插入插槽内容的子组件,而不是所有子组件

slot 是怎么更新的

组件中含有slot的更新,是属于比较特殊的场景

假设有父组件parent-comp

<div>
    <slot-comp>
        <span>{{ msg }}</span>
    </slot-comp>
</div>

子组件slot-comp

<div>
    <slot></slot>
</div>

这里的msg属性在进行依赖收集的时候,收集到的是parent-comp渲染watcher

假如msg更新了,这个组件在更新的时候遇到了一个子组件slot-comp,按照 Vue 的精确更新策略来说,子组件是不会重新渲染的

但是在源码内部做了一个判断,在updateChildComponent方法内部

const hasChildren = !!(
    // 这里就是判断 slot 元素的地方
    (renderChildren || vm.$options._renderChildren || parentVnode.data.scopedSlots || vm.$scopedSlots !== emptyObject)
)

if (hasChildren) {
    vm.$slots = resolveSlots(renderChildren, parentVnode.context)
    vm.$forceUpdate()
}

这里调用了slot-comp组件上的vm.$forceUpdate(),因此触发了slot-comp渲染watcher

总结,这次msg的更新不光触发了parent-comp的重新渲染,也触发了拥有slot的子组件的重新渲染,也就是两层渲染,如果slot-comp内部又渲染了其他子组件,也是不会进行递归更新的(只要子组件不要再有slot

父子组件的更新会经历两个 nextTick 吗

不会

源码里queueWatcher里的逻辑,父组件更新的时候,全局变量isFlushingtrue,所有不会等到下一个tick执行,而是直接推进队列里,在一个tick里一起执行了

// 父组件更新的 nextTick 中会执行这个,会去循环运行 queue 里的 watcher
function flushSchedulerQueue() {
    currentFlushTimestamp = getNow()
    flushing = true
    for (let index = 0; index < queue.length; index++) {
        // 更新父组件
        watcher.run()
    }
}

如果在父组件更新的过程中又触发了子组件的响应式更新,导致触发了queueWatcher的话,由于isFlushingtrue,会这样走else中的逻辑,由于子组件的id是大于父组件的id的,所以会插在父组件的watcher之后,父组件的更新函数执行完毕后,就会执行子组件的watcher了,这是在同一个tick中的

if (!flushing) {
    queue.push(watcher)
} else {
    // 如果已经刷新,则根据其 id 拼接watcher
    // 如果已经超过它的 id,它将立即运行下一个
    let i = queue.length - 1
    while (i > inndex && queue[i].id > watcher.id) {
        i--
    }
    queue.splice(i + 1, 0, watcher)
}

Vue 2.6 的优化

把上述对于slot的操作又进一步优化了,简单了说,利用v-slot:foo语法生成的插槽,会统一被编译成函数,在子组件的上下文中执行,所以父组件不会再收集到它内部的依赖,如果父组件中没有用到 msg,更新只会影响到子组件本身,而不再是通过父组件修改_props来通知子组件更新了

Last Updated:
Contributors: af