侧边栏

Vue源码阅读笔记之响应式原理(二)

发布于 | 分类于 源码分析/Vue

这是"Vue源码阅读笔记"系列第二篇文章。前一篇文章中我们提到initState中调用的initData方法,调用observe(data)完成了对数据的观察。

在很早之前的对象描述符与响应式数据这篇文章中,对于defineProperty做了简单的整理,并实现了一个比较粗糙的响应式数据更新视图的例子。接下来让我们深入Vue的核心部分:响应式数据的工作原理。

参考:

接下来我们看看Vue中initData是如何实现的响应式数据的。跟前面一样,我们对initData进行简化

Js
function initData (vm: Component) {
  let data = vm.$options.data
  // 对数据进行代理
  const keys = Object.keys(data)
  let i = keys.length
  while (i--) {
    const key = keys[i]
    proxy(vm, `_data`, key)
  }
  // 观察数据变化
  observe(data, true /* asRootData */)
}

initData中做了两件事

  • 将数据代理到this上
  • 实现响应式数据

数据代理

在Vue中,我们可以通过 this.msg 访问到 this.$data.msg ,这是通过数据代理实现的,原理十分简单,即修改对应的getter和setter。

Js
export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

响应式数据

Vue文档中提到了响应式数据的基本原理,稍作整理得到下列结论

  • 数据模型仅仅是普通的 JavaScript 对象。而当你修改它们时,视图会进行更新
  • 如何监听数据的变化呢?所谓监听数据变化,实际上是监听数据某个属性值的变化,Vue使用的是Object.defineProperty
  • 数据属性值变化时该如何更新视图呢?通过发布-订阅者模式,维护一个订阅该属性值的队列,并在值变化时通知所有订阅者,更新视图

其中,Object.defineProperty和订阅者模式在对象描述符与响应式数据这里已经提到过,这里就不再赘述了,我们来看看Vue中是如何实现的。

在Vue中,由ObserverDepWatcher这三个类构成了Vue响应式系统(即observer模块)的核心。observer模块位于/src/core/observer下面。

此处强烈推荐observer-dep-watch这个项目,相当于一个精简版的响应式系统。下面的源码中,我移除了一些细节代码,这样可以更清晰地了解整个模块的结构。

###Observer

Observer用来将数据转换成可监控的值

Js
// /src/core/observer/index.js
class Observer {
  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    
    // 数据对象自身的观察者
    def(value, '__ob__', this)
    // 需要对数组进行单独处理
    if (Array.isArray(value)) {
      const augment = hasProto
        ? protoAugment
        : copyAugment
      augment(value, arrayMethods, arrayKeys)
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }
}

Watcher

Watcher是数据变化的订阅者,用来执行数据变化之后的某些操作(比如更新视图)

Js
class Watcher {
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
      
    this.cb = cb
    this.expression = expOrFn.toString()
    // parse expression for getter
    // 把需要观察的属性值表达式解析成一个可执行的函数
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
      // ...
    }
    
    // 这里会调用pushTarget和一次getter
    this.value = this.lazy ? undefined : this.get()
  }
}

构建一个Watcher对象需要的构造参数中包括

  • expOrFn,表示需要监听的属性名
  • cb,对应属性名的值发生变化时的回调,用于处理相应的业务逻辑(比如更新视图)

###Dep

Dep用来连接ObserverWatcher,并充当事件变化的发布者:通过Observer提供的set和get,在get中收集Watcher,在set中通知Watcher

js
class Dep {
  constructor () {
    this.id = uid++
    this.subs = []
  }

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

上面只是复制粘贴了相关的实现源码,那么,这三个类是如何关联起来的呢?

Observer与Dep的关联

首先,在Observer的walk方法中,对数据对象的每个属性进行劫持

Js
walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
    	defineReactive(obj, keys[i], obj[keys[i]])
    }
}

可以看见实际上是defineReactive在工作,这个函数在其他的源文件中也出现过很多次。

Js
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()
  	
  // 递归劫持setter和getter
  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = val
      if (Dep.target) {
        // 收集依赖
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      const value = val
      // 新旧值相同不触发更新的原因
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      
      val = newVal
      childOb = !shallow && observe(newVal)
      // 通知订阅者
      dep.notify()
    }
  })
}

这里通过defineReactivesetget形成闭包,为数据的每个属性(包括子属性)都维护了一个Dep对象,可以看见:

  • get中通过dep.depend()添加订阅者
  • set中通过dep.notify()通知订阅者

其中还有一些检测属性描述符configurable、已设置的settergetter等代码,这里先省略了

Watcher与Dep的关联

这个就简单了,Dep作为发布者,Watcher作为订阅者,直接通过其接口关联即可

Js
// Dep.addSub
addSub (sub: Watcher) {
  	this.subs.push(sub)
}
Js
// Watcher.addDep
addDep (dep: Dep) {
    const id = dep.id
    if (!this.depIds.has(id)) {
      dep.addSub(this)
    }
}

然而在上面添加订阅者的时候,我们发现是通过dep.depend()来实现的

Js
// Dep.depend
depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
}

这里使用了一个静态对象Dep.target,来标识当前的Watcher

Js
// dep.js
Dep.target = null
const targetStack = []

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

Watcher构造函数中的最后一句调用了this.get()

Js
// watcher.js
this.value = this.lazy ? undefined : this.get()
Js
// Watcher.get
get(){
  // 这里先将Dep.target关联到当前的Watcher实例对象
  pushTarget(this);
   try {
      // getter实际上就是Watcher观察的属性表达式
      value = this.getter.call(vm, vm)
   }catch (e) {
     // ...
   }
}

分析

下面一步步分析:

  • Watcher的构造函数中,我们可以了解到this.getter实际上就是构造参数expOrFn的函数形式(这是在/src/core/util/lang.jsparsePath实现的),其作用就是获取数据对象对应属性表达式的值,这里相当于调用了一次get
  • 调用get,这样就可以触发defineReactive中该属性描述符get中的dep.depend()
  • 由于在调用get之前先通过pushTarget(this)将Dep.target关联到当前的Watcher实例对象,从而为当前属性的发布者dep添加订阅者。

再回头看一看整个流程,我们传入了data数据对象

  • 首先通过observe(data),实例化一个Observer对象,其作用是通过defineReactive劫持对象属性描述符
  • 数据对象data的每一个属性值都有一个Dep实例,在属性的get中收集订阅者,在属性的set中通知订阅者
  • 在需要观察的数据属性的地方,实例化Watcher对象,在实例化的过程中,通过pushTarget(this)和一次this.getter.call(vm, vm),触发dep.depend实现依赖的收集

可以看见,Dep对象充当联结Observerwatcher对象之间的桥梁。实际上,属性值的set描述符才是真正的发布者。

单独处理的数组

列表渲染的文档中提到

Vue 包含一组观察数组的变异方法,所以它们也将会触发视图更新。

在JavaScript中,数组是一种特殊的对象,通过walk遍历对象属性的方法,并不能实现预期的依赖收集和更新通知,那么该如何检测数组本身的变化,诸如添加元素、移除元素呢?

Vue的做法是新建一个数组原型并重写相关方法

js
const augment = hasProto ? protoAugment : copyAugment
augment(value, arrayMethods, arrayKeys)

其中arrayMethods就是增强版的数组原型

js
// /src/core/observer/array.js
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
;[
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
.forEach(function (method) {
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    // ...
    // 在这里通知所有的订阅者
    ob.dep.notify()
  })
}

然后修改数组数据对象的原型,使其指向增强版的数组原型

Js
if (Array.isArray(value)) {
    const augment = hasProto
    ? protoAugment
    : copyAugment
    // 如果是数据对象为数组,修改其原型为增强版的数组原型
    augment(value, arrayMethods, arrayKeys)
    this.observeArray(value)
} else {
	this.walk(value)
}

现在遍历数组,然后分别为每个数组元素均实例化Observer对象即可。

js
observeArray (items: Array<any>) {
  for (let i = 0, l = items.length; i < l; i++) {
  	observe(items[i])
  }
}

小结

通过分析,现在对于Vue中的响应式系统有了一个更直观的了解~尽管仍旧忽略了不少实现细节。然后明白了其中的一些限制

  • Vue不能检测到对象属性的添加或删除,这是因为整个observer(data)是在初始化的时候对属性进行劫持的,解决这个问题的办法是通过Vue.set()这个API,或者创建一个包含原对象属性和新属性的新对象,通过赋值的形式
  • Vue无法检测到数组的长度变化,即this.arr[this.arr.length] = xxx无法触发更新。除非我们使用Vue实现的增强版数组原型方法才可以。

我们知道,响应式数据最大的好处就是在数据更新时自动更新视图,让开发者只需要关注数据本身。现在了解了整个响应式数据的原理,接下来就是了解Vue的视图渲染,以及数据更新时触发的视图更新。

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。