Vue2.6.x源码阅读 - 7.源码阅读-core-响应式原理

数据类型(data、prop、watch、computed)初始化,构建数据双向绑定等相关源码学习。

  • 关于响应式原理的基本概念的介绍可见Vue响应式原理一文。了解基本概念后,继续结合源码对响应式原理展开更深入的了解。

观察者模式(Observer pattern) 与 发布-订阅模式(Pub-sub pattern)

  • 观察者模式JavaScript设计模式中常用的设计模式之一,定义了对象间一对多的依赖关系,当被观察者发生变化时,观察者也会根据依赖关系随之进行更新。形象点来说,被观察者相当于一个视频号,观察者就是关注视频号的人,当视频号更新时,关注人都会收到对应的推送,然后开展其某些行为。这种模式很容易就能联想到Vue的数据响应,被订阅的数据发生变化时,最终相应到view层的dom变化。

  • 不过Vue的响应式原理并没有仅限于停留在观察者模式。在观察者模式的基础上,优化出了一个新的模式,即发布-订阅模式。它与前者的区别在于,在发布者(被观察者)与订阅着(观察者)之间,增加了一个调度中心。由调度中心对发布者发布的内容进行统一处理,发送至订阅了该内容的订阅者。订阅者与发布者之间并不存在直接沟通,其沟通是通过第三方的调度中心完成的。再形象点来说,就好比视频号的订阅人与视频号的所有人之间并没有直接联系,而通过视频平台构建两者的关系。

  • 两种模式的结构可见下图。摘自观察者模式和发布订阅模式有什么区别?

  • Vue即采用了发布订阅模式来作为响应式原理的核心设计模式。回看官方给出的结构图。可以根据源码实现与设计模式的进行一定的转化。转化后图摘自从发布-订阅模式到Vue响应系统

  • Observer部分就是发布者(被订阅者),Dep(Dependency)即调度中心,在原图中合并为了Data部分。Watcher即订阅者(观察者),在原图中依旧为Watcher

  • 用更Vue化的意思来说,整个流程即是通过Observer与数据(Data)构建联系,整体作为一个发布者。在数据发生变化时,通过Dep通知到Watcher,而Watcher又与界面元素建立了联系,从而完成了dom的更新。

  • Dep(调度中心)在Vue中主要做了两件事,依赖收集(Collect Dependency)与派发更新(Notify)。即收集来自订阅者的订阅,通知订阅者相关发布者的更新。

Observer

  • 在上述设计模式的解释中,通过图解,已将设计模式转换成了Vue源码的实现。在Vue核心源码(core)中,整个core/observer目录均为响应式原理的实现代码。先整体预览一遍目录内的文件构成。

    • array.js 创建含有重写数组方法的数组,让所有的响应式数据数组继承自该数组

    • dep.js Dep Class相关代码

    • index.js Observer Class相关代码

    • scheduler.js 任务调度工具,Wathcer执行的核心

    • traverse.js 递归遍历响应式数据,用于触发依赖收集。

    • watcher.js Watcher Class相关代码

array.js

  • 该文件的功能,上述也已提到,创建含有重写数组方法的数组,让所有的响应式数据数组继承自该数组。即对定义在Vue data中的数据进行响应式处理,当数组发生改变的时,触发对应的响应式操作。那么如何去触发响应式操作,在Vue中,就是对Array.prototype中的原生方法进行了重写,在原生方法的基础上,加入了Vue所需要的触发依赖收集与派发更新两部分的代码。

  • 分段解析该文件中的内容,首先该文件引入了util/lang.js中的一个def方法。该方法实质上是封装了Object.defineProperty,固定了writableconfigurable属性为true。而开放了enumerable属性的配置。关于这几个属性的作用,可详见[Object.defineProperty()简介](https://github.com/fff455/tech-share/blob/master/JavaScript/Object.defineProperty()%E7%AE%80%E4%BB%8B.md)

    /**
    * Define a property.
    */
    export function def (obj: Object, key: string, val: any, enumerable?: boolean) {
      Object.defineProperty(obj, key, {
        value: val,
        enumerable: !!enumerable, // 开放enumerable的配置,双非运算符将enumerable转换为一个真正的boolean类型
        writable: true,
        configurable: true
      })
    }
  • 那么为何开放enumberable可配置。首先,该属性意为是否可枚举,即是否能够通过Object.keys()访问到。当我们在遍历整个Vue实例对象的属性时,不难发现其中存在循环定义。在5.源码阅读-core-Vue构造函数中的_init()方法内,就存在一行循环定义的代码vm._self = vm,将实例本身定义进了_self属性,即循环定义。除此之外Vue中存在的另一个常见循环定义还有vm.$el.__vue__ = vm。正因为这样的定义存在,所以必须让这些属性不可遍历,否则对Object属性的遍历也会进入死循环。也因此开放了enumerable的配置。

  • 继续看array.js的后续代码

    // arrayMethods 通过Object.create(Array.prototype)创建
    // 即 arrayMethods 继承自 Array.prototype,该定义方式也防止了后续重写污染Array原型
    const arrayProto = Array.prototype
    export const arrayMethods = Object.create(arrayProto)
    
    // 需要重写的数组方法
    // 这里重写的数组方法均为能够改变原数组的方法,这也是因为不改变数组本身的方法的调用不需要触发派发更新
    const methodsToPatch = [
      'push',
      'pop',
      'shift',
      'unshift',
      'splice',
      'sort',
      'reverse'
    ]
    /**
    * Intercept mutating methods and emit events
    * 遍历需重写方法的数组,进行方法重写
    */
    methodsToPatch.forEach(function (method) {
      // cache original method 缓存原生数组方法
      const original = arrayProto[method]
      // 通过基于Object.defineProperty封装的def()重写原生的数组方法
      def(arrayMethods, method, function mutator (...args) {
        // 通过apply调用原生方法,由于需重写的原生方法入参个数未知,故不可直接通过original()的形式进行调用
        const result = original.apply(this, args)
        // 这里ob即为obsever,在index.js中继续做分析
        const ob = this.__ob__
        let inserted
        switch (method) {
          case 'push':
          case 'unshift':
            inserted = args
            break
          case 'splice':
            inserted = args.slice(2)
            break
        }
        // push、unshift、splice等方法入参中,变化部分的内容需要变为响应式
        // 假设arr中的元素原来均为响应式,那么通过 arr.push(obj) 新加入的obj也需要是响应式的
        if (inserted) ob.observeArray(inserted)
        // notify change
        ob.dep.notify() // 通知调度中心进行派发更新
        return result // return重写数组方法的结果
      })
    })
  • 通过array.js的代码也可以发现Vue2中的一个问题,数组发生变化,设置数组的length并不会触发响应式更新,在Vue3中通过Proxy替代Object.defineProperty后进行了解决。

dep.js

构造函数中的数据初始化

  • 了解完响应式原理最核心几个类的实现后,回看构造函数中的数据初始化。在5.源码阅读-core-Vue构造函数中,可以了解到数据(state)的初始化位于构造函数中的initState方法中完成。该方法对包括method在内的五种数据进行了初始化。那么根据重要程度,先从data类型的初始化方法initData开始进行解析。

initData

  • 直接上initData的代码,其中对开发环境下的warning部分的代码进行了一定的省略。

    function initData (vm: Component) {
    let data = vm.$options.data
    data = vm._data = typeof data === 'function'
      ? getData(data, vm)
      : data || {}
    if (!isPlainObject(data)) {
      // 非生产环境下,对data进行判空保护,以防data()不返回object
    }
    // proxy data on instance
    const keys = Object.keys(data)
    const props = vm.$options.props
    const methods = vm.$options.methods
    let i = keys.length
    while (i--) {
      const key = keys[i]
      if (process.env.NODE_ENV !== 'production') {
        if (methods && hasOwn(methods, key)) {
          // data与method判重保护
        }
      }
      if (props && hasOwn(props, key)) {
        // data与prop判重保护,注意与prop的重复判定并不只是在开发环境下进行。
      } else if (!isReserved(key)) {
        proxy(vm, `_data`, key)
      }
    }
    // observe data
    observe(data, true /* asRootData */)
    }

最后更新于