Vue数据绑定原理之数据劫持

3,586 阅读13分钟

首先我们这次的源码分析不仅仅是通过源码分析其实现原理,我们偶尔还会通过Vue项目编写的测试用例了解更多细节。

原理结构

data.png

根据官方的指导图来看,数据(data)在变更的时候会触发setter而引起通知事件(notify),告知Watcher数据已经变了,然后Watcher再出发重新渲染事件(re-render),会调用组件的渲染函数去重新渲染DOM

每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据属性记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。

其实看完官方介绍的我还是一脸懵逼,毕竟我更希望知道它的实现细节,所以我们一步一步的来看,首先是图中的Data(紫色)部分。

数据劫持

Vue使用的是MVVM模式,Model层的改变会更新View-Model层,那么它是如何检测到数据层的改变的呢?

官方指导文档-深入响应式原理中我们其实已经知道Vue是使用Object.defineProperty()实现数据劫持的,并且该属性无法通过其他兼容方法完美的实现,正是因为如此,Vue才不支持IE8以下的浏览器。

好了我们重头开始,查看源码我们可以看见顺着Vue对象的实例化过程,其中有个步骤叫做initState(vm),这个方法中做的一部分事情就是观测组件中声明的data,它调用了initData(vm)

// instance/state.js
function initData (vm: Component) {
  1. 代理data,props,methods到实例上,以便直接用this就可以调用

  2. observe(data, true /* asRootData */)
}

到这里,终于进入正题。

observe()

initData方法中调用了observe方法,并将data作为参数传了进去,根据函数名和参数我们其实可以猜到,这个方法就是用来观测数据变化的。那首先我们从单元测试来看一看observe有啥需要注意的:

// test/unit/modules/observer/observer.spec.js
// it("create on object")
const obj = {
  a: {},
  b: {}
}
// 也可以是以下方法创建的
// const obj = Object.create(null)
// obj.a = {}
// obj.b = {}
const ob1 = observe(obj)
expect(ob1 instanceof Observer).toBe(true)
expect(ob1.value).toBe(obj)
expect(obj.__ob__).toBe(ob1)
// should've walked children
expect(obj.a.__ob__ instanceof Observer).toBe(true)
expect(obj.b.__ob__ instanceof Observer).toBe(true)
// should return existing ob on already observed objects
const ob2 = observe(obj)
expect(ob2).toBe(ob1)
// test/unit/modules/observer/observer.spec.js
// it("create on array")

// on array
const arr = [{}, {}]
const ob1 = observe(arr)
expect(ob1 instanceof Observer).toBe(true)
expect(ob1.value).toBe(arr)
expect(arr.__ob__).toBe(ob1)
// should've walked children
expect(arr[0].__ob__ instanceof Observer).toBe(true)
expect(arr[1].__ob__ instanceof Observer).toBe(true)

我们可以看到,observe方法为obj和其子对象都绑定了一个Observer实例,如果是数组的话,则会遍历数组给数组中的每一个对象也绑定一个Observer实例。实际上就是循环加上递归,给每一个数组或对象(plainObject)都绑定一个Observer实例,并且重复调用observe方法只会得到同一实例,也就是单例模式。

Observer类

上面我们可以看到observe方法是响应化data的一个入口,而它实际上又是通过实例化Observer类实现的,那么Observer实例化的过程中究竟做了哪些事呢。源码中,该类的代码有一段注释:

Observer class that is attached to each observed object. Once attached, the observer converts the target object's property keys into getter/setters that collect dependencies and dispatch updates.

Observer类会被关联在每个被观测的对象上。一旦关联上,这个观测器就会把目标对象上的每个属性都转换为getter/setter,以便用来收集依赖和分发更新事件。

再来看看源码:

export class Observer {
  value: any;
  dep: Dep;

  constructor (value: any) {
    0. 把传入的value绑定给this.value

    1. 新建Dep实例绑定给this.dep
 
    2.this绑定在传入的value的原型属性"__ob__"3. 如果value是数组,遍历数组对每个元素调用 observe(数组第i个元素)

    4. 不是数组,则对对象的每个可枚举的属性调用 defineReactive
  }
}

我总结出了这个方法主要做了这三件事:

1. 将对象标记为依赖 2. 循环观测数组元素 3. 响应化对象的每个可枚举属性

接下来我们重点看看响应化数据这个功能是如何实现的。

defineReactive

话不多说,直接先上源码概括:

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  0. 设置闭包实例 const dep = new Dep()

  1. 如果 property.configurable === false 直接 return

  2. 设置闭包变量 val 的值

  3. let childOb = !shallow && observe(val) 观测该属性

  4. Object.defineProperty(obj, key, {...}) !!!
}

从源码概括中咱们可以看到defineReactive其实主要做了这三件事:

1. 将属性标记为依赖
2. 递归观测属性
3. 数据劫持

而数据劫持这里,使用到的就是我们前面提到的Object.defineProperty!我们来细品:

Object.defineProperty(obj, key, {
  enumerable: true,
  configurable: true,
  get: function reactiveGetter () {
    const value = 调用自带getter或者获取闭包变量val
    if (Dep.target) {
      // 依赖收集
    }
    return value
  },
  set: function reactiveSetter (newVal) {
    调用自带setter.call(obj, newVal)或者设置闭包变量val = newVal
    
    // 重新观测新值
    childOb = !shallow && observe(newVal)
    // 依赖变更通知
    dep.notify()
  }
})

我们把注意力放到重点上,一些小细节代码就没放上来。

首先,这里设置了属性的setget(如果不了解的同学还需要先学习defineProperty)。在set中,会更新闭包变量val的值(如果属性有自带setter则会调用setter),并且它会调用依赖的通知方法,这个方法会告诉依赖的所有观测者并调用每个观测者的update方法(我们稍后再细讲),这也就是官网提到的:

当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染

而在get中,附加功能就只有依赖收集,那为什么把依赖收集放到get中呢。咱们反向思考一下,如果要收集依赖,那么就要调用属性的get也就是获取属性值,哪里会获取到属性值呢,当然是模板里,也就是模板渲染的时候,要把占位符替(比如{{ msg }})换为实际值,这个时候就会进行依赖收集。而模板没有用到的属性,则不会进行依赖收集。官网也有提到:

它会在组件渲染的过程中把“接触”过的数据属性记录为依赖。

DIY

OK,看懂了吗,接下来让我们自己来简单的复现一下以上功能。

首先是observe方法,注意事项是返回Observer实例,并且是单例

function observe(value) {
  let ob

  if (value.hasOwnProperty("__ob__")) {
    ob = value.__ob__
  } else if (Array.isArray(value) || ArrisPlainObject(value)) {
    ob = new Observer(value)
  }

  return ob
}

function isPlainObject(obj) {
  return Object.prototype.toString.call(obj) === "[object Object]"
}

其次是Observer对象,它会标记依赖,绑定观测实例到数据上,会处理数组,响应化所有属性

class Observer {
  constructor(value) {
    this.value = value
    // this.dep = new Dep()
    // 挂载实例到value上
    const proto = Object.getPrototypeOf(value)
    proto.__ob__ = this

    if (Array.isArray(value)) {
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }
  observeArray(value) {
    // 观测数组的每个元素
    value.forEach((item) => {
      observe(item)
    })
  }
  walk(value) {
    // 响应化对象所有可枚举属性
    Object.keys(value).forEach((key) => {
      defineReactive(value, key)
    })
  }
}

最后是defineReactive,它会递归观测属性,标记依赖,响应化传入的属性

function defineReactive(obj, key, val) {
  // 创建闭包依赖标记
  // const dep = new Dep()

  // 闭包存储实际值
  val = obj[key]
  // 递归观测属性
  observe(val)

  Object.defineProperty(obj, key, {
    set(newVal) {
      val = newVal
      observe(newVal)
      // dep.notify()
    },
    get() {
      // 收集依赖
      return val
    },
  })
}

考虑到我们还没有了解Dep,所以相关代码先忽略。并且我们实现的是一个最简版本,没有考虑到过多的边缘情况。

接下来我们试验一下:

var data = {
  a: 0,
  b: {
    c: {
      d: 1,
    },
  },
}
observe(data)

我们再控制台中打印出data,发现我们已经为data,a,b,c绑定好了__ob__。只不过现在它还不能收集依赖以及更新依赖。

依赖标记

我称其为依赖标记,因为它会和被观测的数据进行绑定,也就是说我们把响应式数据看做是一个依赖,而这个依赖标记会去处理和依赖有关的事情,比如记录观测者,分发更新事件等。

Dep类

这个类其实十分简单,功能也很明确。我们先来看看源码概括:

class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor () {
    this.id = uid++
    this.subs = []
  }
  addSub (sub: Watcher) {
    this.subs.push(sub)
  }
  removeSub (sub: Watcher) {
    remove(this.subs, sub)
  }
  notify () {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      // subs aren't sorted in scheduler if not running async
      // we need to sort them now to make sure they fire in correct
      // order
      subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

我们先来看看这明明白白的三个功能:

1. 添加订阅者addSub
2. 删除订阅者removeSub
3. 通知订阅者更新notify

并且我们可以看出依赖里面存储的订阅者是一个Watcher数组。也就是说实际和Watcher交互的是Dep。他还有一个静态属性target该属性指向的也是一个Watcher实例。

让我们我们再回过头来看看Observer中的相关操作。

export function defineReactive () {
  let childOb = !shallow && observe(val);

  Object.defineProperty(obj, key, {
    get: function reactiveGetter() {
      const value = 获取value

      if (Dep.target) {
        dep.depend();
        if (childOb) {
          childOb.dep.depend();
          if (Array.isArray(value)) {
            dependArray(value);
          }
        }
      }
      return value;
    },
    set: function reactiveSetter(newVal) {
      // ...
      childOb = !shallow && observe(newVal);

      dep.notify();
    },
  });
}

先讲解最简单的set,在更新响应式值的时候只调用了该值所属的dep.notify(),它会通知所有订阅者说我这边数据变了,麻烦你更新同步一下。

而在获取(“接触”)该值的时候,调用了值得get,开始了依赖收集。首先如果有收集者,也就是Dep.target,那么该值作为一个依赖被收入,如果该值是一个数组或者对象,那么该值被观测后的Observer也作为一个依赖被收入,并且如果是数组的话,会循环收入每个元素也作为依赖。总结一下:

如果当前有收集者 Dep.target -- 依赖+1
如果当前值是对象或数组 -- 依赖+1
如果当前值是数组 -- 依赖+n

Observer特意将和Watcher相关的代码抽分出来为Dep,目的也是让整个数据响应过程更加松散,可能某天观测数据变更方法不再是Observer的时候,还能继续进行依赖收集和更新通知。

另外Dep还提供了两个静态方法,用来修改Dep.target

const targetStack = []

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

export function popTarget () {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

从这里我们可以看出,Dep.target是全局唯一的,一个时间点只会有一个Dep.target。 那具体哪里会用到它呢?注意到Dep的定义中,target的类型是Watcher,所以我们需要在了解Watcher之后才能知道它会在什么时候被设置上。

DIY

由于Dep的功能主要和Watcher相关,并且其功能很简单,所以在我们掌握Wathcer之后再来实现它。

数据观测

接下来我们来到了最关键的一步,它能将咱们劫持的数据真正的用于视图更新,并在视图更新时同步数据。

Watcher类

之前提了很多Watcher,我们从上文知道,它会在某个时间点成为收集者Dep.target去收集依赖addDep,它会在数据变更时响应通知update。我们先来看看它的构造函数:

class Watcher {
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    // 初始化属性 deps,newDeps,depIds,newDepIds...
    
    // 设置getter
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
    }

    // 获取监听的值
    this.value = this.get()
  }
}

这里有个参数expOrFn有点隐晦,它可以是一个函数或者一个表达式,作为表达式,它通常是a.bmsg这样的值。你可能有点熟悉了,当我们在Vue组件中自定义watch的时候,用的也是类似的表达式。

watch: {
  msg (val, newVal) {}
}

没错,源码注释里有提到,$watch() 和 指令都是用的Watcher。 而expOrFn是用来转换为获取组件中的值的getter。比如expOrFn === 'msg',实际上被转换为了以下内容:

// 简单表示
this.getter = function (obj) {
  return obj.msg
}

不过这里的this.value = this.get()用的却不是直接的getter,这是为什么呢? 我们再来看看get()方法:

class Watcher {
  // ...
  get () {
    pushTarget(this)
    let value
    const vm = this.vm
    value = this.getter.call(vm, vm)
  
    // 递归收集可收集依赖
    if (this.deep) {
      traverse(value)
    }

    popTarget()
    this.cleanupDeps()
    return value
  }
}

这里用到了我们前面提到的Dep的两个静态方法pushTargetpopTarget。我们知道这两个方法是用来设置和取消Dep.target的,而我们在Observer中了解到,在获取属性值的get方法中,会根据Dep.target来搜集依赖。

而在这里的watcher.get方法中,我们可以看到,首先添加了当前Watcher作为Dep.target,然后获取属性的值触发属性的get方法,调用dep.depend()Wathcer收集当前依赖。我们把Observer中属性的get方法中收集依赖折合一下:

Object.defineProperty(obj, key, {
  get: function reactiveGetter () {
    const dep = new Dep()
    // ...
    if (Dep.target) {
      // wathcer.addDep(dep)
      Dep.target.addDep(dep)
    }
    // ...
  }
}

而这里的addDep就是Watcher的收集依赖的方法:

class Watcher {
  constructor () {
    // ...
    this.deps = []
    this.newDeps = []
    this.depIds = new Set()
    this.newDepIds = new Set()
  }
  // ...
  addDep (dep: Dep) {
    const id = dep.id
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        dep.addSub(this)
      }
    }
  }
}

而这个方法主要的作用就是去重然后存储目标依赖depwatcher.newDeps里。然后将watcher存储到dep.subs里,建立一个双向引用。

然后接下来就是递归收集子对象依赖(如果有),然后清除Dep.target引用,最后调用this.cleanupDeps,而这个方法做的事也很简单:

  1. 旧依赖列表有而新依赖列表没有的这些依赖,由于新依赖中已经没有了dep -> watcher的引用,所以对应的也要清除dep <- wathcer引用,这里调用了dep.removeSub(this),就是告诉你和我撇清关系,我的心里已经没有你了。

  2. newDepsnewDepIds赋值给depsdepIds,然后清空newDepsnewDepIds,重新开始生活。

到这里,Watcher的初始化就已经完成了。

小小的总结

当然我们的分析还没结束,只不过我们需要短暂的总结一下,消化之前的概念,才能更深刻的理解接下来的步骤。

数据响应.jpg

上图是我们目前所了解到的一个关系图,黄色的流程表示依赖收集的过程,绿色的表示数据变更的过程。

绑定数据到DOM

目前为止,我们只是了解了如何劫持数据,并且在数据变更时更新它的观测者。比如:

假设我们已经收集完依赖了,也就是每个响应化属性都有一个订阅列表subs,这里面装着和该属性相关的观测者。当属性出现变更时,由于数据被劫持(set),这些观测者都会得到通知,调用各自的update,去执行自己的回调函数。

但是,什么时候才会收集依赖呢?我们写的{{msg}}模板表达式怎么和data.msg关联起来的呢?Watcher是什么时候实例化的?

我们下一篇继续分析。

原文链接:我的博客