vue响应式系统源码解析

vue和react是现在前端框架的双子星。vue以其简单好用而闻名。vue以数据驱动视图,数据响应系统是vue的核心。这篇文章主要是结合源码分析vue响应式系统的原理和实现。

代理

下面这段代码是vue使用的典型方式:

<div id="app-5">
  <p>{{ message }}p>
  <button v-on:click="reverseMessage">逆转消息button>
div>
复制代码
var app5 = new Vue({
  el: '#app-5',
  data: {
    message: 'Hello Vue.js!'
  },
  methods: {
    reverseMessage: function () {
      this.message = this.message.split('').reverse().join('')
    }
  }
})
复制代码

我们可以看到当我们给this.message赋值时,视图会自动更新。这就是vue数据响应系统做的事情,当然vue做的事情远比这个复杂地多,但这是核心理念。原理也很简单,vue帮我们代理了数据的赋值操作,在数据赋值时进行了DOM的更新,这些对于vue使用者是不可见的,也是无需考虑的。

实现数据代理在js中有两种方式,一个是ES5的gettersetter方法;一个是ES6的proxyapi。vue2.5及以下采用的是gettersetter方法;即将出来的vue3.0全部改为proxy方式来实现。

getter和setter

Object提供一个方法definePropery可以让我们给一个对象的属性定义gettersetter,从而代理对象属性的取值和赋值操作,用法如下:

var o = {};
Object.defineProperty(o, "b", {
  get: function(){
    alert('我在取值');
  },
  set: function(newValue){
    alert('我在赋值');
  },
  enumerable : true,
  configurable : true
});
var a = o.b; // 弹出“我在取值”
o.b=5; // 弹出“我在赋值”
复制代码

有了这个特性,我们就可以实现最简单的双向数据绑定。比如vue中的v-model效果。

<input type="text" id="name">input>
复制代码
var data = {};
var nameDom = document.querySelector('#name');
Object.defineProperty(data, 'value', {
  get: function(){
    return nameDom.value;
  },
  set: function(value){
    nameDom.value = value;
  }
})
复制代码

由此我们便实现了最简单的双向数据绑定。当然,vue实现响应式数据的思路和上面不一样,要复杂的多,但是基本理念就是通过劫持数据的赋值和取值操作来完成的。

观察者模式

我们要想实现vue的响应式数据,就要给vue初始化对象的data和props的所有属性设置setter和getter函数。vue源码src/core/instance/state.jsinitData函数有如下代码,其中observe都是循环遍历data对象,给每个属性都设置setter和getter。

  // src/core/instance/state.js
  // observe data
  observe(data, true /* asRootData */)
复制代码

我们可以知道的是setter函数中的操作肯定是要更新DOM的,那每个数据绑定的DOM不同,绑定的属性也不同,如果像我们上面那样把每个响应式数据和具体DOM的属性绑定起来,就太麻烦和复杂了。Vue采取的策略是虚拟DOM,每次数据setter操作,都会根据你写的template或者render生成虚拟DOM,然后和之前的虚拟DOM进行比较,如果有不同,则进行DOM更新,而且只更新有变化的部分;如果相同就不做操作。这种方式可以解决我们的问题,性能也没有问题。在vue实例mount的过程中会执行以下代码:

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}
复制代码

这句就是用来执行DOM的更新渲染的,我们在响应式数据的setter中应该执行updateComponent就能达到我们的目的了。看起来很简单是吧,但是现在有两个问题:

  1. 我们在data和props中声明的属性不一定都绑定到Dom上了,如果是没有绑定到Dom上的数据,在进行setter的时候,也要DOM更新操作,虽然不会引起真正的DOM更新,但也是很浪费性能。
  2. 我们数据setter可能会不止有Dom更新的任务,比如watch了一个属性,那么这个属性就有Dom更新和watch绑定的回调两个任务。

数据setter绑定不同的任务,在数据改变时,执行所有绑定的任务,这个不就是观察者模式嘛。。。

观察者模式一个典型的例子就是DOM元素的事件绑定

var btnDom = document.getElementById('btn');
btnDom.addEventListener('click', function(){
  console.log('click事件发生了,做点啥...');
});
复制代码

我们给btnDom绑定click事件,就相当于在观察btnDom,当btnDom被点击,就会调用我们绑定的事件。现在我们的响应式数据(data和props)就是我们观察的对象,我们把需要执行的任务放入响应式数据的setter里,在响应式数据被赋值的时候,执行这些任务。观察者模式这个名字是和现实的一个类比,有的有观察者实例,有的直接注册回调函数,其实本质是一样的,这些观察者实例或者回调函数都在观察的动作中被放入被观察者的实例中的,在被观察者发生改变时,执行注册在自己身上的回调函数或者通知观察者。下面是一段典型的观察者模式实现:


//被观察者
class Subject{
 constructor(){
   this.observerList = [];
 }

 addObserver(observer){
   this.observerList.push(observer);
 }

 removeObserver(observer){
   const index = this.observerList.findIndex(item => item === observer);
   if(index !== -1){
     this.observerList.splice(index, 1);
   }
 }

 notify(context){
   const length = this.observerList.length;
   for(let i = 0; iconst observer = this.observerList[i];
     observer.notify(context);
   }
 }
}

//观察者
class Observer{
 constructor(){
   this.notify = function(){
     // ...
   };
 }
}
复制代码

Subject实例通过addObserverremoveObserver来添加和删除观察者,然后在合适的时机通过notify方法通知所有的观察者。vue也是类似的实现机制,不过Vue的设计比较巧妙,实现形式有所不同。vue有3个类是用来处理响应式数据的观察者模式:ObserverWatcherDep

  • Observer, 该类主要作用是用来定义属性的getter和setter方法
  • Watcher, 观察者类,且同时用于$watch实例方法和watch指令
  • Dep, 观察者容器,每一个响应式数据的属性都拥有一个自己独立的Dep实例,盛放自己的观察者

我们上面写的观察者模式或者是事件绑定,需要我们主动去添加观察者,那么在响应式数据这个模式当中我们应该在何时去收集属性自己的观察者那?答案是在响应式数据的getter中收集。因为被观察的数据在求值的时候肯定会触发getter函数,这是一个很好的时机,而且也能避免没有参与DOM更新的属性被绑定DOM更新的观察者。所以,vue采用的方式是在getter中收集观察者,在setter中通知观察者

Observer类型中有一个defineReactive函数,这个函数主要是用来定义属性的getter和setter方法,下面是一个简化版的defineReactive函数,去掉了一个边界情况的数据,只考虑对象这种响应式数据。

/**
* Define a reactive property on an Object.
*/
export function defineReactive (
 obj: Object,
 key: string,
 shallow?: boolean
) {
 const dep = new Dep()
 val = obj[key];
 let childOb = !shallow && observe(val)
 Object.defineProperty(obj, key, {
   enumerable: true,
   configurable: true,
   get: function reactiveGetter () {
     if (Dep.target) {
       dep.depend()
     }
     return val
   },

   set: function reactiveSetter (newVal) {
     /* eslint-disable no-self-compare */
     if (newVal === val || (newVal !== newVal && val !== val)) {
       return
     }
     val = newVal
     childOb = !shallow && observe(newVal)
     dep.notify()
   }
 })
}
复制代码

上面代码还是很清楚简单的,在getter中的dep.depend就是收集观察者;在setterdep.notify就是通知观察者。每一个响应式属性都拥有自己的闭包Dep实例,这个Dep实例中装载这所有该属性的观察者。那么Dep.target是什么东西那?它就是我们所要收集的观察者。这里Dep.target可能会有点迷糊,我们先来考虑一下vue的$watch实例方法或者watch指令是怎么用的:

vm.$watch(expOrFn, function(){...});
复制代码

expOrFn的值发送变化时,执行回调函数。而$watch方法就是新建了一个Watcher实例:

Vue.prototype.$watch = function (
  expOrFn: string | Function,
  cb: any,
  options?: Object
): Function {
  const vm: Component = this
  if (isPlainObject(cb)) { // 用于处理watch指令cb是一个对象,带有immediate或者deep参数的情况,createWatcher中是整理参数,创建Watcher实例
    return createWatcher(vm, expOrFn, cb, options)
  }
  options = options || {}
  const watcher = new Watcher(vm, expOrFn, cb, options)
  if (options.immediate) {
    cb.call(vm, watcher.value)
  }
  return function unwatchFn () {
    watcher.teardown()
  }
}
复制代码

现在我们需要明确的一点就是,其实通过改变数据来更新DOM这个操作,其实就是创建了一个渲染函数的Watcher实例,跟我们使用$watch方法去观察一个数据是一样的,只不过这个操作是Vue主动做的,只不过它观察的是所有