一文搞懂Vue响应式原理!!!

74 阅读10分钟

一、 响应式原理

什么是响应式原理?

意思就是在改变数据的时候,视图会跟着更新。(MVVM模式)

侵入式和非侵入式响应式系统

侵入式的做法就是要求用户代码“知道”框架的代码,表现为用户代码需要继承框架提供的类。 非侵入式则不需要用户代码引入框架代码的信息,从类的编写者角度来看,察觉不到框架的存在。 VUE是利用了Object.defineProperty的方法里面的setter 与getter方法的观察者模式来实现。

二、预备知识

2.1 Object.defineProperty

这个方法就是在一个对象上定义一个新的属性,或者改变一个对象现有的属性,并且返回这个对象。里面有两个字段 set,get。顾名思义,set就是去设置属性的值,而get就是获取属性的值。

举个栗子:

// 在对象中添加一个属性与存取描述符的示例
var bValue;
var obj = {};
Object.defineProperty(obj, 'a', {
  // 可枚举
  enumerable: true,
  // 可以被配置,比如可以被delete
  configurable: true,
  get() { // getter
    console.log('你试图访问a');
    return bValue;
  },
  set(newValue) { // setter
    console.log('你试图改变a');
    bValue = newValue;
  }
});
obj.a = 8;
console.log(obj.a);

最终打印

你试图改变a
你试图访问a
8

从在上述栗子中,可以看到当我们对 obj.a 赋值8的时候,就会调用set函数,这时候给bValue赋值,之后我们就可以通过obj.a来获取这个值,这时候,get函数被调用。 掌握到这一步,我们已经可以实现一个极简的VUE双向绑定了。

<input type="text" id="txt" />
<span id="sp"></span>
<script>
var txt = document.getElementById('txt'),
    sp = document.getElementById('sp'),
    obj = {}
// 给对象obj添加msg属性,并设置setter访问器
Object.defineProperty(obj, 'msg', {
  // 设置 obj.msg  当obj.msg反生改变时set方法将会被调用  
  set: function (newVal) {
    // 当obj.msg被赋值时 同时设置给 input/span
    txt.value = newVal
    sp.innerText = newVal
  }
})
// 监听文本框的改变 当文本框输入内容时 改变obj.msg
txt.addEventListener('keyup', function (event) {
  obj.msg = event.target.value
})
</script>

VUE给data里所有的属性加上set,get这个过程就叫做Reactive化

2.2 观察者模式

什么是观察者模式?它分为注册环节跟发布环节

比如我去买芝士蛋糕,但是店家还没有做出来。这时候我又不想在店外面傻傻等,我就需要隔一段时间来回来问问蛋糕做好没,对于我来说是很麻烦的事情,说不定我就懒得买了。

店家肯定想要做生意,不想流失我这个吃货客户。于是,在蛋糕没有做好的这段时间,有客户来,他们就让客户把自己的电话留下,这就是观察者模式中的注册环节。然后蛋糕做好之后,一次性通知所有记录了的客户,这就是观察者的发布环节

这里来简单实现一个观察者模式的类

function Observer() {
  this.dep = [];
  register(fn) {
    this.dep.push(fn)
  }
  notify() {
    this.dep.forEach(item => item())
  }
}
const wantCake = new Oberver();
// 每来一个顾客就注册一个想执行的函数
wantCake.register(() => {'console.log("call daisy")'})
wantCake.register(() => {'console.log("call anny")'})
wantCake.register(() => {'console.log("call sunny")'})
// 最后蛋糕做好之后,通知所有的客户
wantCake.notify()

三、原理解析

官网用了一张图来表示这个过程。

总共分为三步骤:

  • 侦测数据的变化
  • 收集视图依赖了哪些数据
  • 数据变化时,自动“通知”需要更新的视图部分,并进行更新

对应专业俗语分别是:

  • 数据劫持 / 数据代理
  • 依赖收集
  • 发布订阅模式

1、init 阶段: 数据劫持 / 数据代理

Vue通过设定对象属性的 setter/getter 方法来监听数据的变化,通过getter进行依赖收集,而每个setter方法就是一个观察者,在数据变更的时候通知订阅者更新视图。

对象响应式处理:

function render() {
  console.log('模拟视图渲染')
}
let data = {
  a: {
    m: {
      n: 5
    }
  },
  b: 10,
}
observe(data)
function observe(obj) { // 我们来用它使对象变成可观察的
 // 判断类型
  if (!obj || typeof obj !== 'object') return
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key])

  })
  function defineReactive(obj, key, value) {
   // 递归子属性
    observe(value)
    Object.defineProperty(obj, key, {
      enumerable: true, //可枚举(可以遍历)
      configurable: true, //可配置(比如可以删除)
      get: function reactiveGetter() {
        console.log('get', value) // 监听
        return value
      },
      set: function reactiveSetter(newVal) {
        observe(newVal) //如果赋值是一个对象,也要递归子属性
        if(newVal !== value) {
          console.log('set', newVal) // 监听
          render()
          value = newVal
        }
      }
    })
  }
}
data.a = {
  x: 1000,
  y: 1000
} //set {x: 1000,y: 1000} 模拟视图渲染
data.b // get 10

上面这段代码的主要作用在于:observe这个函数传入一个 obj(需要被追踪变化的对象),通过遍历所有属性的方式对该对象的每一个属性都通过 defineReactive 处理,以此来达到实现侦测对象变化。值得注意的是,observe 会进行递归调用。

不过这种方式有几个注意点需补充说明:

  • 无法检测到对象属性的添加或删除(如data.location.a=1)。

因为 Vue 通过Object.defineProperty来将对象的key转换成getter/setter的形式来追踪变化,但getter/setter只能追踪一个数据是否被修改,无法追踪新增属性和删除属性。如果是删除属性,我们可以用vm.$delete实现,如果是新增属性

1)可以使用 Vue.set(location, a, 1) 方法向嵌套对象添加响应式属性;

2)也可以给这个对象重新赋值,比如data.location = {...data.location,a:1}

数组响应式处理:

Object.defineProperty 不能监听数组的变化,需要进行数组方法的重写。

vue2.0重写了7个数组方法:popshiftunshiftsortreversesplicepush

function render() {
  console.log('模拟视图渲染')
}

let obj = [1, 2, 3]
let methods = ['pop', 'shift', 'unshift', 'sort', 'reverse', 'splice', 'push']
// 先获取到原来的原型上的方法
let arrayProto = Array.prototype
// 创建一个自己的原型 并且重写methods这些方法
let proto = Object.create(arrayProto)
methods.forEach(method => {
  proto[method] = function() {
    arrayProto[method].call(this, ...arguments)
    render()
  }
})
function observer(obj) {
  // 把所有的属性定义成set/get的方式
  if (Array.isArray(obj)) {
    obj.__proto__ = proto
    return
  }
  if (typeof obj == 'object') {
    for (let key in obj) {
      defineReactive(obj, key, obj[key])
    }
  }
}
function defineReactive(data, key, value) {
  observer(value)
  Object.defineProperty(data, key, {
    get() {
      return value
    },
    set(newValue) {
      observer(newValue)
      if (newValue !== value) {
        render()
        value = newValue
      }
    }
  })
}
observer(obj)
function $set(data, key, value) {
  defineReactive(data, key, value)
}
obj.push(123, 55)
console.log(obj) //[1, 2, 3, 123,  55]

这种方法将数组的常用方法进行重写,进而覆盖掉原生的数组方法,重写之后的数组方法需要能够被拦截。

2、mount 阶段:依赖收集

为什么要收集依赖

我们之所以要观察数据,其目的在于当数据的属性发生变化时,可以通知那些曾经使用了该数据的地方。

let globalData = {
    text: 'vue1'
};
let test1 = new Vue({
    template:
        `<div>
            <span>{{text}}</span> 
        <div>`,
    data: globalData
});
let test2 = new Vue({
    template:
        `<div>
            <span>{{text}}</span> 
        <div>`,
    data: globalData
});

如果我们执行下面这条语句:

globalData.text = '我修改了text';

此时我们需要通知 test1 以及 test2 这两个Vue实例进行视图的更新,我们只有通过收集依赖才能知道哪些地方依赖我的数据,以及数据更新时派发更新。

收集依赖需要为依赖找一个存储依赖的地方,为此我们创建了Dep,它用来收集依赖、删除依赖和向依赖发送消息等。

于是我们先来实现一个订阅者 Dep 类,用于解耦属性的依赖收集和派发更新操作,它的主要作用是用来存放 Watcher 观察者对象。我们可以把Watcher理解成一个中介的角色,数据发生变化时通知它,然后它再通知其他地方。

class Dep {
    constructor () {
        /* 用来存放Watcher对象的数组 */
        this.subs = [];
    }
    /* 在subs中添加一个Watcher对象 */
    addSub (sub) {
        this.subs.push(sub);
    }
    /* 通知所有Watcher对象更新视图 */
    notify () {
        this.subs.forEach((sub) => {
            sub.update();
        })
    }
}

以上代码主要做两件事情:

  • 用 addSub 方法可以在目前的 Dep 对象中增加一个 Watcher 的订阅操作;
  • 用 notify 方法通知目前 Dep 对象的 subs 中的所有 Watcher 对象触发更新操作。

所以当需要依赖收集的时候调用 addSub,当需要派发更新的时候调用 notify。

function defineReactive(obj: Object, key: string, ...) {
    const dep = new Dep()
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get: function reactiveGetter () {
        ....
        dep.depend()
        return value
        ....
      },
      set: function reactiveSetter (newVal) {
        ...
        val = newVal
        dep.notify()
        ...
      }
    })
  }
  

Vue 中定义一个 Watcher 类来表示观察订阅依赖。

当属性发生变化后,我们要通知用到数据的地方,而使用这个数据的地方有很多,而且类型还不一样,既有可能是模板,也有可能是用户写的一个watch, 这时需要抽象出一个能集中处理这些情况的类。然后,我们在依赖收集阶段只收集这个封装好的类的实例进来,通知也只通知它一个,再由它负责通知其他地方。

依赖收集的目的是将观察者 Watcher 对象存放到当前闭包中的订阅者 Dep 的 subs 中

Watcher的简单实现

class Watcher {
  constructor(obj, key, cb) {
    // 将 Dep.target 指向自己
    // 然后触发属性的 getter 添加监听
    // 最后将 Dep.target 置空
    Dep.target = this
    this.cb = cb
    this.obj = obj
    this.key = key
    this.value = obj[key]
    Dep.target = null
  }
  update() {
    // 获得新值
    this.value = this.obj[this.key]
   // 定义一个 cb 函数,这个函数用来模拟视图更新,调用它即代表更新视图
    this.cb(this.value)
  }
}

以上就是 Watcher 的简单实现,在执行构造函数的时候将 Dep.target 指向自身,从而使得收集到了对应的 Watcher,在派发更新的时候取出对应的 Watcher, 然后执行 update 函数。

收集依赖

所谓的依赖,其实就是Watcher。至于如何收集依赖,总结起来就一句话,在getter中收集依赖,在setter中触发依赖。 先收集依赖,即把用到该数据的地方收集起来,然后等属性发生变化时,把之前收集好的依赖循环触发一遍就行了。

具体来说,当外界通过Watcher读取数据时,便会触发getter从而将Watcher添加到依赖中,哪个Watcher触发了getter,就把哪个Watcher收集到Dep中。当数据发生变化时,会循环依赖列表,把所有的Watcher都通知一遍。

mount 阶段的时候,会创建一个Watcher类的对象。这个Watcher实际上是连接Vue组件与Dep的桥梁。

每一个Watcher对应一个vue component。

最后我们对 defineReactive 函数进行改造,在自定义函数中添加依赖收集和派发更新相关的代码,实现了一个简易的数据响应式。

function observe (obj) {
  // 判断类型
  if (!obj || typeof obj !== 'object') {
    return
  }
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key])
  })
  function defineReactive (obj, key, value) {
    observe(value)  // 递归子属性
    let dp = new Dep() //新增
    Object.defineProperty(obj, key, {
      enumerable: true, //可枚举(可以遍历)
      configurable: true, //可配置(比如可以删除)
      get: function reactiveGetter () {
        console.log('get', value) // 监听
     // 将 Watcher 添加到订阅
       if (Dep.target) {
         dp.addSub(Dep.target) // 新增
       }
        return value
      },
      set: function reactiveSetter (newVal) {
        observe(newVal) //如果赋值是一个对象,也要递归子属性
        if (newVal !== value) {
          console.log('set', newVal) // 监听
          render()
          value = newVal
     // 执行 watcher 的 update 方法
          dp.notify() //新增
        }
      }
    })
  }
}

class Vue {
    constructor(options) {
        this._data = options.data;
        observer(this._data);
        /* 新建一个Watcher观察者对象,这时候Dep.target会指向这个Watcher对象 */
        new Watcher();
        console.log('模拟视图渲染');
    }
}

而render函数里,会访问data的属性,比如

render: function (createElement) {
  return createElement('h1', this.shopInfo)

}

此时会去调用这个属性shopInfo的getter函数,即:

// getter函数

get: function reactiveGetter () {
    ....

    if (Dep.target) {
      dp.addSub(Dep.target) // 新增
    }
    ....

 },

在get的函数里,Dep.target就是watcher本身,这里做的事情就是给shopInfo注册了Watcher这个对象。这样每次render一个vue 组件的时候,如果这个组件用到了shopInfo,那么这个组件相对应的Watcher对象都会被注册到shopInfo的Dep中。这个过程就叫做依赖收集

收集完所有依赖shopInfo属性的组件所对应的Watcher之后,当它发生改变的时候,就会去通知Watcher更新关联的组件。

3、更新阶段:发布订阅模式

当shopInfo发生改变的时候,就去调用Dep的notify函数,然后通知所有的Watcher调用update函数更新。

notify () {
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
}

可以用一张图来表示:

由此图我们可以看出Watcher是连接VUE component 跟 data属性的桥梁。

总结

  • new Vue() 后, Vue 会调用_init 函数进行初始化,也就是init 过程,在这个过程Data通过Observer转换成了getter/setter的形式,来对数据追踪变化,当被设置的对象被读取的时候会执行getter 函数,而在当被赋值的时候会执行 setter函数。
  • 当外界通过Watcher读取数据时,会触发getter从而将Watcher添加到依赖中。
  • 在修改对象的值的时候,会触发对应的settersetter通知之前依赖收集得到的 Dep 中的每一个 Watcher,告诉它们自己的值改变了,需要重新渲染视图。这时候这些 Watcher就会开始调用 update 来更新视图。