原理深度解析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函数中onmousedown和onclick的区别和联系探讨
May 19 Javascript
浅谈JavaScript的事件
Feb 27 Javascript
BOM系列第二篇之定时器requestAnimationFrame
Aug 17 Javascript
Extjs让combobox写起来简洁又漂亮
Jan 05 Javascript
JavaScript &amp; jQuery完美判断图片是否加载完毕
Jan 08 Javascript
浅谈javascript的url参数parse和build函数
Mar 04 Javascript
js实现轮播图的两种方式(构造函数、面向对象)
Sep 30 Javascript
Node.JS使用Sequelize操作MySQL的示例代码
Oct 09 Javascript
JS简单实现点击跳转登陆邮箱功能的方法
Oct 31 Javascript
Vue 全家桶实现移动端酷狗音乐功能
Nov 16 Javascript
浅谈layui里的上传控件问题
Sep 26 Javascript
原生js实现瀑布流效果
Mar 09 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
IIS6+PHP5+MySQL5+Zend Optimizer+phpMyAdmin安装配置图文教程 2009年
2009/06/08 PHP
php错误级别的设置方法
2013/06/17 PHP
php使用sql server验证连接数据库的方法
2014/12/25 PHP
Laravel框架运行出错提示RuntimeException No application encryption key has been specified.解决方法
2019/04/02 PHP
PHP7中I/O模型内核剖析详解
2019/04/14 PHP
基于jquery的获取mouse坐标插件的实现代码
2010/04/01 Javascript
一个简单的JS时间控件示例代码(JS时分秒时间控件)
2013/11/22 Javascript
Node.js实现的简易网页抓取功能示例
2014/12/05 Javascript
Listloading.js移动端上拉下拉刷新组件
2016/08/04 Javascript
JavaScript中${pageContext.request.contextPath}取值问题及解决方案
2016/12/08 Javascript
20行JS代码实现网页刮刮乐效果
2017/06/23 Javascript
EasyUI实现下拉框多选功能
2017/11/07 Javascript
Vue框架TypeScript装饰器使用指南小结
2019/02/18 Javascript
总结python实现父类调用两种方法的不同
2017/01/15 Python
Python中实现switch功能实例解析
2018/01/11 Python
Python实现扣除个人税后的工资计算器示例
2018/03/26 Python
Django组件之cookie与session的使用方法
2019/01/10 Python
Python实现加密的RAR文件解压的方法(密码已知)
2020/09/11 Python
HTML5 canvas实现移动端上传头像拖拽裁剪效果
2016/03/14 HTML / CSS
美国家喻户晓的保健品品牌:Vitamin World(维他命世界)
2016/08/19 全球购物
skyn ICELAND官网:冰岛成分天然护肤品
2020/08/24 全球购物
电气技术员岗位职责
2013/11/19 职场文书
实用求职信范文分享
2013/12/25 职场文书
理工类毕业自我鉴定
2014/02/20 职场文书
巾帼文明岗申报材料
2014/05/01 职场文书
保安公司服务承诺书
2014/05/28 职场文书
学校周年庆活动方案
2014/08/22 职场文书
企业财务人员岗位职责
2015/04/14 职场文书
超市员工管理制度
2015/08/06 职场文书
《我和小伙伴》教学反思
2016/02/20 职场文书
nginx网站服务如何配置防盗链(推荐)
2021/03/31 Servers
Python基础之数据结构详解
2021/04/28 Python
浅析NIO系列之TCP
2021/06/15 Java/Android
MySQL 十大常用字符串函数详解
2021/06/30 MySQL
mysql备份策略的实现(全量备份+增量备份)
2021/07/07 MySQL
Python采集爬取京东商品信息和评论并存入MySQL
2022/04/12 Python