原理深度解析Vue的响应式更新比React快

前言

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

例子

举例来说 这样的一个组件:

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

我们在触发 this.msg = 'Hello, Changed~'的时候,会触发组件的更新,视图的重新渲染。

但是 <ChildComponent /> 这个组件其实是不会重新渲染的,这是 Vue 刻意而为之的。

在以前的一段时间里,我曾经认为因为组件是一棵树,所以它的更新就是理所当然的深度遍历这棵树,进行递归更新。本篇就从源码的角度带你一起分析,Vue 是怎么做到精确更新的。

React的更新粒度

而 React 在类似的场景下是自顶向下的进行递归更新的,也就是说,React 中假如 ChildComponent 里还有十层嵌套子元素,那么所有层次都会递归的重新render(在不进行手动优化的情况下),这是性能上的灾难。(因此,React 创造了Fiber,创造了异步渲染,其实本质上是弥补被自己搞砸了的性能)。

他们能用收集依赖的这套体系吗?不能,因为他们遵从Immutable的设计思想,永远不在原对象上修改属性,那么基于Object.defineProperty 或 Proxy 的响应式依赖收集机制就无从下手了(你永远返回一个新的对象,我哪知道你修改了旧对象的哪部分?)

同时,由于没有响应式的收集依赖,React 只能递归的把所有子组件都重新 render一遍(除了memo和shouldComponentUpdate这些优化手段),然后再通过 diff算法 决定要更新哪部分的视图,这个递归的过程叫做 reconciler,听起来很酷,但是性能很灾难。

Vue的更新粒度

那么,Vue 这种精确的更新是怎么做的呢?其实每个组件都有自己的渲染 watcher,它掌管了当前组件的视图更新,但是并不会掌管 ChildComponent 的更新。

具体到源码中,是怎么样实现的呢?

在 patch  的过程中,当组件更新到ChildComponent的时候,会走到patchVnode,那么这个方法大致做了哪些事情呢?

patchVnode

执行 vnode 的 prepatch 钩子。

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

这里会走到updateChildComponent方法,这个 child 具体指什么呢?

 prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
  const options = vnode.componentOptions
  // 注意 这个child就是ChildComponent组件的 vm 实例,也就是咱们平常用的 this
  const child = vnode.componentInstance = oldVnode.componentInstance
  updateChildComponent(
   child,
   options.propsData, // updated props
   options.listeners, // updated listeners
   vnode, // new parent vnode
   options.children // new children
  )
 },

其实看传入的参数也能猜到大概了,就是做了:

  • 更新props(后续详细讲)
  • 更新绑定事件
  • 对于slot做一些更新(后续详细讲)

如果有子节点的话,对子节点进行 diff。

比如这样的场景:

<ul>
 <li>1</li>
 <li>2</li>
 <li>3</li>
<ul>

要对于 ul 中的三个 li 子节点 vnode 利用 diff 算法来更新,本篇略过。

然后到此为止,patchVnode 就结束了,并没有像常规思维中的那样去递归的更新子组件树。

这也就说明了,Vue 的组件更新确实是精确到组件本身的。

如果是子组件呢?

假设列表是这样的:

<ul>
 <component>1</component>
 <component>2</component>
 <component>3</component>
<ul>

那么在diff的过程中,只会对 component 上声明的 props、listeners等属性进行更新,而不会深入到组件内部进行更新。

注意:不会深入到组件内部进行更新!(划重点,这也是本文所说的更新粒度的关键)

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

那么有同学可能要问了,如果不会递归的去对子组件更新,如果我们把 msg 这个响应式元素通过props传给 ChildComponent,此时它怎么更新呢?

首先,在组件初始化 props的时候,会走到 initProps 方法。

const props = vm._props = {}

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

至此为止,是实现了对于 _props 上字段变更的劫持。也就是变成了响应式数据,后面我们做类似于 _props.msg = 'Changed' 的操作时(当然我们不会这样做,Vue内部会做),就会触发视图更新。

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

这里要注意一个细节,其实父组件发生重渲染的时候,是会重新计算子组件的 props 的,具体是在 updateChildComponent 中的:

 // update props
 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: any = vm.$options.props // wtf flow?
   // 就是这句话,触发了对于 _props.msg 的依赖更新。
   props[key] = validateProp(key, propOptions, propsData, vm)
  }
  toggleObserving(true)
  // keep a copy of raw propsData
  vm.$options.propsData = propsData
 }

那么,由于上面注释标明的那段代码,msg 的变化通过 _props 的响应式能力,也让子组件重新渲染了,到目前为止,都只有真的用到了 msg 的组件被重新渲染了。

正如官网 api 文档中所说:

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

我们需要知道一个小知识点,vm.$forceUpdate 本质上就是触发了渲染watcher的重新执行,和你去修改一个响应式的属性触发更新的原理是一模一样的,它只是帮你调用了 vm._watcher.update()(只是提供给你了一个便捷的api,在设计模式中叫做门面模式)

slot是怎么更新的?

注意这里也提到了一个细节,也就是 插入插槽内容的子组件:

举例来说

假设我们有父组件parent-comp:

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

子组件 slot-comp:

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

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

这里的 msg 属性在进行依赖收集的时候,收集到的是 parent-comp 的`渲染watcher。(至于为什么,你看一下它所在的渲染上下文就懂了。)

那么我们想象 msg 此时更新了,

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

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

但是在源码内部,它做了一个判断,在执行 slot-comp 的 prepatch 这个hook的时候,会执行 updateChildComponent 逻辑,在这个函数内部会发现它有 slot 元素。

 prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
  const options = vnode.componentOptions
  // 注意 这个child就是 slot-comp 组件的 vm 实例,也就是咱们平常用的 this
  const child = vnode.componentInstance = oldVnode.componentInstance
  updateChildComponent(
   child,
   options.propsData, // updated props
   options.listeners, // updated listeners
   vnode, // new parent vnode
   options.children // new children
  )
 },

在 updateChildComponent 内部

 const hasChildren = !!(
  // 这玩意就是 slot 元素
  renderChildren ||        // has new static slots
  vm.$options._renderChildren || // has old static slots
  parentVnode.data.scopedSlots || // has new scoped slots
  vm.$scopedSlots !== emptyObject // has old scoped slots
 )

然后下面走一个判断

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

这里调用了 slot-comp 组件vm实例上的 $forceUpdate,那么它所触发的渲染watcher就是属于slot-comp的渲染watcher了。
总结来说,这次 msg 的更新不光触发了 parent-comp 的重渲染,也进一步的触发了拥有slot的子组件 slot-comp 的重渲染。
它也只是触发了两层渲染,如果 slot-comp 内部又渲染了其他组件 slot-child,那么此时它是不会进行递归更新的。(只要 slot-child 组件不要再有 slot 了)。

比起 React 的递归更新,是不是还是好上很多呢?

赠礼 一个小issue

有人给 Vue 2.4.2 版本提了一个issue,在下面的场景下会出现 bug。

let Child = {
 name: "child",
 template:
  '<div><span>{{ localMsg }}</span><button @click="change">click</button></div>',
 data: function() {
  return {
   localMsg: this.msg
  };
 },
 props: {
  msg: String
 },
 methods: {
  change() {
   this.$emit("update:msg", "world");
  }
 }
};

new Vue({
 el: "#app",
 template: '<child :msg.sync="msg"><child>',
 beforeUpdate() {
  alert("update twice");
 },
 data() {
  return {
   msg: "hello"
  };
 },
 components: {
  Child
 }
});

具体的表现是点击 click按钮,会 alert 出两次 update twice。 这是由于子组件在执行 data 这个函数初始化组件的数据时,会错误的再收集一遍 Dep.target (也就是渲染watcher)。

由于数据初始化的时机是 beforeCreated -> created 之间,此时由于还没有进入子组件的渲染阶段, Dep.target 还是父组件的渲染watcher。

这就导致重复收集依赖,重复触发同样的更新

怎么解决的呢?很简单,在执行 data 函数的前后,把 Dep.target 先设置为 null 即可,在 finally 中再恢复,这样响应式数据就没办法收集到依赖了。

export function getData (data: Function, vm: Component): any {
 const prevTarget = Dep.target
+ Dep.target = null
 try {
  return data.call(vm, vm)
 } catch (e) {
  handleError(e, vm, `data()`)
  return {}
+ } finally {
+  Dep.target = prevTarget
 }
}

后记

如果你对于 Dep.target、 渲染watcher等概念还不太理解,可以看我写的一篇最简实现 Vue 响应式的文章,欢迎阅读:
手把手带你实现一个最精简的响应式系统来学习Vue的data、computed、watch源码

本文也存放在我的Github博客仓库中,欢迎订阅和star。

到此这篇关于原理深度解析Vue的响应式更新比React快的文章就介绍到这了,更多相关Vue的响应式更新比React快内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • vue响应式更新机制及不使用框架实现简单的数据双向绑定问题

    最近看到有些人说vue是双向数据绑定的,有些人说vue是单向数据流的,我认为这两种说法都是错误的,vue是一款具有响应式更新机制的框架,既可以实现单向数据流也可以实现数据的双向绑定. 2 单向数据流与数据双向绑定 单向数据流是指model中的数据发生改变时引起view的改变. 双向数据绑定是指model中的数据发生改变时view的改变,view的改变也会引起model的改变. //这个是单向数据流,改变这个input的value值并不能是data中的text属性发生改变. <input type

  • 原理深度解析Vue的响应式更新比React快

    前言 我们都知道 Vue 对于响应式属性的更新,只会精确更新依赖收集的当前组件,而不会递归的去更新子组件,这也是它性能强大的原因之一. 例子 举例来说 这样的一个组件: <template> <div> {{ msg }} <ChildComponent /> </div> </template> 我们在触发 this.msg = 'Hello, Changed~'的时候,会触发组件的更新,视图的重新渲染. 但是 <ChildCompone

  • 深度解析 Vue3 的响应式机制

    目录 什么是响应式 响应式原理 定制响应式数据 Vueuse 工具包 什么是响应式 响应式一直都是 Vue 的特色功能之一:与之相比,JavaScript 里面的变量,是没有响应式这个概念的:你在学习 JavaScript 的时候首先被灌输的概念,就是代码是自上而下执行的: 我们看下面的代码,代码在执行后,打印输出的两次 double 的结果也都是 2:即使 我们修改了代码中 count 的值后,double 的值也不会有任何改变 let count = 1 let double = count

  • vue视图响应式更新详细介绍

    目录 概述 思路 第一步统一封装更新函数 第二步监听并触发视图更新 引入Dep管家 实现下语法糖v-model 概述 前面两篇文章已经实现了对数据的变化的监听以及模板语法编译初始化,但是当数据变化时,视图还不能够跟随数据实时更新.本文就在之前的基础上介绍下视图响应式更新部分. 思路 统一封装更新函数 待数据发生改变时调用对应的更新函数 这里思考个问题: 在何时注册这个更新函数? 如何找到对应的更新函数? 第一步统一封装更新函数 基于上篇文章compile的部分,将数据初始化的部分统一封装起来.

  • vue.js响应式原理解析与实现

    从很久之前就已经接触过了angularjs了,当时就已经了解到,angularjs是通过脏检查来实现数据监测以及页面更新渲染.之后,再接触了vue.js,当时也一度很好奇vue.js是如何监测数据更新并且重新渲染页面.今天,就我们就来一步步解析vue.js响应式的原理,并且来实现一个简单的demo. 首先,先让我们来了解一些基础知识. 基础知识 Object.defineProperty es5新增了Object.defineProperty这个api,它可以允许我们为对象的属性来设定gette

  • Vue模拟响应式原理底层代码实现的示例

    目录 1.Vue.js功能: 2.Observer.js功能(数据劫持): 3.Compiler.js功能: 4.Dep.js功能: 5.Watcher.js功能: 整体分析Vue的基本结构如下图所示:(备注:完整代码github地址https://github.com/1512955040/MiniVue) 上图中,为我们模拟最小vue的整体结构,首先创建一个vue类型,它负责把data中的成员注入到vue实例中,并且转化成getter/setter,observer的作用是数据劫持,对dat

  • 浅谈Vue的响应式原理

    一.响应式的底层实现 1.Vue与MVVM Vue是一个 MVVM框架,其各层的对应关系如下 View层:在Vue中是绑定dom对象的HTML ViewModel层:在Vue中是实例的vm对象 Model层:在Vue中是data.computed.methods等中的数据 在 Model 层的数据变化时,View层会在ViewModel的作用下,实现自动更新 2.Vue的响应式原理 Vue响应式底层实现方法是 Object.defineProperty() 方法,该方法中存在一个getter和s

  • 浅谈Vue 数据响应式原理

    前言 Vue的数据响应主要是依赖了Object.defineProperty(),那么整个过程是怎么样的呢?以我们自己的想法来走Vue的道路,其实也就是以Vue的原理为终点,我们来逆推一下实现过程. 本文代码皆为低配版本,很多地方都不严谨,比如 if(typeof obj === 'object')这是在判断obj是否为为一个对象,虽然obj也有可能是数组等其他类型的数据,但是本文为了简便,就直接这样写来表示判断对象,对于数组使用Array.isArray(). 改造数据 我们先来尝试写一个函数

  • 通过图带你深入了解vue的响应式原理

    前言 如果自己去实现数据驱动的模式,如何解决一下几个问题: 通过什么手段去知道我的数据变了? 通过什么东西去同步更新视图? 数据劫持--obvserver 我们需要知道数据的获取和改变,数据劫持是最基础的手段.在Obeserver中,我们可以看到代码如下: Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function reactiveGetter () { // ... }, set:

  • 一步一步实现Vue的响应式(对象观测)

    平时开发中,Vue的响应式系统让我们不再去操作DOM,只需关心数据逻辑的处理,极大地降低了代码的复杂度.而响应式系统也是Vue的核心,作为开发者有必要了解其实现原理! 简易版 以watch为切入点 watch是平时开发中使用率非常高的功能,其目的是观测一个数据,当数据变化时执行我们预先定义的回调.使用方式如下: { watch: { obj(val, oldVal) { console.log(val, oldVal); } } } 上面观测了Vue实例的obj属性,当其值发生变化时,打印出新值

随机推荐