一文彻底搞懂Vue2响应式原理

1,980 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第2天,点击查看活动详情

什么是响应式

Vue的响应式是实现数据驱动视图的第一步,也是考察Vue原理的第一题。其作用是监听data的数据,组件data的数据一旦变化,立即触发视图的更新。

基于属性监听的响应式

核心API:Object.defineProperty

Vue2.0的响应式核心API:Object.defineProperty。

作用:Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。

语法:Object.defineProperty(obj, prop, descriptor)

  • obj是目标对象,prop是要定义的属性,descriptor是属性描述符,定义属性的访问行为。

descriptor可以分为数据描述符和存取描述符两类。

  1. 数据描述符即普通的属性赋值,涉及描述字段:value和writable。
const data = {};

Object.defineProperty(data, 'name', {
  value: 'zhangsan', // 属性赋值
  writable: true, // 属性值是否可以修改,默认值false
});
  1. 访问描述符即通过设置getter和setter控制属性存取行为,涉及描述字段:get和set。
const data = {};
let value = 'zhangsan';

Object.defineProperty(data, 'name', {
  get() {      // 读取属性时行为
    return value;
  },
  set(newValue) {  // 属性赋值时行为
    value = newValue;
  },
});

数据描述符和访问描述符不能共用,即value,writable和get,set两组字段不能共存。

descriptor还有一组字段:configurable和enumerable,这两个字段可以被数据描述符和访问描述符共享。

Object.defineProperty(data, 'name', {
  configurable: true, // 属性是否可以重新定义和属性是否可以删除,默认值false
  enumerable: true,   // 属性是否可以枚举,默认值false
  value: 'zhangsan',
  writable: false
});

let value = 'zhangsan';
Object.defineProperty(data, 'name', {
  configurable: true, // 属性是否可以重新定义和属性是否可以删除,默认值false
  enumerable: true,   // 属性是否可以枚举,默认值false
  get() {
    return value;
  },
  set(newValue) {
    value = newValue;
  }
});

configurable为false时无法重新defindProperty,也不能删除属性;enumerable为false时for...in或者Object.keys()无法遍历到该属性

到这里我们发现,能够定义响应式逻辑的地方只有get和set函数,这就是响应式的关键。

实现响应式的关键逻辑

普通对象监听

const data = {
  name: 'zhangsan',
  age: 20,
};

// 视图更新逻辑
function udpateView() {
  console.log('视图更新');
}

// 通过Object.defineProperty重新定义属性
function defineReactive(target, key, value) {
  Object.defineProperty(target, key, {
    configurable: true,
    enumerable: true,
    get() {
      return value;
    },
    set(newValue) {
      if (newValue !== value) {
        value = newValue;
        udpateView();
      }
    },
  });
}

// 监听对象属性
function observer(target) {
  if (typeof target !== 'object' || typeof target === null) {
    return target;
  }

  for (const key in target) {
    defineReactive(target, key, target[key]);
  }
}

observer(data);
data.name = 'lisi';
console.log(data.name);

深度对象监听

如果data中的属性值还是一个对象,那么该对象中的属性也需要被监听起来,这种场景典型的需要用到递归。

function defineReactive(target, key, value) {
  observer(value); // 深度监听
  Object.defineProperty(target, key, {
    configurable: true,
    enumerable: true,
    get() {
      return value;
    },
    set(newValue) {
      if (newValue !== value) {
        observer(newValue); // 更新数据时也要深度监听
        value = newValue;
        udpateView();
      }
    },
  });
}

使用Object.defineProperty做响应式的缺点

  1. 深度监听,需要一次性递归到底,计算量比较大
  2. 描述符只有get和set,无法监听新增属性和删除属性操作
  3. 无法原生监听数组

这三个缺点中,第2点是defineProperty本身API缺陷,而第1点和第3点都是出于性能考虑而做的取舍

为什么一次性递归是最优解?

首先,我们分析一下第1点深度监听的问题,我们会在defineReactive的一开始做observer的递归处理,一次性递归到底把所有属性都设为响应式,是不是一定要这么做呢?

function defineReactive(target, key, value) {
  observer(value); // 深度监听
  Object.defineProperty(target, key, {
    // ...
    get() {
      return value;
    },
    // ...
  });
}

我们知道,在Vue3基于Proxy的响应式当中,深度监听是放在get当中的,即什么时候访问什么时候监听,代码如下:

const data = {
  name: "zhangsan",
  age: 20,
  address: {
    province: "zhejiang",
    city: "hangzhou",
  }
}

function observerProxy(data) {
  console.log(`observerProxy ${data}`);
  // ...
  return new Proxy(data, {
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver);
      return observerProxy(result); // 深度监听
    },
    // ...
  });
}

const dataProxy = observerProxy(data);
console.log(dataProxy.address);
console.log(dataProxy.address);
console.log(dataProxy.address);

/* 控制台结果
...
observerProxy [object Object]
Proxy {province: 'zhejiang', city: 'hangzhou'}
observerProxy [object Object]
Proxy {province: 'zhejiang', city: 'hangzhou'}
observerProxy [object Object]
Proxy {province: 'zhejiang', city: 'hangzhou'}
*/

那Vue2当中能不能也把深度递归放到get当中呢?这样不就可以不用一次性递归到底了吗?我们看看代码

function defineReactive(target, key, value) {
  console.log(`defineReactive ${key}`);
  Object.defineProperty(target, key, {
    // ...
    get() {
      observer(value); // 深度监听
      return value;
    },
    // ...
  });
}

observer(data);
console.log(data.address); // 连续访问三次
console.log(data.address);
console.log(data.address);

/* 控制台结果
...
defineReactive province
defineReactive city
{}
defineReactive province
defineReactive city
{}
defineReactive province
defineReactive city
{}
*/

显然,如果把observer(value)放在get中,那么每次访问该属性都要重新进行深度监听,假如一个对象有100个属性,那么每次访问这个对象就要进行100次监听,这样性能就会更差,还不如一开始就全部递归监听完算了,性能还好一些。

Vue3中可以在get中深度监听是因为Proxy无需对一个对象的每一个属性单独设置,只需要代理对象整体就行了,时间复杂度只有O(1),而Object.defineProperty需要对每个属性单独设置,时间负载度为O(n),性能差一个数量级,所以一次性递归监听是最优解。

为什么不支持监听数组?

对于数组,defineProperty是支持get和set的,因为数组本身也是对象,但Vue2中没有对数组做监听,统一用包装数组变更方法的形式来做响应式,这其实也是出于性能考虑。

比如在下面的代码中,我们在data里添加了一个hobbies数组,监听后在数组的最前面插入了一个baseball,看看控制台会打印什么?

const data = {
  name: "zhangsan",
  age: 20,
  hobbies: ["swimming", "game", "movie", "music"],
};

function defineReactive(target, key, value) {
  observer(value); // 深度监听
  Object.defineProperty(target, key, {
    // ...
    set(newValue) {
      if (newValue !== value) {
        observer(newValue); // 更新数据时也要深度监听
        value = newValue;
        udpateView();
      }
    },
  });
}

function observer(target) {
  console.log(`observer ${target}`);
  // ...
  for (const key in target) {
    defineReactive(target, key, target[key]);
  }
}

observer(data);
data.hobbies.unshift("baseball");

/* 控制台结果
observer movie
视图更新
observer game
视图更新
observer swimming
视图更新
observer baseball
视图更新
*/

我们发现,当我们在数组的最前面插入一个值之后,触发了4次视图更新observer,为什么会这样呢?仔细想想就知道,这是因为监听时我们将数组的0,1,2,3四个key分别进行了监听,所以当数组最前面插入了一个值以后,原来的值对应的索引全都发生了变化,或者说每个索引对应的值全都发生了变化,原来0对应swimming,现在0对应baseball,四个索引值都发生了变化触发了四次set,于是就触发了四次视图更新observer操作。

由于Vue采用异步更新策略,四次视图更新会被去重合并,影响不大。但是observer操作是同步的,当初始数组的数据量很大的时候,轻微的变动就会导致所有索引的重新set,如果数组成员是个对象,还会深度递归,这会带来巨大的性能问题。 所以Vue2觉得性能消耗和收益不成正比,于是就放弃了对数组成员进行响应式监听,我们只能通过后面要讲的方法调用的形式实现响应式。

所以,我们可以完善一下前面的observer函数,把数组排除在监听范围之外:

// 监听对象属性
function observer(target) {
  console.log(`observer ${target}`);
  if (typeof target !== "object" || typeof target === null || Array.isArray(target)) {
    return target;
  }

  for (const key in target) {
    defineReactive(target, key, target[key]);
  }
}

说到底,一切的一切还是因为Object.defineProperty是对每个key单独设置的,单独设置就要遍历,就要递归,性能就比较差。Vue3中使用Proxy对整体进行代理完美解决了这个问题。

基于方法调用的响应式

新增和删除属性:Vue.set,Vue.delete

上文说道,Object.defineProperty无法监听到属性的增加和删除,所以Vue提供了两个方法:Vue.set()和Vue.delete()来弥补这一点。

function Vue() {}
Vue.set = function (target, key, value) {
  if (typeof target !== 'object' || typeof target === null) {
    console.warn(
      `Cannot set reactive property on undefined, null, or primitive value: ${target}`
    );
    return;
  }

  // 处理数组
  if (Array.isArray(target)) {
    target.splice(key, 1, value);
    return value;
  }
  
  // 处理普通对象
  defineReactive(target, key, value); // 把新添加的属性做响应式处理
  udpateView();
  return value;
};

Vue.delete = function (target, key) {
  if (typeof target !== 'object' || typeof target === null) {
    console.warn(
      `Cannot delete reactive property on undefined, null, or primitive value: ${target}`
    );
    return;
  }
  
  // 处理数组
  if (Array.isArray(target)) {
    target.splice(key, 1);
    return;
  }
  
  // 处理普通对象
  delete target[key];
  udpateView();
};

Vue.set(data, 'gender', 'male');
data.gender = 'female';
Vue.delete(data, 'gender');
console.log(data.gender);

对于数组对象,代码中使用target.splice()来处理,这个splice是封装过的,可以触发响应式,Vue对数组的常用变更方法都进行了封装以实现响应式,下面就来看这个问题。

包装数组变更方法

对于数组,Vue对数组常用的变更方法进行了封装,在其中添加了响应式的逻辑,涉及的方法如下:

  • push,pop
  • shift,unshift
  • splice
  • sort,reverse

从思路上,我们需要覆盖Array.prototype上面的原始方法,但不能直接修改Array.prototype,毕竟不能影响正常数组的使用。所以,关键的逻辑在于新建一个Array对象作为数组原型,修改该对象的方法并将响应式数组__proto__设为该对象,改变数组的原型指向,从原生的Array.prototype改为自定义原型。

// 重新定义数组原型
const arrProto = Object.create(Array.prototype); // 以Array.prototype为原型创建对象
const methodList = ['push','pop','shift','unshift','splice','sort','reverse'];
methodList.forEach((method) => {
  arrProto[method] = function () {
    udpateView();
    Array.prototype[method].call(this, ...arguments);
  };
});

function observer(target) {
  if (typeof target !== 'object' || typeof target === null) {
    return target;
  }

  // 替换数组原型对象并且不监听数组
  if (Array.isArray(target)) {
    target.__proto__ = arrProto;
  } else {
    for (const key in target) {
      defineReactive(target, key, target[key]);
    }
  }
}

总结

要实现响应式更新,本质上还是需要一个触发点,在JS中触发点就是函数调用。

上述两种响应式其实都是通过定义函数来实现触发,要么是通过setter这样的钩子函数自动触发,要么就是像Vue.set这样主动调用,万变不离其宗。