Vue.js 源码(2)—— Object 的变化侦测

702 阅读4分钟

这是我参与更文挑战的第2天,活动详情查看: 更文挑战

前言

Vue.js 最独特的特性之一是看起来并不显眼的响应式系统。

从状态生成 DOM,再输出到用户界面显示的一整套流程叫做渲染,应用在进行时会不断地进行重新渲染。而响应式系统赋予框架重新渲染的能力,其重要组成部分就是 变化侦测。没有它,就没有重新渲染。框架在运行时,视图也无法随着状态的变化而变化。

简单来说,变化侦测的作用是侦测数据的变化。当数据变化时,会通知视图进行相应的更新。

Vue.js 中 Object 和 Array 的变化侦测采用不同的处理方式。本篇,将详细介绍 Object 的变化侦测。

什么是变化侦测

通常,在运行时应用内部的状态会不断发生变化,此时需要不停地重新渲染。这是如何确定状态中发生了什么变化?

变化侦测就是用来解决这个问题的,它分为两种方式,一种是 “推”模型,一种是 “拉”模型。提到这两个词,想必一些同学就会感觉很熟悉。没错,就是观察者模式

Angular 和 React 中的变化侦测都属于“拉”。 当状态发生变化时,框架并不知道具体是哪个状态变化了,所以会进行一个暴力对比来找出哪些 DOM 节点需要重新渲染。在 Angular 中是脏检查,在 React 中是虚拟 DOM。

Vue.js 的变化侦测属于“推”。 当状态发生变化时, Vue.js 能知道是哪些状态发生变化了,因此它可以进行更细粒度的更新。但是它也有一定的代价,因为粒度越细,每个状态所绑定的依赖就越多,依赖追踪在内存上的开销就会越大。因此,从 Vue.js 2.0 开始,引入了虚拟 DOM,将粒度调整为中等粒度,即一个状态所绑定的依赖不再是具体的 DOM 节点,而是一个组件。

如何追踪变化、收集依赖

有两种方法可以侦测到变化,Object.definePropertyProxy

function defineReative(data, key,val){
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function(){
            return val;
        },
        set: function (newVal) {
            if(val === newVal) return;
            val = newVal;
        }
    })
}

封装好之后,每当从 data 的 key 中读取数据时,get 函数被触发,每当往 data 的 key 中设置数据时,set 函数被触发。

所以,我们可以在 getter 中收集依赖,在 setter 中触发依赖

依赖收集在哪里

我们已经明确了目标,要在 getter 中收集依赖,那么应该把依赖收集到哪里呢?

我们先假设依赖是个函数,保存在全局变量上,如 window.target 上。我们把之前的 defineReactive 改造以下:

function defineReative(data, key, val) {
  let dep = []; // 新增
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      dep.push(window.target); // 新增
      return val;
    },
    set: function (newVal) {
      if (val === newVal) return;
      val = newVal;
      // 新增
      dep.forEach(function (w) {
        w(newVal, val);
      });
    },
  });
}

我们新增了数组 dep,用来存储收集的依赖。当 set 函数被触发时,会依次触发收集的依赖。接下来,我们把依赖收集的相关代码封装成一个 Dep 类,用来收集依赖、删除依赖和向依赖发送通知等。

export default class Dep {
  constructor() {
    this.subs = [];
  }

  addSub(sub) {
    if (sub && sub.update) {
      this.subs.push(sub);
    }
  }

  removeSub(sub) {
    remove(this.subs, sub);
  }

  depend() {
    if (window.target) {
      this.addSub(window.target);
    }
  }

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

function remove(arr, item) {
  if (arr.length) {
    const index = arr.indexOf(item);
    if (index > -1) {
      return arr.splice(index, 1);
    }
  }
}

然后,我们再改造一下 defineReactive:

function defineReative(data, key, 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 实例中。

依赖是谁

当状态发生变化时,需要通知谁,谁就是依赖。在上面的代码中,window.target 就是依赖。

由于依赖会一直观察着状态的变化,所以我们就把依赖叫做 Watcher

Watcher 是一个中介的角色,数据发生变化时通知它,然后它再通知其他地方。

我们先看一个 Watcher 的经典使用方式:

vm.$watch('a.b.c', function(newVal, oldVal){
    // ... 
}

这段代码表示当 data.a.b.c 发生变化时,就触发后面的函数。

那么,思考一下它是如何实现的?我们新建一个 Watcher 类来模拟一下:

export default class Watcher {
  constructor(vm, expOrFn, cb) {
    this.vm = vm;
    // 执行 this.getter(),就可以读取 data.a.b.c 的数据
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
    }
    this.cb = cb;
    this.value = this.get();
  }

  get() {
    window.target = this;
    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);
  }
}

先把 window.target 设置成 this,也就是 watcher 实例,这时候读取 data.a.b.c 的值时,会触发 getter 进行依赖收集(回顾一下 defineReactive),把当前的 watcher 实例主动添加到 data.a.b.c 的 Dep 实例中。

每当 data.a.b.c 的值发生变化,触发 setter,对应的 Dep 调用 notify 方法,通知收集所有依赖,依次触发 update 方法。

我们再看一下 parsePath 是如何实现的:

export const unicodeRegExp = /a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/
const bailRE = new RegExp(`[^${unicodeRegExp.source}.$_\\d]`)
export function parsePath (path: string): any {
  if (bailRE.test(path)) {
    return
  }
  const segments = path.split('.')
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}

将 path 用 . 分割成数组,然后循环获取。

递归侦测所有的 key

上面我们已经实现侦测单个key,下面我们要开始侦测所有的 key 了。我们新建一个 Observer 类, 把一个对象的所有属性都转换成 getter/setter:

export default class Observer {
  constructor(value) {
    this.value = value;
    if(typeof value === 'object'){
        this.walk(value); // 把对象的所有属性都转成 getter/setter
    }
  }
  
  walk(obj) {
    Object.keys(obj).forEach((key) => {
      defineReactive(obj, key, obj[key]);
    });
  }
}

// 侦测单个 key 的变化
function defineReative(data, key, val) {
  let dep = new Dep(); 
  
  new Observer(val); // 新增, 如果子属性是对象,递归遍历子属性,同样转成 getter/setter
  
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      dep.depend(); 
      return val;
    },
    set: function (newVal) {
      if (val === newVal) return;
      val = newVal;
      new Observer(newVal) // 新修改的值如果也是对象,同样转成 getter/setter
      dep.notify();
    },
  });
}

上述代码,我们定义了 Observer 类,用来将对象转换成可被侦测的 object。只有对象才会调用 walk 方法来遍历转换。

我们在 defineReactive 函数中新增了一行 new Observer(val),如果对象的子属性也是对象,递归遍历转换成 getter/setter。我们还在 setter 中 新增了一行 new Observer(newVal),当新设置的值是个对象时,也会把这个对象转成响应式的 object。

Object 的问题

我们观察下 Observer 的实现会发现,只有修改属性的操作会触发 setter 通知依赖更新,新增属性不会触发 setter 函数,因为新增的属性一开始不存在,也就没有 getter/setter。

Vue 无法检测 property 的添加或移除。由于 Vue 会在初始化实例时对 property 执行 getter/setter 转化,所以 property 必须在 data 对象上存在才能让 Vue 将它转换为响应式的。 ——Vue.js 官网

好在,官方给我们提供了 vm.$setvm.$delete,可以实现追踪新增和删除属性。这两个 API,我们在后续讲完 Array 的侦测变化后再讲。

总结

Vue.js 是如何追踪 Object 的变化的?

  • Vue.js 会把对象作为参数,传递给 Observer 的构造函数,遍历所有属性,调用 defineReactive 把每个属性都转换成 getter/setter
  • 如果子属性也是对象,继续递归;
  • 在 defineReactive 中,新建一个 dep 用来收集依赖,当某处访问到某个状态时,会触发 getter 收集依赖,之后状态修改时会触发 setter,对应的 dep 会通知收集的所有依赖 update;
  • 另外,触发 setter 时,如果新的值是个对象,同样会使用 Observer 转换成响应式对象