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

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

首先我们这次的源码分析不仅仅是通过源码分析其实现原理,我们还会通过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是什么时候实例化的?

我们下一篇继续分析。

上一篇 手写源码
下一篇 Vue数据绑定原理之依赖收集触发