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


Posted in Javascript onApril 04, 2020

前言

我们都知道 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快内容请搜索三水点靠木以前的文章或继续浏览下面的相关文章希望大家以后多多支持三水点靠木!

Javascript 相关文章推荐
用js实现控制内容的向上向下滚动效果
Jun 26 Javascript
JS中处理与当前时间间隔的函数代码
May 23 Javascript
qq悬浮代码(兼容各个浏览器)
Jan 29 Javascript
JSONP之我见
Mar 24 Javascript
jquery中cookie用法实例详解(获取,存储,删除等)
Jan 04 Javascript
JavaScript实现简单的tab选项卡切换
Jan 05 Javascript
JavaScript中的Reflect对象详解(ES6新特性)
Jul 22 Javascript
第一次接触神奇的Bootstrap网格系统
Jul 27 Javascript
Node.js利用js-xlsx处理Excel文件的方法详解
Jul 05 Javascript
详解基于vue的移动web app页面缓存解决方案
Aug 03 Javascript
jquery鼠标悬停导航下划线滑出效果
Sep 29 jQuery
JS中Map和ForEach的区别
Feb 05 Javascript
Vue的data、computed、watch源码浅谈
Apr 04 #Javascript
VUE table表格动态添加一列数据,新增的这些数据不可以编辑(v-model绑定的数据不能实时更新)
Apr 03 #Javascript
mpvue实现微信小程序快递单号查询代码
Apr 03 #Javascript
mpvue网易云短信接口实现小程序短信登录的示例代码
Apr 03 #Javascript
javascript用defineProperty实现简单的双向绑定方法
Apr 03 #Javascript
JavaScript检测浏览器是否支持CSS变量代码实例
Apr 03 #Javascript
JS内置对象和Math对象知识点详解
Apr 03 #Javascript
You might like
基于mysql的bbs设计(一)
2006/10/09 PHP
php中DOMDocument简单用法示例代码(XML创建、添加、删除、修改)
2010/12/19 PHP
PHP获取中国时间(上海时区时间)及美国时间的方法
2017/02/23 PHP
Ubuntu中支持PHP5与PHP7双版本的简单实现
2018/08/19 PHP
基于PHP实现用户在线状态检测
2020/11/10 PHP
JavaScript 变量命名规则
2009/09/23 Javascript
JQuery 获取和设置Select选项的代码
2010/02/07 Javascript
Javascript Math ceil()、floor()、round()三个函数的区别
2010/03/09 Javascript
createElement与createDocumentFragment的点点区别小结
2011/12/19 Javascript
Firefox中beforeunload事件的实现缺陷浅析
2012/05/03 Javascript
简单的邮箱登陆的提示效果类似于yahoo邮箱
2014/02/26 Javascript
jquery选择器原理介绍($()使用方法)
2014/03/25 Javascript
jQuery源码解读之removeClass()方法分析
2015/02/20 Javascript
JS实现超简单的仿QQ折叠菜单效果
2015/09/21 Javascript
js 点击a标签 获取a的自定义属性方法
2016/11/21 Javascript
javascript入门之window对象【新手必看】
2016/11/22 Javascript
jQuery实现标签页效果实战(4)
2017/02/08 Javascript
jQuery实现动态删除LI的方法
2017/05/30 jQuery
react中fetch之cors跨域请求的实现方法
2018/03/14 Javascript
浅析Vue 生命周期
2018/06/21 Javascript
关于Vue中axios的封装实例详解
2019/10/20 Javascript
微信小程序报错: thirdScriptError的错误问题
2020/06/19 Javascript
如何使用python3获取当前路径及os.path.dirname的使用
2019/12/13 Python
python应用Axes3D绘图(批量梯度下降算法)
2020/03/25 Python
HTML5实现获取地理位置信息并定位功能
2015/04/25 HTML / CSS
澳大利亚玩具剧场:Toy Playhouse
2019/03/03 全球购物
英国高级健康和美容产品零售商:Life and Looks
2019/08/01 全球购物
日本最大的购物网站乐天市场国际版:Rakuten Global Market(支持中文)
2020/02/03 全球购物
给国外客户的邀请函
2014/01/30 职场文书
学校周年庆活动方案
2014/08/22 职场文书
家庭贫困证明范本(经典版)
2014/09/22 职场文书
高中生第一学年自我鉴定2015
2014/09/28 职场文书
邀请函的格式
2015/01/30 职场文书
2015年安全生产月活动总结
2015/03/26 职场文书
物业接待员岗位职责
2015/04/15 职场文书
门球健将观后感
2015/06/16 职场文书