# Vue 数据响应式原理

# 响应式原理介绍

响应式 是Vue 最独特的特性之一,是非侵入性的响应式系统。数据模型仅仅是普通的 JavaScript 对象。而当你修改它们时,视图会进行更新。vue2 的数据劫持是利用 Object.defineProperty (opens new window) 的 getter 和 setter 来监听到属性的变化时做一些事情。而 vue3 已经放弃了使用这个方法,而是采用 es6 提供的 Proxy (opens new window)

# 检测变化的注意事项

# 对于对象

  • Vue 无法检测 property 的添加或移除
  • 对于已经创建的实例,Vue 不允许动态添加根级别的响应式 property
  • 有时可能需要为已有对象赋值多个新 property,比如使用 Object.assign()_.extend()。但是,这样添加到对象上的新 property 不会触发更新。

小提示

  • 对于第一、二类问题可以使用 Vue.set(object, propertyName, value) 或者 this.$set(object, propertyName, value)
  • 对于第三类问题应该用原对象与要混合进去的对象的 property 一起创建一个新的对象:
// 代替 `Object.assign(this.someObject, { a: 1, b: 2 })`
this.someObject = Object.assign({}, this.someObject, { a: 1, b: 2 })

# 对于数组

  • 当利用索引直接设置一个数组项时,例如:vm.items[indexOfItem] = newValue
  • 当修改数组的长度时,例如:vm.items.length = newLength

小提示

  • 对于第一类问题和上述对象的第一二类问题处理方法一样
  • 对于第二类问题可以用 vm.items.splice(newLength) 解决

# 额外补充

返回顶部

非侵入式介绍

如果想要更改视图,只要直接更改对应属性的值即可,不需要调用 Vue 提供的 API 接口,而像 React 和小程序则需要调用相应的接口才能使视图进行更新。举个例子🌰:

Vue:

this.a++;

小程序:

this.setData({
  a: this.data.a++
})

声明响应式 property

由于 Vue 不允许动态添加根级响应式 property,所以你必须在初始化实例前声明所有根级响应式 property,哪怕只是一个空值:

响应式原理流程图如下:

# 1. 手写总体概述

注意

目前这个文档只是介绍**【数据劫持】( Vue2版本 ),并没有说明当改变数据页面也会同时更新视图的原理,这个将在依赖收集这篇文章进行讲解,因为个人对响应式的理解是:响应式分为两个重要的步骤,一为【数据劫持】,二为【依赖收集】**,通过这两步可以实现响应式,所谓响应式就是当用户更改数据时其视图会自动进行更新。

而本文介绍响应式的两个主要部分:

  • 第一个就是把对象变成响应式的;
  • 第二个就是把数组变成响应式的;
  • 当然也还有其他的一些前置准备。

# 1.1 获取用户的 data 值

我们知道在 Vue 中,用户会传进来 options 参数,首先我们要获取用户的 data 对象的值,因为可能用户传的是对象,也可能是函数,所以要进行判断,然后将 data 挂载到 vm 实例上,这是为了后续方便获取 data 上的值:

vm.$data = data = (type === 'function' ? data.call(vm) : data);

# 1.2 代理 $data 数据

因为我们知道一般访问 data 里的数据都是直接通过 this.xxx 来访问某个属性,并且能拿到相关数据,其实是 Vue 做了一层代理,实际上还是访问的 this.$data.xxx 来获取属性的值的,因此我们可以通过 Object.defineProperty 来做代理,代理的对象是 this,如果用户想访问 this.xxx ,那么就通过 get 方法 return this.$data.xxx 来解决代理。

注意

这里不需要递归代理每一个数据,因为我们只要代理第一层数据,让代码能访问到第一层的数据即可,比如访问 this.a.b ,因为 this 对象下并没有 a 属性,所以要代理,代理后能访问到 this.a ,因为对象 a 中本来就有 b 属性,所以不进行代理还是能获取到的。

实现的思路就是获取到 vm.$data 后遍历每个 key 后进行代理处理,代码如下:

function proxyData(vm) {
  // 代理$data,能通过this.xxx直接访问属性
  const $data = vm.$data;
  for (let key in $data) {
    Object.defineProperty(vm, key, {
      get() {
        return $data[key];
      },
      set(newVal) {
        $data[key] = newVal;
      }
    })
  }
}

# 1.3 数据劫持——对象

这一步我们需要将对象进行数据劫持,这里我特地找了一下 vue 源码的数据劫持的代码,想查看可以戳👉🏻这里 (opens new window)。当时个人学习的时候第一次没学明白,其实懂了后还是觉得挺简单的,也就是简单的递归,为什么会感觉难呢?可能是因为这里的递归他不是一个函数里的递归,而是三个函数的递归,之后会展示自己画的流程图,可能会清晰一点:

  • 首先要明白这里实现数据劫持用了三个函数,分别为:

    • observe 函数
    • Observe
    • defineReactive 函数
  • 那么首先用 observe 函数来观测待劫持的对象,最开始的也就是用户传的 data 对象,注意一下 observe 函数只观测对象类型的数据,也就是 Object 或者 Array,如果不是这两个类型的直接返回,那么可能就有疑问,那普通数据怎么劫持呢,更改普通数据不也能实现响应式嘛?这是因为普通数据在对象里被观察了,因为用户传的 data 也是一个对象,所以如果基本数据类型的话,肯定是在 data 这个最大的对象下存在的,所以肯定被观测过了。

  • 其次 Observe 类主要就是对对象和数组进行观测,并实施不同的策略,如果是 Object 的话,那就调用 walk 方法,遍历当前对象的每一个 key 值,然后利用 defineReactive 函数对其进行劫持。数组会在后续讨论。

  • defineReactive 函数会一开始就调用 observe 函数,因为如果当传进来的 value 值还是 Object 就继续递归,直到为基本数据类型,就会被直接 return 回来,然后执行下面的 Object.defineProperty 方法,因为这样过后 Object 里的所有基本数据类型的值都被劫持了,深层的对象中的数据也被劫持了,目前数组里的基本类型数据先不讨论。

  • 还有一些细节也不讨论,先给一个简单的模板代码:

function observe(value) {
  // 不对基础类型进行观察
  if (typeof value !== 'object' || value === null) return;
  new Observe(value);
}

// 观察者类,用于观测数据使其成为响应式数据
function Observe(value) {
  if (Array.isArray(value)) {
    // 如果是数组
    ......
  } else {
    // 观测对象
    this.walk(value);
  }
}
  
Observe.prototype.walk = function (data) {
  for (let key in data) {
    defineReactive(data, key, data[key]);
  }
}
  
function defineReactive(data, key, value) {
  observe(value); // 递归观测
  Object.defineProperty(data, key, {
    get() {
      console.log('数据劫持get操作', key, '->', value);
      return value;
    },
    set(newVal) {
      console.log('数据劫持set操作', newVal, '<-', value);
      // 如果没改变值那么直接返回
      if (value === newVal) return;
      // 观察更改的值,让其也变成响应式
      observe(newVal);
      value = newVal;
    }
  })
}

注意

在 set 函数里要对新值也进行观察,也就是 observe(newVal),这样就能劫持新的数据了。

# 1.4 数据劫持——数组

Array 的数据劫持方法和 Object 的方式不一样,为什么会产生这样的效果呢?这是因为假设用户很可能会产生一个长度为 1000 的 Array,如果都对每个基本类型进行劫持就会非常消耗性能,因为每个数据都要 defineProperty,所以 Vue 用了一个折衷的办法:

  • 首先那就是监听 Array 中对象类型的数据( Array 或 Object ),所以我们在 Vue 中对数组的下标进行修改,如果值为基本数据类型的话是不会产生视图更新的,如果为 Object 类型,因为进行了数据劫持,是会更新视图的。

  • 同时重写了七个会改变原数组的方法:['push', 'pop', 'unshift', 'shift', 'sort', 'reverse', 'splice'],并且更改了数组实例的原型链,优先使用我们自己重写的方法。但是数组的监听用户操作又要注意以下的两个方面:

    • 一是让我们自己写的方法不失原有方法的功能和返回值,对于这个问题只要运行并返回原来函数的返回值就能解决
    • 二是使用方法时用户可能会插入新的值,那么也要进行插入值的数据劫持,这个问题需要我们对用户的操作进行判断,如果为 push|unshift|splice 之一就获取插入的值,然后对每个值进行 observe 就行
  • 数组的重写需要对原型链的知识有了解,也就是我们需要复制原有原型中的七个方法,重写后放入我们自己创建的原型中,然后将数组实例的 __proto__ 指向我们写的原型,然后我们自己的原型的 __proto__ 指向 Array.prototype 即可。

具体代码如下:

const METHODS = [
  'push',
  'pop',
  'unshift',
  'shift',
  'sort',
  'reverse',
  'splice'
];

const arrayPrototypeCopy = Array.prototype,
  newMethods = Object.create(arrayPrototypeCopy); // 我们自己的原型的 __proto__ 指向 Array.prototype

for (let m of METHODS) {
  def(newMethods, m, function () {
    const args = Array.prototype.slice.call(arguments);
    const res = arrayPrototypeCopy[m].apply(this, args);
    const ob = this.__ob__;
    console.log('监听数组的', m, '方法');
    // 监听修改数组的操作,如果添加的元素中有对象也要监听
    let addElement;
    switch (m) {
      case 'push':
      case 'unshift':
        addElement = args;
        break;
      case 'splice':
        // 因为splice添加的元素得从第三个开始算
        addElement = args.slice(2);
        break;
      default:
        break;
    }

    // 如果有添加的元素就进行观察
    addElement && ob.observeArr(addElement);
    // 如果数组更新了那么就更新页面
    ob.dep.notify();
    return res;
  }, false);
}

export {
  newMethods
}

function Observe(value) {
  if (Array.isArray(value)) {
    // 如果是数组,需要重写七个方法,且更改数组的原型链
    value.__proto__ = newMethods;
    // 观测数组中的每一项
    this.observeArr(value);
  } else {
    // 观测对象
    this.walk(value);
  }
}

Observe.prototype.observeArr = function (data) {
  for (let i = 0, len = data.length; i < len; i++) {
    observe(data[i]);
  }
}

小提示

为了让我们自己写的方法不被遍历,我们使用了 def 函数让每个函数的 enumerable 为 false。

# 1.5 给每个观测过的对象加上标识符

这个标识符也就是 __ob__,一是因为加上后如果观察的时候如果发现有这个标识符就代表观察过了,就不用递归观察了:

def(value, '__ob__', this, false);

function def(obj, key, value, enumerable) {
  Object.defineProperty(obj, key, {
    writable: true,
    configurable: true,
    value,
    enumerable
  }) 
}
  • 如果直接在观测对象下添加 __ob__ 的话,就会把该对象上的属性也进行劫持,这不需要,所以要把其的 enumerable 置为 false

二是为了依赖收集时作准备用的

到这里就实现了一个简单的数据劫持功能,讲的有点潦草,主要还是需要自己去写一遍理清流程。

# 2. 数据劫持流程图

observe函数 -> Observe类: 为Object或者Array
Observe类 -> defineReactive函数: 为Object
Observe类 -> 劫持数组: 为Array
重写数组的7个方法 -> 劫持数组: 返回自定义原型
defineReactive函数 -> observe函数: 递归
劫持数组 -> observe函数: 递归
observe函数 -> 结束: 如果为基本数据类型或者已经被监听

# 3. 写在最后