稍微学一下 Vue 的数据响应式(Vue2 及 Vue3)

什么是数据响应式

从一开始使用 Vue 时,对于之前的 jq 开发而言,一个很大的区别就是基本不用手动操作 dom,data 中声明的数据状态改变后会自动重新渲染相关的 dom。
换句话说就是 Vue 自己知道哪个数据状态发生了变化及哪里有用到这个数据需要随之修改。

因此实现数据响应式有两个重点问题:

  1. 如何知道数据发生了变化?
  2. 如何知道数据变化后哪里需要修改?

对于第一个问题,如何知道数据发生了变化,Vue3 之前使用了 ES5 的一个 API Object.defineProperty Vue3 中使用了 ES6 的 Proxy,都是对需要侦测的数据进行 变化侦测 ,添加 getter 和 setter ,这样就可以知道数据何时被读取和修改。

第二个问题,如何知道数据变化后哪里需要修改,Vue 对于每个数据都收集了与之相关的 依赖 ,这里的依赖其实就是一个对象,保存有该数据的旧值及数据变化后需要执行的函数。每个响应式的数据变化时会遍历通知其对应的每个依赖,依赖收到通知后会判断一下新旧值有没有发生变化,如果变化则执行回调函数响应数据变化(比如修改 dom)。

下面详细分别介绍 Vue2 及 Vue3 的数据变化侦测及依赖收集。

Vue2

变化侦测

Object 的变化侦测

转化响应式数据需要将 Vue 实例上 data 属性中定义的数据通过递归将所有属性都转化为 getter/setter 的形式,Vue 中定义了一个 Observer 类来做这个事情。

function def(obj, key, val, enumerable) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}

class Observer {
  constructor(value) {
    this.value = value;
    def(value, '__ob__', this);
    if (!Array.isArray(value)) {
      this.walk(value);
    }
  }
  walk(obj) {
    for (const [key, value] of Object.entries(obj)) {
      defineReactive(obj, key, value);
    }
  }
}

直接将一个对象传入 new Observer() 后就对每项属性都调用 defineReactive 函数添加变化侦测,下面定义这个函数:

function defineReactive(data, key, val) {
  let childOb = observe(val);
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      // 读取 data[key] 时触发
      console.log('getter', val);
      return val;
    },
    set: function (newVal) {
      // 修改 data[key] 时触发
      console.log('setter', newVal);
      if (val === newVal) {
        return;
      }
      val = newVal;
    }
  })
}

function observe(value, asRootData) {
  if (typeof val !== 'object') {
    return;
  }
  let ob;
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__;
  } else {
    ob = new Observer(val);
  }
  return ob;
}

函数中判断如果是对象则递归调用 Observer 来实现所有属性的变化侦测,根据 __ob__ 属性判断是否已处理过,防止多次重复处理,Observer 处理过后会给数据添加这个属性,下面写一个对象试一下:

const people = {
  name: 'c',
  age: 12,
  parents: {
    dad: 'a',
    mom: 'b'
  },
  mates: ['d', 'e']
};
new Observer(people);
people.name; // getter c
people.age++; // getter 12  setter 13
people.parents.dad; // getter {}  getter a

打印 people 可以看到所有属性添加了 getter/setter 方法,读取 name 属性时打印了 people.age++ 修改 age 时打印了 getter 12 setter 13 说明 people 的属性已经被全部成功代理监听。

Array 的变化侦测

可以看到前面 Observer 中仅对 Object 类型个数据做了处理,为每个属性添加了 getter/setter,处理后如果属性值中有数组,通过 属性名 + 索引 的方式(如:this.people.mates[0])获取也是会触发 getter 的。但是如果通过数组原型方法修改数组的值,如 this.people.mates.push('f'),这样是无法通过 setter 侦测到的,因此,在 Observer 中需要对 Object 和 Array 分别进行单独的处理。

为侦测到数组原型方法的操作,Vue 中是通过创建一个拦截器 arrayMethods,并将拦截器重新挂载到数组的原型对象上。

下面是拦截器的定义:

const ArrayProto = Array.prototype;
const arrayMethods = Object.create(ArrayProto);
;[
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
].forEach(method => {
  const original = ArrayProto[method];
  Object.defineProperty(arrayMethods, method, {
    value: function mutator(...args) {
      console.log('mutator:', this, args);
      return original.apply(this, args);
    },
    enumerable: false,
    writable: true,
    configurable: true
  })
})

这里 arrayMethods 继承了 Array 的原型对象 Array.prototype,并给它添加了 push pop shift unshift splice sort reverse 这些方法,因为数组是可以通过这些方法进行修改的。添加的 push pop... 方法中重新调用 original(缓存的数组原型方法),这样就不会影响数组本身的操作。

最后给 Observer 中添加数组的修改:直接将拦截器挂载到数组原型对象上

class Observer {
  constructor(value) {
    this.value = value;
    def(value, '__ob__', this);
    if (Array.isArray(value)) {
      value.__proto__ = arrayMethods;
    } else {
      this.walk(value);
    }
  }
  walk(obj) {
    for (const [key, value] of Object.entries(obj)) {
      defineReactive(obj, key, value);
    }
  }
}

再来验证一下:

const people = {
  name: 'c',
  age: 12,
  parents: {
    dad: 'a',
    mom: 'b'
  },
  mates: ['d', 'e']
};
new Observer(people);
people.mates[0]; // getter (2) ["d", "e"]
people.mates.push('f'); // mutator: (2) ["d", "e"] ["f"]

现在数组的修改也能被侦测到了。

依赖收集

目前已经可以对 ObjectArray 数据的变化进行截获,那么开始考虑一开始提到的 Vue 响应式数据的第二个问题:如何知道数据变化后哪里需要修改?

最开始已经说过,Vue 中每个数据都需要收集与之相关的依赖,用来表示该数据变化时需要进行的操作行为。

通过数据的变化侦测我们可以知道数据何时被读取或修改,因此可以在数据读取时收集依赖,修改时通知依赖更新,这样就可以实现数据响应式了。

依赖收集在哪

为每个数据都创建一个收集依赖的对象 dep,对外暴露 depend(收集依赖)、notify(通知依赖更新)的两个方法,内部维护了一个数组用来保存该数据的每项依赖。

对于 Object,可以在 getter 中收集,setter 中通知更新,对 defineReactive 函数修改如下:

function defineReactive(data, key, val) {
  let childOb = observe(val);
  // 处理每个响应式数据时都创建一个对象用来收集依赖
  let dep = new Dep();
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      // 收集依赖
      dep.depend();
      return val;
    },
    set: function (newVal) {
      if (val === newVal) {
        return;
      }
      val = newVal;
      // 通知依赖更新
      dep.notify();
    }
  })
}

上面代码中依赖是收集在一个 Dep 实例对象上的,下面看一下 Dep 这个类。

class Dep {
  constructor() {
    this.subs = [];
  }
  addSub(sub) {
    this.subs.push(sub);
  }
  removeSub(sub) {
    if (this.subs.length) {
      const index = this.subs.indexOf(sub);
      this.subs.splice(index, 1);
    }
  }
  depend() {
    if (window.target) {
      this.addSub(window.target);
    }
  }
  notify() {
    const subs = this.subs.slice();
    for (let i = 0; i < subs.length; i++) {
      subs[i].update();
    }
  }
}

Dep 的每个实例都有一个保存依赖的数组 subs,收集依赖时是从全局的一个变量上获取到并插入 subs,通知依赖时就遍历所有 subs 成员并调用其 update 方法。

Object 的依赖收集和触发都是在 defineProperty 中进行的,因此 Dep 实例定义在 defineReactive 函数中就可以让 getter 和 setter 都拿到。

而对于 Array 来说,依赖可以在 getter 中收集,但触发却是在拦截器中,为了保证 getter 和 拦截器中都能访问到 Dep 实例,Vue 中给 Observer 实例上添加了 dep 属性。

class Observer {
  constructor(value) {
    this.value = value;
    this.dep = new Dep();
    def(value, '__ob__', this);
    if (Array.isArray(value)) {
      value.__proto__ = arrayMethods;
    } else {
      this.walk(value);
    }
  }
  walk(obj) {
    for (const [key, value] of Object.entries(obj)) {
      defineReactive(obj, key, value);
    }
  }
}

Observer 在处理数据响应式时也将自身实例添加到了数据的 __ob__ 属性上,因此在 getter 和拦截器中都能通过响应式数据本身的 __ob__.dep 拿到其对应的依赖。修改 defineReactive 和 拦截器如下:

function defineReactive(data, key, val) {
  let childOb = observe(val);
  let dep = new Dep();
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      dep.depend();
      // 给 Observer 实例上的 dep 属性收集依赖
      if (childOb) {
        childOb.dep.depend();
      }
      return val;
    },
    ...
  })
}

;[
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
].forEach(method => {
  const original = ArrayProto[method];
  def(arrayMethods, method, (...args) => {
    const result = original.apply(this, args);
    const ob = this.__ob__;
    ob.dep.notify();
    return result;
  })
})

依赖长什么样

现在已经知道了依赖保存在每个响应式数据对应的 Dep 实例中的 subs 中,通过上面 Dep 的代码可以知道,收集的依赖是一个全局对象,且该对象对外暴露了一个 update 方法,记录了数据变化时需要进行的更新操作(如修改 dom 或 Vue 的 Watch)。

首先这个依赖对象的功能主要有两点:

  1. 需要主动将自己收集到对应响应式数据的 Dep 实例中;
  2. 保存数据变化时要进行的操作并在 update 方法中调用;

其实就是一个中介角色,Vue 中起名为 Watcher。

class Watcher {
  constructor(vm, expOrFn, cb) {
    this.vm = vm;
    // 保存通过表达式获取数据的方法
    this.getter = parsePath(expOrFn);
    this.cb = cb;
    this.value = this.get();
  }
  get() {
    // 将自身 Watcher 实例挂到全局对象上
    window.target = this;
    // 获取表达式对应的数据
    // 会自动触发该数据的 getter
    // getter 中收集依赖时从全局对象上拿到这个 Watcher 实例
    let value = this.getter.call(this.vm, this.vm);
    window.target = undefined;
    return value;
  }
  update() {
    const oldValue = this.value;
    this.value = this.get();
    // 将旧值与新值传递给回调函数
    this.cb.call(this.vm, this.value, oldValue);
  }
}

对于第一点,主动将自己收集到 Dep 实例中,Watcher 中设计的非常巧妙,在 get 中将自身 Watcher 实例挂到全局对象上,然后通过获取数据触发 getter 来实现依赖收集。

第二点实现很简单,只需要将构造函数参数中的回调函数保存并在 update 方法中调用即可。

构造函数中的 parsePath 方法就是从 Vue 实例的 data 上通过表达式获取数据,比如表达式为 "user.name" 则需要解析该字符串然后获取 data.user.name 数据。

总结

  • 数据先通过调用 new Observer() 为每项属性添加变化侦测,并创建一个 Dep 实例用来保存相关依赖。在读取属性值时保存依赖,修改属性值时通知依赖;
  • Dep 实例的 subs 属性为一个数组,保存依赖是向数组中添加,通知依赖时遍历数组一次调用依赖的 update 方法;
  • 依赖是一个 Watcher 实例,保存了数据变化时需要进行的操作,并将实例自身放到全局的一个位置,然后读取数据触发数据的 getter,getter 中从全局指定的位置获取到该 Watcher 实例并收集在 Dep 实例中。

以上就是 Vue2 中的响应式原理,在 Observer 处理完后,外界只需要通过创建 Watcher 传入需要监听的数据及数据变化时的响应回调函数即可。

Vue3

Vue3 中每个功能单独为一个模块,并可以单独打包使用,本文仅简单讨论 Vue3 中与数据响应式相关的 Reactive 模块,了解其内部原理,与 Vue2 相比又有何不同。

因为该模块可以单独使用,先来看一下这个模块的用法示例:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>vue3 demo</title>
</head>
<body>
  <div id="app">
    <div id="count"></div>
    <button id="btn">+1</button>
  </div>
  <script src="./vue3.js"></script>
  <script>
    const countEl = document.querySelector('#count')
    const btnEl = document.querySelector('#btn')
    // 定义响应式数据
    const state = reactive({
      count: 0,
      man: {
        name: 'pan'
      }
    })
    // 定义计算属性
    let double = computed(() => {
      return state.count * 2
    })
    // 回调函数立即执行一次,内部使用到的数据更新时会重新执行回调函数
    effect(() => {
      countEl.innerHTML = `count is ${state.count}, double is ${double.value}, man's name is ${state.man.name}`
    })
    // 修改响应式数据触发更新
    btnEl.addEventListener('click', () => {
      state.count++
    }, false)
  </script>
</body>
</html>

通过示例可以看到实现 Vue3 这个数据响应式需要有 reactive、computed、effect 这几个函数,下面仍然通过从变化侦测及依赖收集两个方面介绍,简单实现这几个函数。

变化侦测

示例中的 reactive 函数是对数据进行响应式化的,因此该函数的功能就类似于 Vue2 中的 defineReactive 函数的 getter/setter 处理,处理后能够对数据的获取及修改操作进行捕获。

const toProxy = new WeakMap()
const toRaw = new WeakMap()

const baseHandler = {
  get(target, key) {
    console.log('Get', target, key)
    const res = Reflect.get(target, key)
    // 递归寻找
    return typeof res == 'object' ? reactive(res) : res
  },
  set(target, key, val) {
    console.log('Set', target, key, val)
    const res = Reflect.set(target, key, val)
    return res
  }
}
function reactive(target) {
  console.log('reactive', target)
  // 查询缓存
  let observed = toProxy.get(target)
  if (observed) {
    return observed
  }
  if (toRaw.get(target)) {
    return target
  }
  observed = new Proxy(target, baseHandler)
  // 设置缓存
  toProxy.set(target, observed)
  toRaw.set(observed, target)
  return observed
}

reactive 中使用 Proxy 对目标进行代理,代理的行为是 baseHander ,然后对目标对象及代理后的对象进行缓存,防止多次代理。

baseHandler 中就是对数据的获取及修改进行拦截,并通过 Reflect 执行 get/set 的原本操作,并在获取值为 Object 时递归进行响应式处理。很简单地就完成了数据的响应式处理。

依赖收集

依赖收集与 Vue2 类似,在 getter 中收集依赖,setter 中触发依赖,修改 baseHandler 如下:

const baseHandler = {
  get(target, key) {
    const res = Reflect.get(target, key)
    // 收集依赖
    track(target, key)
    return typeof res == 'object' ? reactive(res) : res
  },
  set(target, key, val) {
    const info = {
      oldValue: target[key],
      newValue: val
    }
    const res = Reflect.set(target, key, val)
    // 触发更新
    trigger(target, key, info)
    return res
  }
}

track 函数收集依赖,trigger 函数触发依赖更新。

首先需要两个全局变量,用于保存当前待收集的依赖对象的 effectStack 及一个记录所有数据及其对应依赖的表 targetMap 。

const effectStack = []
const targetMap = new WeakMap()

接下来定义这收集依赖及触发依赖更新这两个函数:

function track(target, key) {
  // 从栈中拿到待收集的依赖对象
  let effect = effectStack[effectStack.length - 1]
  if (effect) {
    // 通过 target 及 key 从依赖映射表中拿到对应的依赖列表(Set类型)
    // 首次需要对依赖映射表初始化
    let depsMap = targetMap.get(target)
    if (depsMap === undefined) {
      depsMap = new Map()
      targetMap.set(target, depsMap)
    }
    let dep = depsMap.get(key)
    if (dep === undefined) {
      dep = new Set()
      depsMap.set(key, dep)
    }
    // 若 target.key 对应的依赖列表中不存在该依赖则收集
    if (!dep.has(effect)) {
      dep.add(effect)
    }
  }
}
function trigger(target, key, info) {
  // 依赖映射表中取出 target 相关数据
  const depsMap = targetMap.get(target)
  if (depsMap === undefined) {
    return
  }
  // 普通依赖对象的列表
  const effects = new Set()
  // 计算属性依赖对象的列表
  const computedRunners = new Set()
  if (key) {
    // 取出 key 相关的依赖列表遍历分类存入 effects 及 computedRunners
    let deps = depsMap.get(key)
    deps.forEach(effect => {
      if (effect.computed) {
        computedRunners.add(effect)
      } else {
        effects.add(effect)
      }
    })
  }
  // 遍历执行所有依赖对象
  const run = effect=> effect()
  effects.forEach(run)
  computedRunners.forEach(run)
}

track 及 trigger 的大致代码也很简单,track 是拿到待收集的依赖对象 effect 后收集到 effectStack,trigger 是从 effectStack 拿到对应的依赖列表遍历执行。

到现在就差这个依赖对象了,根据上面 trigger 函数可以知道,这个依赖 effect 首先是个函数可以执行,并且还有自身属性,如 computed 表示其为一个计算属性的依赖,有时会根据该标识进行写特殊处理。

下面开始介绍这个依赖对象是如何产生的:

// 创建依赖对象
function createReactiveEffect(fn, options) {
  const effect = function effect(...args) {
    return run(effect, fn, args)
  }
  effect.computed = options.computed
  effect.lazy = options.lazy
  return effect
}

function run(effect, fn, args) {
  if (!effectStack.includes(effect)) {
    try {
      effectStack.push(effect)
      return fn(...args)
    } finally {
      effectStack.pop()
    }
  }
}

createReactiveEffect 是一个高阶函数,内部创建了一个名为 effect 的函数,函数内部返回的是一个 run 函数,run 函数中将依赖 effect 对象存入全局的待收集依赖栈 effectStack 中,并执行传入的回调函数,该回调函数其实就是一开始示例中 effect 函数传入的修改 Dom 的函数。也就是说依赖对象作为函数直接执行就会添加依赖到全局栈并执行回调函数。

回调函数中如果有读取了响应式数据的话则会触发 proxy 的 get 收集依赖,这时就能从 effectStack 上拿到该依赖对象了。

然后给 effect 增加了 computed lazy 属性后返回。

最后就是对外暴露的 effect 及 computed 函数了:

// 创建依赖对象并判断非计算属性则立即执行
function effect(fn, options = {}) {
  let e = createReactiveEffect(fn, options)
  if (!options.lazy) {
    e()
  }
  return e
}

// computed 内部调用 effect 并添加计算属性相关的 options
function computed(fn) {
  const runner = effect(fn, {
    computed: true,
    lazy: true
  })
  return {
    effect: runner,
    get value() {
      return runner()
    }
  }
}

computed 就不多说了,effect 就是将传入的回调函数传给 createReactiveEffect 创建依赖对象,然后执行依赖对象就会执行回调函数并收集该依赖对象。

总结

  • reactive 将传入的数据对象使用 proxy 包装,通过 proxy 的 get set 拦截数据的获取及修改,与 Vue2 的 defineProperty 一样,在 get 中收集依赖,在 set 中触发依赖;
  • effect 函数接受一个回调函数作为参数,将回调函数包装一下作为依赖对象后执行回调函数,回调函数执行时触发相关数据的 get 后进行依赖收集;

到此 Vue2 及 Vue3 中的数据响应式原理都分析完了。

Vue2 及 Vue3 数据响应式的对比

本次 Vue 对于数据响应式的升级主要在变化侦测部分。

Vue2 中的变化侦测实现对 Object 及 Array 分别进行了不同的处理,Objcet 使用了
Object.defineProperty API ,Array 使用了拦截器对 Array 原型上的能够改变数据的方法进行拦截。虽然也实现了数据的变化侦测,但存在很多局限 ,比如对象新增属性无法被侦测,以及通过数组下边修改数组内容,也因此在 Vue2 中经常会使用到 $set 这个方法对数据修改,以保证依赖更新。

Vue3 中使用了 es6 的 Proxy API 对数据代理,没有像 Vue2 中对原数据进行修改,只是加了代理包装,因此首先性能上会有所改善。其次解决了 Vue2 中变化侦测的局限性,可以不使用 $set 新增的对象属性及通过下标修改数组都能被侦测到。


最近一直在看 Vue.js 的原理,数据响应式是 Vue.js 中非常重要的功能,因此抽时间写一篇文章记录一下学习成果,如果哪里有讲的不对的,还望及时指出,一起交流探讨。

阅读 577更新于 11月19日
推荐阅读
前端之旅
用户专栏

前端之旅

9 人关注
6 篇文章
专栏主页
目录