2. 「vue@2.6.11 源码分析」数据驱动视图(响应式)

873 阅读9分钟

这是一个系列文章,请关注 vue@2.6.11 源码分析 专栏


vue 最核心的卖点是数据驱动和组件。浏览器原生提供的交互是通过dom api来修改dom元素,由于浏览器兼容性问题后面的框架如jquery对原生的api进行了一层的封装以屏蔽浏览器的差异性,但并未作出实质的改变。想想这个过程,通常是数据发生变化,js根据变化的情况进行判断而后操作dom。dom变动的本质实际根本上实际是由数据驱动,我在第一家公司数字政通(egova)首次接触了的此类框架knockout

所谓数据驱动其实就是监听数据发生变化,当数据发生变化后通知订阅者做出响应。

在介绍v2.6.11实现之前,我们先看下观察者模式。

观察者模式

一般的观察者模式只存在订阅关系,是单向的,即主题保存着观察者的引用,为了和vue实现的对齐,下面的实现添加另一层关系即观察者(ObserverWatcher)对于主题(Subject)的依赖关系(因此主题也可以认为是依赖Dep)。 此时观察者和主题的关系则变为双向的,并且是多对多的,即一个主题可以被多个观察者订阅,一个观察者也可以依赖多个主题(有多个依赖)。

主题/依赖:Dep

let uid = 0;

export default class Dep {
  constructor() {
    this.watchers = [];
    this.id = `Dep_${uid++}`;
  }

  addSub(watcher) {
    if (this.watchers.findIndex(item => item === watcher) >= 0) {
      return
    }
    this.watchers.push(watcher);
  }

  notify() {
    this.watchers.forEach(watcher => watcher.update(this))
  }
}

观察者:Watcher

let uid = 0;

export default class Watcher {
  constructor() {
    this.deps = []
    this.id = `Watcher_${uid++}`;
  }

  addDep(dep) {
    if (this.deps.findIndex(item => item === dep) >= 0) {
      return
    }
    this.deps.push(dep)
    dep.addSub(this);
  }

  update(dep) {
    console.log(`${this.id}_${dep.id}, changed`)
  }
}

test

import Watcher from "./Watcher.js";
import Dep from "./Dep.js";

function main() {
  const watcherOne = new Watcher();
  const watcherTwo = new Watcher();

  const subjectOne = new Dep();
  watcherOne.addDep(subjectOne)
  watcherTwo.addDep(subjectOne)
  subjectOne.notify();

  console.log('----------');

  const subjectTwo = new Dep();
  watcherTwo.addDep(subjectTwo)
  subjectTwo.notify();
}

main();

image.png

小结

vue的数据驱动视图的核心就是,数据具备响应式能力(即上面观察者模式中的主题的能力可以被订阅-addSub,也可以通知变更-notify

另外上面观察者和主题的双向关系:订阅关系和依赖关系(依赖收集就是指的依赖关系的建立)是开发者手动建立的。但是在vue开发中,我们并未做过这类事情,这个已经包含在框架内了,框架会自动进行依赖收集(addSub/addDep)和派发更新(notify)

通过ProxyObject.definePropery两种方式来拦截数据的读取-getter和修改-setter,vue-v2.x版本的响应式能力基于Object.definePropery实现。

如何自动收集依赖?依赖关系建立的发起人是观察者,可以设置一个全局的变量来记录当前观察者(那么自然要求依赖收集需要时同步的过程),这个观察者是有“作用过程”的,在这个“作用过程”中读取了响应式数据即进入响应式数据的getter,在getter中建立双向关系。

建立完双向关系后,派发更新就简单了,直接在响应式数据的setter中通知所有的观察者

由于数据需要具备addSubnotify能力,后面的实现(也是vue的实现)将这些能力收敛到Dep类中,将数据和Dep关联起来(一对一),自然就将二者捆绑起来了。这里关联在vue中有两种不同的实现(一个是对象属性,一个是闭包),分别针对不同的场景,后面会看到。

下面我们看下依赖收集和派发更新的具体实现,vue中常使用的响应式数据为普通对象数组两种形式,下面我们只以普通对象来说明这两个问题(数组后面单独再补充)。

响应式的实现(v2.x)

1. 数据增强(具备响应式能力)

observe

Object.isExtensible判断对象是否可扩展性
默认情况下,对象都是可以扩展的,即对象可以添加新的属性和方法。使用Object.preventExtensions()、Object.seal()和Object.freeze()方法都可以标记对象为不可扩展。

observe方法用来作为增强value的入口,判断是否可以进行增强(具备响应式能力)

  1. 我们这里由于是使用普通对象作为案例,因此先判断是否是普通对象,如果不是则忽略
  2. 然后会再判断value是否已经是响应式对象了,如果是则直接返回
  3. 即是可扩展的普通对象又不是响应式对象,则进行增强:new Observer()。
function isPlainObject(obj) {
  return obj.toString() === '[object Object]'
}

export function observe(value) {
  if (!isPlainObject(value)) {
    return
  }
  let ob
  if (value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (Object.isExtensible(value)) {
    ob = new Observer(value)
  }
  return ob
}

Observer

这里关注三点

  1. 有一个和当前value关联的dep对象,这是为了给value新增删除属性做准备的(value是普通对象,其每个属性也会关联一个dep对象,在defineReactive方法中通过闭包持有),后面会细说
  2. value新增一个不可枚举的属性作为标识,说明已经是响应式对象了,还有一个很重要的作用可以直接通过数据拿到dep属性即value.__ob__用来发布变更,在set/del部分会看到。
  3. 遍历该普通对象的每个属性,然后调用defineReactive来拦截每个属性的读取和修改。
import Dep from "./Dep.js";

export class Observer {
  constructor(value) {
    this.value = value
    this.dep = new Dep()

    // 这里添加属性不应被枚举
    Object.defineProperty(value, '__ob__', {
      value: this,
      enumerable: false,
      writable: true,
      configurable: true
    })

    this.walk(value)
  }

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

defineReactive

先是创建一个dep对象用来关联当前属性的读取和修改,相当于当前属性的读取和修改进行的依赖收集和派发更新都是借助该dep对象来完成。

然后或者该属性的属性描述符判断是否可配置,不可配置则直接返回;

所以由于该属性可以已经被重写过即定义了setter/getter等,先保存下来,因为这里也要改写setter/getter,为了保证之前修改的setter/getter不丢失,会在新的setter/getter调用老的setter/getter

递归遍历(DFS)当前属性值,保证整颗对象涉及的所有对象都具备响应式能力;单纯后面的 Object.defineProperty仅仅是拦截当前属性,仅能使得当前属性的读写具备响应式能力。

另外关注到enumerableconfigurable,二者显然应该为true

然后重点看下重写后的getter(setter在后面的派发更新时再细说),先是执行老的getter以获取属性值(如果没有提供getter,则通过闭包读写值val),如果当前有观察者,则进行双向关系的保存:观察者收集依赖和依赖收集订阅者(就是观察者),这部分能力在watcher.addDep(dep)方法中。

这里需要注意,setter存在 && getter不存在的场景是没有意义的,之所以提这个是因为源码中对于存在老setter会直接调用老setter并且不会更新val,这会导致如果不存在getter拿不到最新值(即返回val)不会出现意外,直接忽略该场景就行,因为实际开发中,这种设置没有意义。

注意这里的dep有两个:

  1. defineReactive提供的dep用来监听现有属性的读写
  2. Observer中提供的dep用来监听新增和删除属性的

后面会有案例说明这两个dep的区别

export function defineReactive(obj, key, val) {
  // obj[key]的getter/setter 通过闭包来关联一个依赖
  // 可以认为这个dep的状态就是这个属性的数据状态
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  const getter = property && property.get
  const setter = property && property.set

  let childOb = observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      const value = getter ? getter.call(obj) : val

      if (Dep.currentWatcher) {
        Dep.currentWatcher.addDep(dep) // 这个dep是当前方法提供的
        childOb && Dep.currentWatcher.addDep(childOb.dep) // childOb.dep是Observer实例上的
      }

      return value
    },
    set: function (newVal) {
     //...
    }
  })
}

2. 依赖收集

上面数据增强部分看到可以让关心的数据变成响应式,要完成依赖收集,就需要观察者发起。

由于依赖收集过程存在多层嵌套的可能性,因此通过栈去存储每一层的观察者。即下面的pushTarget和popTarget方法,二者用来设置当前观察者和恢复上一层观察者。显然从push到pop有个作用过程,在这个作用过程期间会执行一段逻辑(下面构造函数中的第一个入参fn)如果读取了响应式数据,则会进入getter(调用 watcher.addDep ),完成依赖收集。

import Dep from "./Dep.js";

let uid = 0;
const watcherStack = [];

function noop() {
}

export default class Watcher {
  constructor(fn = noop, cb = noop) {
    this.deps = []
    this.id = `Watcher_${uid++}`;

    this.getter = fn
    this.cb = cb;
    this.value = this.get();
  }

  get() {
    this.pushTarget();
    const value = this.getter(); // 这里面读取响应式数据,则会进行依赖收集
    this.popTarget();
    this.cleanupDeps();
    return value;
  }
  
  cleanupDeps() {
    console.log('更新和清理新旧依赖')
  } 

  pushTarget() {
    watcherStack.push(this)
    Dep.currentWatcher = this;
  }

  popTarget() {
    watcherStack.pop()
    Dep.currentWatcher = watcherStack.length ? watcherStack[watcherStack.length - 1] : null
  }

  addDep(dep) {
    if (this.deps.findIndex(item => item === dep) >= 0) {
      return
    }
    this.deps.push(dep)
    dep.addSub(this);
  }

  update(dep) {
    const oldValue = this.value;
    this.value = this.get();

    this.cb(oldValue, this.value);
    console.log(`${this.id}_${dep.id}, changed`);
  }
}

3. 派发更新

看下响应式数据的setter实现,逻辑很简单

  1. 先是对比新老数据,如果数据未变化,则直接返回
  2. 如果存在老setter,在调用老setter,否则将新值赋值给外层闭包变量val
  3. 需要将新值增强为响应式数据
  4. 通知所有的观察者(就是订阅者),dep.notify -> [watchers].update

看下上面watcher.udpate实现:需要重新建立依赖关系(watcher.get),然后调用回调。

在v2.6.11实现中,Watcher持有两个依赖数组(deps, newDeps),每次重新建立完依赖后的依赖关系可能发生变更(新增了依赖关系,已有的依赖可能不再存在),因此源码中在收集完依赖后会调用this.cleanupDeps()更新和清理新旧依赖。

export function defineReactive(obj, key, val) {
  //...
  let childOb = observe(val)
  Object.defineProperty(obj, key, {
    //...
    set: function (newVal) {
      const value = getter ? getter.call(obj) : val

      if (newVal === value || (newVal !== newVal && value !== value)) { // 后者是为了判断 NaN
        return
      }

      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = observe(newVal)
      dep.notify()
    }
  })
}

demo

easy-demo

import {observe} from "./observe.js";
import Watcher from "./Watcher.js"; 

function main() {
    const data = {
        a: 'a',
        b: {
            c: 'c'
        }
    }
    // 让数据变成响应式,即具备观察者模式的能力
    observe(data);

    // 建立双向关系(依赖(或者主题的)订阅者、观察者的依赖(或者订阅的主题)
    new Watcher(function () {
        console.log('watcher.fn 读取数据,建立双向关系', JSON.stringify(data.b));
    }, function () {
        console.log('watcher.cb 数据变更回调', JSON.stringify(data.b))
    });

    // 派发跟新
    data.b = {d: 'd'}
}

main();

执行结果如下 image.png

初始data.b和变更后data.b指向两个对象,会发生依赖变更,因此从cleanupDeps是有必要的,否则会有内存泄漏问题,观察者的依赖可能会持续增加。

多层依赖关系(观察者栈)

在页面渲染的过程中嵌套组件是很正常的,这个特性主要是为了满足该场景。

import {observe} from "./observe.js";
import Watcher from "./Watcher.js"; 

function main() {
    const data = {
        a: 'a',
        b: {
            c: 'c'
        }
    }
    // 让数据变成响应式,即具备观察者模式的能力
    observe(data);

    console.log('建立双向关系------------')
    // 建立双向关系(依赖(或者主题的)订阅者、观察者的依赖(或者订阅的主题)
    new Watcher(function () {
        console.log('watcher.fn_1 读取数据,建立双向关系', JSON.stringify(data.b));

        // 嵌套
        new Watcher(function () {
            console.log('watcher.fn_2 读取数据,建立双向关系', JSON.stringify(data.a));
        }, function () {
            console.log('watcher.cb_2 数据变更回调', JSON.stringify(data.a))
        });

    }, function () {
        console.log('watcher.cb_1 数据变更回调', JSON.stringify(data.b))
    });

    console.log('派发更新------------watcher_1')
    data.b = {d: 'd'}
    
    console.log('派发更新------------watcher_2')
    data.a = 'a_1' 
}

main();

image.png

【注意】:修改data.b时嵌套Watcher的那部分逻辑会再执行一次,会创建一个新的watcher实例(watcher.id = 2),之前老的watcher实例(watcher.id = 1)由于仍然被某个dep持有引用(订阅关系)因此不会被释放。所以看到当修改data.a时,有两个watcher执行了相同的逻辑,唯一的区别就是watcher.id

因此我们看到v2.6.11中的Watcher还提供了teardown实例方法,用来解除和依赖的双向关系,这里不再展开。

【补充】如vm.$watch实例方法就会返回一个销毁的函数以防止内存泄漏

  Vue.prototype.$watch = function (expOrFn, cb, options) {
    var vm = this;
    //...
    options = options || {};
    options.user = true;
    var watcher = new Watcher(vm, expOrFn, cb, options);
    //...
    return function unwatchFn () {
      watcher.teardown();
    }
  };

小结

上面的demo几乎就是总结了。

看到写事件相关的基础功能时容易造成内存泄漏问题(引用不释放导致了很多无效监听)。

由于上面只处理了对象属性的响应式,如果给对象新增和删除属性由于不会走getter/setter,因此不能完成响应式的过程,v2.6.11中单独了api以完善这个过程。

对象属性的新增和删除支持响应式

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

vue单独提供以下Api来支持

Vue.set = Vue.prototype.$set = set;
Vue.delete = Vue.prototype.$delete = del;

下面重点看下 set/del 的实现

依赖收集

defineReactive方法中的dep对象是用来支持对象属性的,而Observer实例上的dep对象是和整个对象关联的,主要用来支持对象属性的新增和删除的响应式。依赖收集主要是下面红色部分,当某个属性的值是对象时,则会返回该对象关联的Observer实例即childOb,同样需要在这里进行依赖收集(显然根对象的属性的新增和删除在这里做不到响应式了,因为根对象Observer实例上的dep不参与依赖收集即不会被作为依赖)。

image.png

派发更新

由于不能触发新属性的setter(因为压根没有定义过),因此需要手动派发更新。

属性新增:set

import { defineReactive } from "./observe.js";

export function set(data, key, val) {
  //... 数组场景

  if (key in data && !(key in Object.prototype)) {
    data[key] = val
    return val
  }
  const ob = (data).__ob__
  if (!ob) {
    data[key] = val
    return val
  }
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}
  1. 如果设置的属性已经存在或者data压根不是响应式,则常规处理,然后返回
  2. 关键1:新属性 并且 data是响应式数据,则调用defineReactive监听新属性,保证新属性具备响应式能力
  3. 关键2:通知观察者

属性删除:del

export function del(data, key) {
  //... 数组相关
  const ob = data.__ob__
  if (!hasOwn(data, key)) {
    return
  }
  delete data[key]
  if (!ob) {
    return
  }
  ob.dep.notify()
}

逻辑很简单,不再赘述

demo

function main_2() {
    const data = {
        a: 'a',
        b: {
            c: 'c'
        }
    }
    // 让数据变成响应式,即具备观察者模式的能力
    observe(data);

    new Watcher(function () {
        console.log(data.b) // 属性b变更,以及属性b指向对象新增或者删除属性
    }, function () {
        console.log('set/del 数据变更回调', JSON.stringify(data))
    })

    console.log('常规新增属性------')
    data.b.e = 'e' // 新增属性

    console.log('api新增属性------')
    set(data.b, 'd', 'd')

}
main_2()

image.png

看到只有调用set api触发更新了。

【注意】:该案例中的data属性的新增和删除是无法监听到的。

数组

将数组【元素】变成响应式

上面介绍了普通对象的响应式,现在看下数组的响应式,observe方法和Observer构造函数的变动如下,这里只是增加对数组类型的判断,对数组进行放行。 image.png

Observer构造函数中看到数组和普通对象的处理有些差异,普通对象是遍历该对象的所有属性,逐个将每个属性变成响应式,而数组有两个步骤:先是执行 protoAugment 而后执行 observeArray

两个方法的实现如下

export class Observer {
    //...
    
    protoAugment(arrayData, src) {
        arrayData.__proto__ = src
    }
    
    observeArray(items) {
        for (let i = 0, l = items.length; i < l; i++) {
            observe(items[i])
        }
    }
    
}

observeArray遍历每个数组中的每个元素,增强每个数组元素。protoAugment只是设置了原型。

将数组【自身】变成响应式

vue中,data中只能返回一个对象,不能是数组

{
    data() {
        return [1, 2, 3]
    }
}

image.png

数组只能作为返回对象的某个属性的值,因此数组自身的依赖收集能力和普通对象是一样的

childOb && Dep.currentWatcher.addDep(childOb.dep) // childOb.dep是Observer实例上的 }

和普通对象的主要差异是,普通对象针对每个属性进行了监听,针对新增和删除属性做了特殊处理。而对于数组自身并不能监听索引更新和新增和删除元素,因此数组的这两个问题都需要特殊处理。

派发更新:push、pop、shift、unshift、sort、splice、reverse

因为observeArray只是增强数组中的元素,而对于数组本身的操作,但是针对数组自身的变更如push/pop或者直接通过索引修改元素内容,并不会被监听到。因此针对这部分需要单独实现一次。

下面重点看下调用protoAugment传递的 arrayMethods 是什么?

function def(obj, key, val, enumerable) {
    Object.defineProperty(obj, key, {
        value: val,
        enumerable: !!enumerable,
        writable: true,
        configurable: true
    })
}

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]
    def(arrayMethods, method, function mutator(...args) {
        const result = original.apply(this, args)
        const ob = this.__ob__
        let inserted
        switch (method) {
            case 'push':
            case 'unshift':
                inserted = args
                break
            case 'splice':
                inserted = args.slice(2)
                break
        }
        if (inserted) ob.observeArray(inserted)
        // notify change
        ob.dep.notify()
        return result
    })
})

逻辑很简单,拦截了部分更新数组的方法push、pop、shift、unshift、sort、splice、reverse。这里的关键点是原型链的重新连接。

数组最初始的原型指向Array.prototype,现在指向arrayMethods,另外arrayMethods的原型指向Array.prototype(Object.create了解下),根据原型链上属性的查找规则,自然对于改写的方法走自己重新实现的,而其他的方法依然走原先的。

关注重新实现的部分有三点

  1. 调用内置实现
  2. 对于新增的元素调用 observeArray,将新元素变为响应式
  3. 发布通知

派发更新:set/del

vue-2.x 深入响应式原理 - 对于数组
Vue 不能检测以下数组的变动:
1.当你利用索引直接设置一个数组项时,例如:vm.items[indexOfItem] = newValue 2.当你修改数组的长度时,例如:vm.items.length = newLength

function set(data, key, val) {
  //... 数组场景
  if (Array.isArray(data) && isValidArrayIndex(key)) {
    data.length = Math.max(data.length, key)
    data.splice(key, 1, val)
    return val
  }
  //...
}

function del(data, key) {
    // 数组场景
    if (Array.isArray(data) && isValidArrayIndex(key)) {
        data.splice(key, 1)
        return
    } 
    //...
}

逻辑很清楚,不再赘述。

demo

function main_3() {
    const data = {
        arr_1: [1, 2, 3],
        arr_2: [1, 2, 3]
    }

    observe(data)
    // debugger
    new Watcher(function () {
        console.log('数组更新回调 读取数据,建立双向关系', JSON.stringify(data.arr_1, data.arr_2));
    }, function () {
        console.log('数组更新回调', JSON.stringify(data))
    })

    console.log('更新对象属性-------begin')
    data.arr_1[1] = 'x' // 不是响应性的
    console.log('-----------------------')
    data.arr_1.length = 2 // 不是响应性的
    console.log('更新对象属性-------end')

    // console.log('数组添加新元素-------begin')
    const newLength = 2;
    set(data.arr_2, 1, 'x');
    console.log('-----------------------')
    data.arr_2.splice(newLength) // 等价于 data.arr.length = newLength
    // console.log('数组添加新元素-------end')
}

main_3();

image.png

不再分析。

总结

  1. 组件递归渲染 在这里的体现
  2. nextTick 单独小结补充,涉及到事件循环-渲染等较复杂的问题