尝试解释Vue的变化侦测原理

2019-08-12

变化侦测是Vue以及其他现代前端框架的基本原理。使用了变化侦测技术,就可以让程序员可以只关心操作数据,就可以实现页面响应式的渲染,而不用手撕DOM。而且操作都是看起来透明的,没有什么特别的语法和API。正是这些优点,让变化侦测真的成为了现代前端框架的灵魂。

推荐一下《深入浅出Vue.js》这本书,360的大神刘博文写的,带着手撕vue代码讲原理。看看这本书再看看Vue源码,基本就懂了八九不离十了。

变化侦测做什么

使用Vue和其他现代Web框架,可以让开发者声明DOM和数据的关系(模板、JSX),只操作数据就可以实现DOM随数据的变化更新。而变化侦测的作用就是当数据变化时,通知对应的功能模块(虚拟DOM)进行重新渲染。

追踪变化的方法

追踪变化,指变化发生时能够及时发现。VUE封装了对两个数据类型变化的侦测:对象Object和数组对象Array。

对象

对对象来说,造成对象中的值的变化的方法主要是通过.点运算符,比如a.b=1,也就是说变化来自点运算符的set作用。我们知道在JS中侦测这种变化主要有两种方法:Object.definePropertyProxy。由于Proxy是ES6新标准的技术,对低版本浏览器支持还不够灵活,Vue选择了使用Object.defineProperty的方法。

var value
Object.defineProperty(obj, key, {
		get: function(){
      return value
    }
  	set: function(val){
  		console.log('变化发生')
  		value = val
		}
	}
})

如示例所示,每当obj.key被赋值时,将会触发set方法。我们可以在set方法中通知相应的变化依赖,这样就不会错过对象的变化监听。

这种监控变化的问题是,如果向对象中添加了属性a.b = 1,Vue是监控不到的。所以Vue提供了方法vm.$set来添加可被监听的对象,在Vue风格指南中也推荐在data定义时就确保所有的属性都被定义,哪怕先赋值一undefine。同时,还有一个问题是通过delete操作符去删除对象的属性也是不被支持的,Vue也提供了vm.$delete来代替。

数组

对于数组对象Array,程序员一般通过数组的方法来操纵数组。JS这门动态语言可以允许重写任何对象的任何方法(除非通过Object.definePropertyObject.freeze方法来禁止对象的变化)。Vue选择了重写Array.prototype上的方法来完成追踪变化。当然这不是唯一的选择,也可以使用Proxy,同样也是因为兼容性的考虑。

const originPush = Array.prototype.push
Array.prototype.push = function(){
  console.log('变化发生')
  originPush.apply(this,argments)
}

如示例所示,每当一个数组对象调用push时,实际上是调用的重写的方法。我们可以按这个思路重写所有的会造成数组本身变化的方法,就可以监听数组的变化。

这种监控变化的问题是,Vue不能监听直接使用下标修改数组中的元素,如a[2]=1,推荐用splice方法代替。还有问题是直接修改数组的长度length,比如将length置为0进行数组清空的操作Vue无法监听,推荐用splice代替。

其他数据对象呢

Vue暂时没有支持的意思。比如Set和Map,最好不用这些数据类型作为响应式数据,或需要响应时替换。

追踪变化通知谁

追踪变化需要通知谁这个问题,等价就是问谁需要知道变化。我们称需要知道变化的对象或函数为一个依赖。

在Vue中,首先需要知道数据变化的就是模板和JSX,当数据变化时,它们需要跟随数据变化而重新渲染DOM。首先我们知道Vue中写的模板和JSX最终会编译成渲染函数,渲染函数产生vnode(虚拟DOM)。vnode本质上来说,也是JS的对象,它也是在函数中通过访问数据的get方法(即使用.运算符或[])来访问对象属性,本质上并没有“高人一等”。

其次就是Vue的watch和compute属性,这两个属性中定义的函数都需要了解变化。watch函数是直接指定了对象名称,和需要追踪变化的对象关系非常明确。而compute和虚拟DOM一样,也是在函数中通过访问数据的get方法来访问对象属性。

所以非常显然,我们可以在get方法中记录有哪些函数访问过数据,在set方法中重新调用这些函数即可。也就是Vue的设计思路:在getter中收集依赖,在setter中触发依赖

在具体的实现中,Vue还多加了一个中间层Watcher作为依赖,当数据变化时,setter首先通知Watcher,Watcher再去调用对应的函数。主要是用来将依赖和变化解耦,否则变化源中需要存放所有的依赖,非常难看。