Vue3 的响应式实现

2,131 阅读14分钟

响应式(Reactivity)

概念

响应性(Reactivity)是一种允许我们以声明式的方式去适应变化的编程范例。 通俗来说,就是数据变化了,相应的视图会更新(重新渲染)。

实现思路

  • 当值被访问(touch)时触发跟踪 (track) 函数,收集依赖(collect as dependency)
  • 检测值是否发生变化
  • 当值变化(setter)时用触发 (trigger) 函数通知(notify)该值相关的依赖更新(re-render) image.png

Vue2 的实现

基本原理

Object.defineProperty()

Object.defineProperty() - JavaScript | MDN

ES5 的 Object.defineProperty() 方法支持在一个对象 obj 上定义一个新属性 prop,或者修改一个对象的现有属性 prop,并返回此对象。

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

  • 参数

    • obj:要定义属性的对象。
    • prop:要定义或修改的属性的名称或 Symbol 。
    • descriptor:要定义或修改的属性描述符。对象里目前存在的属性描述符有两种主要形式:数据描述符 和 存取描述符。数据描述符 是一个具有值的属性,该值可以是可写的,也可以是不可写的。存取描述符 是由 getter 函数和 setter 函数所描述的属性。

存取描述符的关键键值

get

属性的 getter 函数,默认为 undefined。当访问该属性时,会调用此函数。执行时不传入任何参数,但是会传入 this 对象(由于继承关系,这里的this并不一定是定义该属性的对象)。该函数的返回值会被用作属性的值。

set

属性的 setter 函数,默认为 undefined。当属性值被修改时,会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的 this 对象。

实现方案

遍历数据 data 的所有属性,通过 Object.defineProperty() 拦截并改写(自定义)数据的属性的 getter & setter 函数,从而在访问对象属性和设置 / 修改对象属性的时候能够执行自定义的回调函数:在 getter 中进行依赖收集操作(track,访问过该属性的节点、组件、函数……都会被收集为依赖 watcher),在 setter 中进行视图更新操作(trigger,通知前面收集到的依赖触发执行 & 视图重新渲染)。

简单实现

// 重写数组的原型方法
let oldArrayPrototype = Array.prototype;
let proto = Object.create(oldArrayPrototype);
['push', 'pop', 'unshift', 'shift', 'sort', 'reverse', 'splice'].forEach(
  (method) => {
    proto[method] = function () {
      updateView();
      oldArrayPrototype[method].call(this, ...arguments);
    };
  }
);
// 监听数据变化
function observer(target) {
  if (typeof target !== 'object' || target == null) {
    // 不是对象,无法更改属性值,直接返回
    return target;
  }

  // 数组,重写原型方法
  if (Array.isArray(target)) {
    target.__proto__ = proto;
  }

  // 循环对象,重新定义属性的 getter & setter
  for (let key in target) {
    defineReactive(target, key, target[key]);
  }
}

// 定义响应式
function defineReactive(obj, key, val) {
  observer(val);
  Object.defineProperty(obj, key, {
    get() {
      // 在这里进行依赖收集(略)
      return val;
    },
    set(newVal) {
      if (newVal !== val) {
        observer(newVal);
        // 在这里进行依赖触发(略)
        updateView();
        val = newVal;
      }
    },
  });
}

function updateView() {
  console.log('视图更新');
}

缺陷

  1. 影响性能(如增加首次渲染时间)、增加内存消耗,尤其是数据层级很深时。
  • 原因:默认会进行递归。
  1. 无法监听数组改变 length & 基于性能考量不支持监听数组索引变化,vue2 在监测数组的变化时需要重写 push, pop, unshift, shift, reverse, sort, splice 这 7 个能改变原数组的原型方法。

Vue响应式原理 - 关于Array的特别处理

为什么defineProperty不能检测到数组长度的“变化”

  • 原因:
    1. 数组的 length 属性具有以下初始化键值:
// 表示对象的属性是否可以被枚举,如能否通过 for-in 循环返回该属性。
enumberable: false

// 表示对象的属性是否可以被删除,以及除 value 和 writable 特性(键值)外的其他特性(如 get、set)是否可以被修改。
configurable: false

// 表示对象的属性值是否可以被改变
writable: true
    • length 属性初始为 non-configurable,无法删除 / 修改 length 属性,无法改写 length 属性的 getter & setter 函数,因此,通过改变 length 而变化的数组长度不能被 Object.defineProperty() 监测到。
    • 而 push, pop, unshift, shift, splice 这几个内置的方法在操作数组时,都会改变原数组 length 的值,而 Object.defineProperty() 不能监测到数组长度的变化,因而不会触发视图更新。
  1. 对于 reverse, sort 方法,没有改变数组 length,改变的是数组的索引。数组的索引是可以被 Object.defineProperty() 监测到的属性,🌰

1.png

但 Vue2 没有支持,官方回复是因为性能问题,在性能和用户体验之间做了取舍。👇

2.png

  1. 对象上新增的属性不能被拦截。
  • 原因:Object.defineProperty() 需要指定对象具体的属性名才能对其 getter 和 setter 进行拦截。
  • 补丁:Vue2 提供了一个 api:this.$set,使新增的属性也拥有响应式的效果。但是需要判断到底什么情况下需要用 $set,什么时候可以直接触发响应式。

Vue3 的实现

基本原理

Proxy

Proxy 是一个包含另一个对象或函数并允许你对其进行拦截的对象。 Proxy - JavaScript | MDN ES6 的 Proxy 对象用于创建一个对象的代理,从而实现对其基本操作的拦截 & 自定义。

  • 语法 const p = new Proxy(target, handler)

  • 参数

    • target:要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。
    • handler:一个通常以函数作为属性的对象,它包含有 Proxy 的各个捕获器(trap),定义了在执行各种操作时代理 p 的行为。所有的捕捉器是可选的。如果没有定义某个捕捉器,那么就会保留源对象的默认行为。Vue3 用到的 traps:
      • handler.get():属性读取操作。
      • handler.set():属性设置操作。
      • handler.deleteProperty():属性 delete 操作。
      • handler.has():属性 in 操作符。
      • handler.ownKeys()Object.getOwnPropertyNames 方法和 Object.getOwnPropertySymbols 方法的捕捉器。

Reflect

Reflect - ECMAScript 6入门 Reflect 对象与 Proxy 对象一样,也是 ES6 为了操作对象而提供的新 API。相比 Object 对象主要有如下特点 / 优势:

  1. 将 Object 对象的一些明显属于语言内部的方法(比如 Object.defineProperty),放到 Reflect 对象上。现阶段,某些方法同时在 Object 和 Reflect 对象上部署,未来的新方法将只部署在 Reflect 对象上。也就是说,从 Reflect 对象上可以拿到语言内部的方法。
  2. 返回结果更合理,不会报错,操作失败会返回 false。比如,Object.defineProperty(obj, name, desc) 在无法定义属性时,会抛出一个错误;而 Reflect.defineProperty(obj, name, desc) 则会返回 false。
  3. 方法都是函数式。Object 存在某些命令式操作,如 name in objdelete obj[name] ,而对应的 Reflect.has(obj, name)Reflect.deleteProperty(obj, name) 都是函数式操作。
  4. Reflect 对象的方法与 Proxy 对象的方法一一对应,只要是 Proxy 对象的方法,就能在 Reflect 对象上找到对应的方法。因此 Proxy 对象可以方便地调用对应的 Reflect 方法,完成默认行为,作为修改行为的基础。

WeakMap

WeakMap - JavaScript | MDN WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。原生的 WeakMap 持有的是每个键对象的“弱引用”,这意味着在没有其他引用存在时垃圾回收能正确进行。原生 WeakMap 的结构是特殊且有效的,其用于映射的 key 只有在其没有被回收时才是有效的。
WeakMap 键名所指向的对象,不计入垃圾回收机制,有助于防止内存泄漏。所以WeakMap 可以实现往对象上添加数据,又不会干扰垃圾回收机制。

实现方案

用 Proxy 代理数据,创建响应式对象,拦截其 getter 和 setter 函数;依赖该数据 / 属性的方法(称为副作用 effect)默认会先执行一次,触发所依赖属性的 get 方法,在 getter 函数中进行依赖收集(track,把当前属性与当前的 effect 建立联系,即映射表);当属性变化时,会触发其 set 方法,在 setter 函数中进行更新(trigger,依次触发映射表中依赖当前属性的 effect)。

关键方法

reactive

把数据变为响应式,遍历 & 自定义对象所有属性的 getter & setter 函数,返回 proxy 对象。

effect

  • effect 方法本质是一个高阶函数(入参或出参是函数),默认会立即执行传入的函数(此时会触发内部函数响应式对象的 get 方法,从而触发依赖收集),在依赖的数据变化时会再执行。
  • 含义:副作用(数据变化会触发相应的回调),相当于 Vue2 中的 watcher。

具体实现

reactive

// 判断是否是对象
function isObject(val) {
  return typeof val === 'object' && val !== null;
}

// 1.响应式的核心方法
function reactive(target) {
  // 创建响应式对象
  return createReactiveObject(target);
}

let toProxy = new WeakMap(); //弱引用映射表,es6;放的是 “原对象:代理后的对象”
// 防止被代理过的对象再次被代理
let toRaw = new WeakMap(); // “代理后的对象:原对象”

// 判断当前对象有无某属性
function hasOwn(target, key) {
  return target.hasOwnProperty(key);
}

// 创建响应式对象
function createReactiveObject(target) {
  if (!isObject(target)) {
    // 不是对象,直接返回
    return target;
  }

  let proxy = toProxy.get(target);
  if (proxy) {
    // 如果 target 已经有相应的代理后的对象,直接返回之前代理过的结果即可
    return proxy;
  }
  if (toRaw.has(target)) {// 判断 target 是否已经是 reactive 对象
    // target 已经是代理后的对象了,则无需再次代理
    return target;
  }

  const baseHandler = {
    // reflect 优点:不会报错 & 会有返回值;以后会替代 Object
    get(target, key, receiver) {
      // target:原对象, key:属性, receiver:当前的代理对象 proxy(target 被代理后的对象)
      console.log('获取');
      let res = Reflect.get(target, key, receiver);
      // res 是当前获取到的值
      return isObject(res) ? reactive(res) : res; // 按需实现递归
    },
    set(target, key, value, receiver) {
      // 识别是 修改属性 or 新增属性
      let hadKey = hasOwn(target, key); //判断这个属性以前有没有
      let oldValue = target[key];
      let res = Reflect.set(target, key, value, receiver);
      if (!hadKey) {
        console.log('新增属性');
        console.log('设置');
      } else if (value !== oldValue) {
        // 屏蔽无意义的修改(即修改前后值相同)
        console.log('修改属性');
        console.log('设置');
      }

      return res;
    },
    deleteProperty(target, key) {
      console.log('删除');
      let res = Reflect.deleteProperty(target, key);
      return res;
    },
  };
  // 创建观察者
  let observer = new Proxy(target, baseHandler); // es6
  toProxy.set(target, observer);
  toRaw.set(observer, target);

  return observer;
}

effect

// reactive 中函数 createReactiveObject 的 baseHandler 修改如下
const baseHandler = {
    get(target, key, receiver) {
      let res = Reflect.get(target, key, receiver);
      // 收集依赖(把属性 & 对应的 effect 建立联系),即 订阅【把当前的 key 与 effect 对应起来】
      track(target, key); // 如果目标上的 key 变化了,重新让数组中的 effect 执行即可
      return isObject(res) ? reactive(res) : res; // 按需实现递归
    },
    set(target, key, value, receiver) {
      let hadKey = hasOwn(target, key);
      let oldValue = target[key];
      let res = Reflect.set(target, key, value, receiver);
      if (!hadKey) {
        trigger(target, 'add', key);
      } else if (value !== oldValue) {
        trigger(target, 'edit', key);
      }

      return res;
    },
    deleteProperty(target, key) {
      console.log('删除');
      let res = Reflect.deleteProperty(target, key);
      return res;
    },
  };
// 2.依赖收集(发布订阅)
// 取值会触发 get,get 触发 track(track 里存映射表,最外层是个 WeakMap);设置值时触发 set,set 触发 trigger,取出 effect 执行,更新视图

// 栈:先进后出
let activeEffectStacks = []; // 保存 reactiveEffect

// 依赖的数据结构应该如下
// {
//   target: {
//     key: [fn, fn, fn,...] // 一个属性可能对应多个副作用(即有多个 effect 都依赖这个属性)【应去重,所以用 Set 数据结构】
//   }
// }

let targetSMap = new WeakMap(); // 集合 和 hash 表

function track(target, key) {
  //若这个 target 中的 key 变化了,就执行栈中的方法
  let effect = activeEffectStacks[activeEffectStacks.length - 1];
  if (effect) {
    // 有对应关系,才创建关联【以下为动态创建依赖关系】
    let depsMap = targetSMap.get(target);
    if (!depsMap) {
      // 首次没有,设置一个并设默认值
      targetSMap.set(target, (depsMap = new Map()));
    }

    // 取对象的 key 对应的副作用数组
    let deps = depsMap.get(key);
    if (!deps) {
      depsMap.set(key, (deps = new Set()));
    }
    if (!deps.has(effect)) {
      deps.add(effect);
    }
  }
}

function trigger(target, type, key) {
  let depsMap = targetSMap.get(target);
  if (depsMap) {
    // 有才需要触发
    let deps = depsMap.get(key);
    if (deps) {
      // 将当前 key 对应的 effect 依次执行
      deps.forEach((effect) => effect());
    }
  }
}

// 响应式——副作用
function effect(fn) {
  // 需要把 fn 这个函数 变成 响应式的函数
  let reactiveEffect = createReactiveEffect(fn);
  // 副作用 默认会先执行一次
  reactiveEffect();
}

function createReactiveEffect(fn) {
  let reactiveEffect = function () {
    // 创建的响应式的 effect
    return run(reactiveEffect, fn); // 2个目的:1、执行 fn;2、把这个 reactiveEffect 存到栈中
  };
  return reactiveEffect;
}

// 运行 fn & 把 effect 存起来
function run(effect, fn) {
  try {
    activeEffectStacks.push(effect);
    fn(); // 和 vue2 一样,利用 js 的单线程
  } finally {
    // 即使前面报错,这里也会执行
    activeEffectStacks.pop();
  }
}

ref

  • ref 中可以用类似 _isRef 字段来判断是否为 ref 类型
  • reactive 中 get 函数需要判断 res 是否为 ref 对象,若是则直接返回 value
// 如果传入 ref 的是一个对象,将调用 reactive 方法进行深层响应转换。
const convert = (raw) => (isObject(raw) ? reactive(raw) : raw); 

function ref(raw) {
  raw = convert(raw);
  const v = {
    _isRef: true,
    get value() {
      track(v, '');
      return raw;
    },
    set value(newValue) {
      raw = convert(newValue);
      trigger(v, '');
    },
  };
  return v;
}

computed

  • 返回一个 ref 对象
  • 原始值 value 应该存放在闭包内,使用 dirty 字段决定是否被缓存
  • 依赖触发 trigger 时,不会立即执行 effect,而是执行 effect options 中的 scheduler

function effect(fn, options = {}) {
  const effect = createReactiveEffect(fn, options);
  if (!options.lazy) {
    effect();
  }
  return effect;
}

function createReactiveEffect(fn, options) {
  const effect = function () {
    return run(effect, fn);
  };
  effect.scheduler = options.scheduler;
  return effect;
}

function computed(getterOrOptions) {
  const getter = isFunction(getterOrOptions)
    ? getterOrOptions
    : getterOrOptions.get;
  const setter = isFunction(getterOrOptions) ? () => {} : getterOrOptions.set;
  let value;
  let dirty = true;
  let v;
  const runner = effect(getter, {
    lazy: true,
    scheduler: () => {
      dirty = true;
      trigger(v, '');
    },
  });
  v = {
    _isRef: true,
    get value() {
      if (dirty) {
        value = runner();
        dirty = false;
      }
      track(v, '');
      return value;
    },
    set value(newValue) {
      setter(newValue);
    },
  };
  return v;
}

应用

API特性适用场景
reactive- 接收一个普通对象然后返回该普通对象的响应式代理。
- 响应式转换是“深层的”:会影响对象内部所有嵌套的属性。返回的代理对象 不等于 原始对象。建议仅使用代理对象而避免依赖原始对象。
只能用于代理非基本数据类型 object。
toRefs可以将一个响应型对象(reactive object) 转化为普通对象(plain object),同时又把该对象中的每一个属性转化成对应的响应式属性(ref)。保留被解构的响应式对象(reactive object)的响应式特性(reactivity)【响应式对象被解构后会丢失响应性】,e.g. ...toRefs(data)
ref- 接受一个参数值并返回一个响应式且可改变的 ref 对象。ref 对象拥有一个指向内部值的单一 property.value。
- 如果传入 ref 的是一个对象,将调用 reactive 方法进行深层响应转换。
- 使用 ref api 时,数据变成了对象,值就是 value 属性的值,如果数据本身就是对象,依然会多一层 value 结构,而 reactive 没有这些副作用。
- 一般用于给 js 基本数据类型添加响应性(也支持非基本类型的 object)
- 基本数据类型共 7 个,只能使用 ref:String,Number,BigInt,Boolean,Symbol,Null,Undefined
watch- 监听特定的 data 源,并在单独的回调函数中定义副作用。默认情况下,它也是惰性的——即,回调仅在监听源发生更改时调用。
- options:
-- immediate:表示是否在第一次渲染的时候执行这个函数。
-- deep:如果我们监听一个对象,是否要看这个对象里面属性的变化。
- 监听:如果某一 / 多个属性变化,就去执行回调函数。
- 惰性地执行副作用。
- 更具体地说明应触发侦听器重新运行的状态;在数据变化的回调中执行异步操作或者开销很大的时候使用。
- 访问侦听状态的先前值和当前值。
computed- 使用 getter 函数,并为从 getter 返回的值返回一个不变的响应式 ref 对象,不能直接对 computed 返回值的 value 属性赋值。
- 也可以使用具有 get 和 set 函数的对象来创建可写的 ref 对象。
- 计算属性,用于需要监听一 / 多个值并且生成一个新的属性时。
- 会根据依赖自动缓存,如果依赖不变,这个值就不会重新计算。

优点

  1. 性能(如首次渲染时间)、内存消耗等方面都优于 Vue2。
  • 原因:Vue3 只有访问到(get)某个属性时才会对其下一层(若有)做响应式,而 Vue1、2 都是在首次渲染就对所有状态遍历(一次性层层递归)拦截做响应式。
  1. 可以监听数组改变 length。
  • 原因:MDN-Proxy中提到:target 是被 Proxy 代理虚拟化的对象。它常被作为代理的存储后端。根据目标验证关于对象不可扩展性或不可配置属性的不变量(保持不变的语义)。Array.length 就是不可配置的属性,故Proxy可以监听原数组中长度的变化。
  1. 对象上原有属性 & 新增属性都可以拦截。
  • 原因:Proxy 拦截的是整个对象 data,监听了 data 上任意属性的访问 & 设置,不需要指定要拦截的属性 key。而 Object.defineProperty() 只能实现对对象的属性进行劫持,所以对于对象上的方法或者新增、删除的属性无能为力。

缺点

兼容性,IE 11 及以下版本不兼容 ES6 的 Proxy

Reference

深入响应性原理 | Vue.js

从零实现Vue3.0响应式源码(正式版)

Vue3 的响应式和以前有什么区别,Proxy 无敌?

  • More Detail

vue3.0 响应式原理(超详细)

深入响应式原理 | Vue.js 技术揭秘

1.1万字从零解读Vue3.0源码响应式系统-前端开发博客