Vue源码解析五——数据响应系统

接下来重点来看Vue的数据响应系统。我看很多文章在讲数据响应的时候先用一个简单的例子介绍了数据双向绑定的思路,然后再看源码。这里也借鉴了这种方式,感觉这样的确更有利于理解。

数据双向绑定的思路

1. 对象

先来看元素是对象的情况。假设我们有一个对象和一个监测方法:

const data = {
    a: 1
};
/**
* exp[String, Function]: 被观测的字段
* fn[Function]: 被观测对象改变后执行的方法
*/
function watch (exp, fn) {

}

我们可以调用watch方法,当a的值改变后打印一句话:

watch('a', () => {
    console.log('a 改变了')
})

要实现这个功能,我们首先要能知道属性a被修改了。这时候就需要使用Object.defineProperty函数把属性a变成访问器属性:

Object.defineProperty(data, 'a', {
    set () {
        console.log('设置了 a')
    },
    get () {
        console.log('读取了 a')
    }
})

这样当我们修改a的值:data.a = 2时,就会打印出设置了 a, 当我们获取a的值时:data.a, 就会打印出读取了 a.

在属性的读取和设置中我们已经能够进行拦截并做一些操作了。可是在属性修改时我们并不想总打印设置了 a这句话,而是有一个监听方法watch,不同的属性有不同的操作,对同一个属性也可能监听多次。

这就需要一个容器,把对同一个属性的监听依赖收集起来,在属性改变时再取出依次触发。既然是在属性改变时触发依赖,我们就可以放在setter里面,在getter中收集依赖。这里我们先不考虑依赖被重复收集等一些情况

const dep = [];
Object.defineProperty(data, 'a', {
    set () {
        dep.forEach(fn => fn());
    },
    get () {
        dep.push(fn);
    }
})

我们定义了容器dep, 在读取a属性时触发get函数把依赖存入dep中;在设置a属性时触发set函数把容器内的依赖挨个执行。

fn从何而来呢?再看一些我们的监测函数watch

watch('a', () => {
    console.log('a 改变了')
})

该函数有两个参数,第一个是被观测的字段,第二个是被观测字段的值改变后需要触发的操作。其实第二个参数就是我们要收集的依赖fn

const data = {
    a: 1
};

const dep = [];
Object.defineProperty(data, 'a', {
    set () {
        dep.forEach(fn => fn());
    },
    get () {
        // Target就是该变量的依赖函数
        dep.push(Target);
    }
})

let Target = null;
function watch (exp, fn) {
    // 将fn赋值给Target
    Target = fn;
    // 读取属性,触发get函数,收集依赖
    data[exp];
}

现在仅能够观测a一个属性,为了能够观测对象data上面的所有属性,我们将定义访问器属性的那段代码封装一下:

function walk () {
    for (let key in data) {
        const dep = [];
        const val = data[key];
        Object.defineProperty(data, key, {
            set (newVal) {
                if (newVal === val) return;
                val = newVal;
                dep.forEach(fn => fn());
            },
            get () {
                // Target就是该变量的依赖函数
                dep.push(Target);
                return val;
            }
        })
    }
}

用for循环遍历data上的所有属性,对每一个属性都用Object.defineProperty改为访问器属性。

现在监测data里面基本类型值的属性没问题了,如果data的属性值又是一个对象呢:

data: {
    a: {
        aa: 1
    }
}

我们再来改一下我们的walk函数,当val还是一个对象时,递归调用walk:

function walk (data) {
    for (let key in data) {
        const dep = [];
        const val = data[key];
        // 如果val是对象,递归调用walk,将其属性转为访问器属性
        if (Object.prototype.toString.call(val) === '[object Object]') {
            walk(val);
        }

        Object.defineProperty(data, key, {
            set (newVal) {
                if (newVal === val) return;
                val = newVal;
                dep.forEach(fn => fn());
            },
            get () {
                // Target就是该变量的依赖函数
                dep.push(Target);
                return val;
            }
        })
    }
}

添加了一段判断逻辑,如果某个属性的属性值仍然是对象,就递归调用walk函数。

虽然经过上面的改造,data.a.aa是访问器属性了,但下面但代码仍然不能运行:

watch('a.aa', () => {
    console.log('修改了 a.b')
})

这是为什么呢?再看我们的watch函数:

function watch (exp, fn) {
    // 将fn赋值给Target
    Target = fn;
    // 读取属性,触发get函数,收集依赖
    data[exp];
}

在读取属性的时候是data[exp],放到这里就是data[a.aa],这自然是不对的。正确的读取方式应该是data[a][aa]. 我们需要对watch函数做改造:

function watch (exp, fn) {
    // 将fn赋值给Target
    Target = fn;
    
    let obj = data;
    if (/\./.test(exp)) {
        const path = exp.split('.');
        path.forEach(p => obj = obj[p])

        return;
    }


    data[exp];
}

这里增加了一个判断逻辑,当监测的字段中包含.时,就执行if语句块的内容。首先使用split函数将字符串转换为数组:a.aa => [a, aa]. 然后使用循环读取到嵌套的属性值,并且return结束。

Vue中提供了$watch实例方法来观测表达式,对复杂的表达式用函数取代:

// 函数
vm.$watch(
  function () {
    // 表达式 `this.a + this.b` 每次得出一个不同的结果时
    // 处理函数都会被调用。
    // 这就像监听一个未被定义的计算属性
    return this.a + this.b
  },
  function (newVal, oldVal) {
    // 做点什么
  }
)

当第一个函数执行时,就会触发this.athis.bget拦截器,从而收集依赖。

我们的watch函数第一个参数是函数时watch函数要做些什么改变呢?要想能够收集依赖,就得读取属性触发get函数。当第一个参数是函数时怎么读取属性呢?函数内是有读取属性的,所以只要执行一下函数就行了。

function watch (exp, fn) {
    // 将fn赋值给Target
    Target = fn;
    
    // 如果 exp 是函数,直接执行该函数
    if (typeof exp === 'function') {
        exp()
        return
    }

    let obj = data;
    if (/\./.test(exp)) {
        const path = exp.split('.');
        path.forEach(p => obj = obj[p])

        return;
    }


    data[exp];
}

对象的处理暂且就到这里,具体的我们在源码中去看。

2. 数组

数组有几个变异方法会改变数组本身:push pop shift unshift splice sort reverse, 那怎么才能知道何时调用了这些变异方法呢?我们可以在保证原来方法功能不变的前提下对方法进行扩展。可是如何扩展呢?

数组实例的方法都来自于数组构造函数的原型, 数组实例的__proto__属性指向数组构造函数的原型,即:arr.__proto__ === Array.prototype, 我们可以定义一个对象,它的原型指向Array.prototype,然后在这个对象中重新定义与变异方法重名的函数,然后让实例的__proto__指向该对象,这样调用变异方法的时候,就会先调用重定义的方法。

const mutationMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];

// 创建以Array.prototype为原型的对象
const arrayMethods = Object.create(Array.prototype);
// 缓存Array.prototype
const originMethods = Array.prototype;

mutationMethods.forEach(method => {
    arrayMethods[method] = function (...args) {
        // 调用原来的方法获取结果
        const result = originMethods[method].apply(this, args);
        console.log(`重定义了${method}方法`)
        return result;
    }
})

我们来测试一下:

const arr = [];
arr.__proto__ = arrayMethods;
arr.push(1);

可以看到在控制台打印出了重定义了push方法这句话。

先大概有个印象,接下来我们来看源码吧。

实例对象代理访问data

initState方法中,有这样一段代码:

const opts = vm.$options
...
if (opts.data) {
    initData(vm)
} else {
    observe(vm._data = {}, true /* asRootData */)
}

opts就是vm.$options,如果opts.data存在,就执行initData方法,否则执行observe方法,并给vm._data赋值空对象。我们就从initData方法开始,开启探索数据响应系统之路。

initData方法定义在core/instance/state.js文件中:

function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  if (!isPlainObject(data)) {
    data = {}
    process.env.NODE_ENV !== 'production' && warn(
      'data functions should return an object:\n' +
      'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
      vm
    )
  }
  // 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)) {
        warn(
          `Method "${key}" has already been defined as a data property.`,
          vm
        )
      }
    }
    if (props && hasOwn(props, key)) {
      process.env.NODE_ENV !== 'production' && warn(
        `The data property "${key}" is already declared as a prop. ` +
        `Use prop default value instead.`,
        vm
      )
    } else if (!isReserved(key)) {
      proxy(vm, `_data`, key)
    }
  }
  // observe data
  observe(data, true /* asRootData */)
}

内容有点多我们从上往下依次来看,首先是这样一段代码:

let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}

我们知道在经过选项合并后,data已经变成一个函数了。那为何这里还有data是否是一个函数的判断呢?这是因为beforeCreate生命周期是在mergeOptions函数之后initState函数之前调用的,mergeOptions函数就是处理选项合并的。如果用户在beforeCreate中修改了vm.$options.data的值呢?那它就可能不是一个函数了,毕竟用户的操作是不可控的,所以这里还是有必要判断一下的。

正常情况下也就是data是一个函数,就会调用getData函数,并将data和Vue实例vm作为参数传过去。该函数也定义在当前页面中:

export function getData (data: Function, vm: Component): any {
  // #7573 disable dep collection when invoking data getters
  pushTarget()
  try {
    return data.call(vm, vm)
  } catch (e) {
    handleError(e, vm, `data()`)
    return {}
  } finally {
    popTarget()
  }
}

其实该函数就是通过调用data获取到数据对象并返回:data.call(vm, vm). 用try...catch包裹是为了捕获可能出现的错误,如果出错的话调用handleError函数并返回一个空对象。

函数的开头和结尾分别调用了pushTargetpopTarget, 这是为了防止使用 props 数据初始化 data 数据时收集冗余的依赖。

再回到initData函数中,所以现在datavm._data就是最终的数据对象了。

接下来是一个if判断:

if (!isPlainObject(data)) {
    data = {}
    process.env.NODE_ENV !== 'production' && warn(
      'data functions should return an object:\n' +
      'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
      vm
    )
  }

isPlainObject是判断是否是一个纯对象的,如果data不是一个对象,在非生产环境下给出警告信息。

继续往下看:

// proxy data on instance
// 获取data对象的键
const keys = Object.keys(data)
// 获取props,是个对象
const props = vm.$options.props
// 获取methods,是个对象
const methods = vm.$options.methods
let i = keys.length
// 循环遍历data的键
while (i--) {
    const key = keys[i]
    // 如果methods存在,并且methods中存在与data对象相同的键,发出警告。data优先
    if (process.env.NODE_ENV !== 'production') {
      if (methods && hasOwn(methods, key)) {
        warn(
          `Method "${key}" has already been defined as a data property.`,
          vm
        )
      }
    }
    // 如果props存在,并且props中存在与data对象相同的键,发出警告。 props优先
    if (props && hasOwn(props, key)) {
      process.env.NODE_ENV !== 'production' && warn(
        `The data property "${key}" is already declared as a prop. ` +
        `Use prop default value instead.`,
        vm
      )
    } else if (!isReserved(key)) { // isReserved 函数用来检测一个字符串是否以 $ 或者 _ 开头,主要用来判断一个字段的键名是否是保留的
      proxy(vm, `_data`, key)
    }
}
// observe data
observe(data, true /* asRootData */)

while中的两个if条件判断了propsmethods中是否有和data对象相同的键,因为这三者中的属性都可以通过实例对象代理访问,如果相同就会出现冲突了。

const vm = new Vue({
    props: { a: { default: 2 } }
    data: { a: 1 },
    methods: {
        a () {
            console.log(3)
        }
    }
})

当调用vm.a的时候,就会产生覆盖现象。为了防止这种情况出现,就在这里做了判断。

再看else if中的内容,当!isReserved(key)成立时,执行proxy(vm,_data, key)isReserved函数的作用是判断一个字符串是否以 $ 或者 _ 开头, 因为Vue内部的变量是以$_开头,防止冲突。如果 key 不是以 $_ 开头,那么将执行 proxy 函数

const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}

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)
}

proxy函数通过Object.defineProperty在实例对象vm上定义了与data数据字段相同的访问器属性,代理的值是vm._data上对应的属性值。当访问this.a时,实际访问的是this._data.a的值。

最后一句代码是

// observe data
observe(data, true /* asRootData */)

调用observedata数据对象转换成响应式的。

observe工厂函数

observe函数定义在core/observer/index.js文件中, 我们找到该函数的定义,一点点来看

if (!isObject(value) || value instanceof VNode) {
    return
}

首先判断如果数据不是一个对象或者是一个VNode实例,直接返回。

let ob: Observer | void

接着定义了ob变量,它是一个Observer实例,可以看到observe函数的最后返回了ob.

下面是一个if...else分支:

if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
    }

首先是if分支,用hasOwn判断了数据对象是否包含__ob__属性,并且判断属性值是否是Observer的实例。如果条件为真的话,就把value.__ob__的值赋给ob

为什么会有这个判断呢?每个数据对象被观测后都会在该对象上定义一个__ob__属性, 所以这个判断是为了防止重复观测一个对象。

接着是else if分支,这个条件判断有点多,我们一个个来看。

  • shouldObserve必须为true
    该变量也定义在 core/observer/index.js 文件内,
/**
 * In some cases we may want to disable observation inside a component's
 * update computation.
 */
export let shouldObserve: boolean = true

export function toggleObserving (value: boolean) {
  shouldObserve = value
}

这段代码定义了shouldObserve变量,初始化为true。接着定义了toggleObserving函数,该函数接收一个参数,这个参数用来更新shouldObserve的值。shouldObserve为true时可以进行观测,为false时将不会进行观测。

  • !isServerRendering()必须为true
    isServerRendering函数用来判断是否是服务端渲染,只有当不是服务端渲染的时候才会进行观测

  • (Array.isArray(value) || isPlainObject(value)) 必须为真
    只有当数据对象是数组或者纯对象时才进行观测

  • Object.isExtensible(value)必须为true
    被观测的数据对象必须是可扩展的, 普通对象默认就是可扩展当。以下三个方法可以将对象变得不可扩展:Object.preventExtensions()Object.freeze()Object.seal()

  • !value._isVue必须为真
    Vue实例含有_isVue属性,这个判断是为了防止Vue实例被观测

以上条件满足之后,就会执行代码ob = new Observer(value),创建一个Observer实例

Observer 构造函数

Observer也定义在core/observer/index.js文件中,它是一个构造函数,用来将数据对象转换成响应式的。

export class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that have this object as root $data

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      if (hasProto) {
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  /**
   * Walk through all properties and convert them into
   * getter/setters. This method should only be called when
   * value type is Object.
   */
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }

  /**
   * Observe a list of Array items.
   */
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

以上是Observer的全部代码,现在我们从constructor开始,来看一下实例化Observer都做了什么。

__ob__ 属性

constructor开始先初始化了几个实例属性

this.value = value
this.dep = new Dep()
this.vmCount = 0
def(value, '__ob__', this)

value就是实例化Observer时传递的参数,现在将它赋给了实例对象的value属性。dep属性指向实例化的Dep实例对象,它就是用来收集依赖的容器。vmCount属性被初始化为0.

接着使用def函数为数据对象添加了__ob__属性,它的值就是当前Observer实例对象。def定义在core/util/lang.js文件中,是对Object.defineProperty的封装。

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

用def来定义__ob__属性是要把它定义成不可枚举的,这样遍历对象就不会遍历到它了。

假设我们的数据对象是

data = {
    a: 1
}

添加__ob__属性后变成

data = {
    a: 1,
    __ob__: {
        value: data, // data 数据对象本身
        dep: new Dep(), // Dep实例
        vmCount: 0
    }
}

处理纯对象

接下来是一个if...else判断, 来区分数组和对象,因为对数组和对象的处理不同。

if (Array.isArray(value)) {
    if (hasProto) {
        protoAugment(value, arrayMethods)
    } else {
        copyAugment(value, arrayMethods, arrayKeys)
    }
    this.observeArray(value)
} else {
      this.walk(value)
}

我们先来看是对象的情况,也就是执行this.walk(value)

walk函数就定义在constructor的下面

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

该方法就是用for循环遍历了对象的属性,并对每个属性都调用了defineReactive方法。

defineReactive 函数

defineReactive也定义在core/observer/index.js文件中,找到它的定义:

/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()

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

  // cater for pre-defined getter/setters
  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      ...
    },
    set: function reactiveSetter (newVal) {
      ...
    }
  })
}

因代码太长,省略了部分内容,之后我们再具体看。该函数的主要作用就是将数据对象的数据属性转换为访问器属性

函数体内首先定义了dep常量,它的值是Dep实例,用来收集对应字段的依赖。

接下来是这样一段代码:

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

先通过Object.getOwnPropertyDescriptor获取字段的属性描述对象,再判断该字段是否是可配置的,如果不可配置,直接返回。因为不可配置的属性是不能通过Object.defineProperty改变其属性定义的。

再往下接着看:

// cater for pre-defined getter/setters
const getter = property && property.get
const setter = property && property.set
if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
}

先保存属性描述对象里面的get和set方法。如果这个属性已经是访问器属性了,那它就存在get或set方法了,下面的操作会使用Object.defineProperty重写get和set方法,为了不影响原来的读写操作,就先缓存setter/getter

接下来是一个if判断,如果满足条件的话,就读取该属性的值。

再下面是这样一句代码:

let childOb = !shallow && observe(val)

因为属性值val也可能是一个对象,所以调用observe继续观测。但前面有一个条件,只有当shallow为假时才会进行深度观测。shallowdefineReactive的第五个参数,我们在walk中调用该函数时并没有传递该参数,所以这里它的值是undefined。!shallow的是true,所以这里会进行深度观测。

不进行深度观测的我们在initRender函数中见过:

defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, () => {
    !isUpdatingChildComponent && warn(`$attrs is readonly.`, vm)
}, true)
defineReactive(vm, '$listeners', options._parentListeners || emptyObject, () => {
    !isUpdatingChildComponent && warn(`$listeners is readonly.`, vm)
}, true)

在Vue实例上定义属性$attrs$listeners时就是非深度观测。

在get中收集依赖

接下来就是使用Object.defineProperty设置访问器属性,先看一下get函数:

get: function reactiveGetter () {
    const value = getter ? getter.call(obj) : val
    if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
    }
    return value
},

get函数首先是要返回属性值,还有就是在这里收集依赖。

第一行代码就是获取属性值。先判断了getter是否存在,getter就是属性原有的get函数,如果存在的话调用该函数获取属性值,否则的话就用val作为属性值。

接下来是收集依赖的代码:

if (Dep.target) {
    dep.depend()
    if (childOb) {
        childOb.dep.depend()
        if (Array.isArray(value)) {
            dependArray(value)
        }
    }
}

首先判断Dep.target是否存在,Dep.target就是要收集的依赖,如果存在的话,执行if语句块内的代码。

dep.depend()dep对象的depend方法执行就是收集依赖。

然后判断了childOb是否存在,存在的话执行childOb.dep.depend(). 那么childOb的值是谁呢?

如果我们有个数据对象:

data = {
    a: {
        b: 1
    }
}

经过observe观测之后,添加__ob__属性,变成如下模样:

data = {
    a: {
        b: 1,
        __ob__: { value, dep, vmCount }
    },
    __ob__: { value, dep, vmCount }
}

对于属性a来说,childOb === data.a.__ob__, 所以childOb.dep.depend()就是data.a.__ob__.dep.depend()

在if语句里面又一个if判断:

if (Array.isArray(value)) {
    dependArray(value)
}

如果属性值是数组,调用dependArray函数逐个触发数组元素的依赖收集

在set函数中触发依赖

set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      // #7981: for accessor properties without setter
      if (getter && !setter) return
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify()
}

set函数主要是设置属性值和触发依赖

const value = getter ? getter.call(obj) : val
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
    return
}

首先也是获取原来的属性值。为什么有这一步呢?因为要跟新值做比较,如果新旧值相等,就可以直接返回不用接下来的操作了。在if条件中,newVal === value这个我们都明白,那后面这个(newVal !== newVal && value !== value)条件是什么意思呢?

这是因为一个特殊的值NaN

NaN === NaN // false

如果newVal !== newVal,说明新值是NaN;如果value !== value,那么旧值也是NaN。那么新旧值也是相等的,也不需要处理。

/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
    customSetter()
}

在非生产环境下,如果customSetter函数存在,将执行该函数。customSetter是defineReactive的第四个参数,上面我们看initRender的时候有传过这个参数:

defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, () => {
      !isUpdatingChildComponent && warn(`$attrs is readonly.`, vm)
}, true)

第四个参数是一个箭头函数,当修改vm.$attrs时,会打印警告信息$attrs是只读的。所以customSetter的作用就是打印辅助信息。

if (getter && !setter) return
if (setter) {
    setter.call(obj, newVal)
} else {
    val = newVal
}

如果存在getter不存在setter的话,直接返回。getter和setter就是属性自身的get和set函数。

下面就是设置属性值。如果setter存在的话,调用setter函数,保证原来的属性设置操作不变。否则用新值替换旧值。

最后是这两句代码:

childOb = !shallow && observe(newVal)
dep.notify()

如果新值也是一个数组或纯对象的话,这个新值是未观测的。所以在需要深度观测的情况下,要调用observe对新值进行观测。最后调用dep.notify()触发依赖。

处理数组

看完了纯对象的处理,再来看一下数组是怎么转换为响应式的。数组有些方法会改变数组本身,我们称之为变异方法,这些方法有:push pop shift unshift reverse sort splice,如何在调用这些方法的时候触发依赖呢?看一下Vue的处理。

if (hasProto) {
    protoAugment(value, arrayMethods)
} else {
    copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value)

首先是一个if...else 判断,hasProto定义在core/util/env.js文件中。

// can we use __proto__?
export const hasProto = '__proto__' in {}

判断当前环境是否可以使用对象的 __proto__ 属性, 该属性在IE11及更高版本中才能使用。

如果条件为true的话,调用protoAugment方法, 传递了两个参数,一个是数组实例本身,一个是arrayMethods(代理原型)。

/**
 * Augment a target Object or Array by intercepting
 * the prototype chain using __proto__
 */
function protoAugment (target, src: Object) {
  /* eslint-disable no-proto */
  target.__proto__ = src
  /* eslint-enable no-proto */
}

该方法的作用就是将数组实例的原型指向代理原型。这样当数组实例调用变异方法的时候就能先走代理原型重定义的方法。我们看一下arrayMethods的实现,它定义在core/observer/array.js文件中:

import { def } from '../util/index'

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
  })
})

这是这个文件的全部内容,该文件只做了一件事,就是导出arrayMethods对象。

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

arrayMethods是以数组的原型为原型创建的对象。

const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

这是定义了数组的变异方法。

接着for循环遍历变异方法,用def在代理原型上定义了与变异方法同名的方法。

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
  })
})

首先缓存了数组原本的变异方法

const original = arrayProto[method]

然后用def在arrayMethods对象上定义了与变异方法同名的函数。函数内首先调用了original原来的函数获取结果

const result = original.apply(this, args)

并在函数末尾返回result。保证了拦截函数的功能与原来方法的功能是一致的。

const ob = this.__ob__
...
ob.dep.notify()

这两句代码就是触发依赖。当变异方法被调用时,数组本身就被改变了,所以要触发依赖。

再看其余的代码:

let inserted
switch (method) {
    case 'push':
    case 'unshift':
        inserted = args
        break
    case 'splice':
        inserted = args.slice(2)
        break
}
if (inserted) ob.observeArray(inserted)

这段代码的作用就是收集新添加的元素,将其变成响应式数据

push和unshift方法的参数就是要添加的元素,所以inserted = args。splice方法从第三个参数到最后一个参数都是要添加的新元素,所以inserted = args.slice(2)。最后,如果存在新添加的元素,调用observeArray函数对其进行观测。

以上是支持__proto__属性的时候,那不支持的时候呢?调用copyAugment方法,并传递了三个参数。前两个跟protoAugment方法的参数一样,一个是数组实例本身,一个是arrayMethods代理原型,还有一个是arrayKeys,

const arrayKeys = Object.getOwnPropertyNames(arrayMethods)

它的值就是定义在arrayMethods对象上的所有的键,也就是所要拦截的变异方法的名称。函数定义如下:

function copyAugment (target: Object, src: Object, keys: Array<string>) {
  for (let i = 0, l = keys.length; i < l; i++) {
    const key = keys[i]
    def(target, key, src[key]) 
  }
}

这个方法的作用就是在数组实例上定义与变异方法同名的函数,从而实现拦截。

if else代码之后,调用了observeArray方法this.observeArray(value), 并将数组实例作为参数。

observeArray方法的定义如下:

/**
* Observe a list of Array items.
*/
observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
        observe(items[i])
    }
}

循环遍历数组实例,并对数组的每一项再进行观测。这是因为如果数组元素是数组或纯对象的话不进行这一步数组元素就不是响应式的,这是为了实现深度观测。比如:

const vm = new Vue({
    data: {
        a: [[1,2]]
    }
})
vm.a.push(1); // 能够触发响应
vm.a[1].push(1); // 不能触发响应

所以需要递归观测数组元素。

Vue.set($set) 和 Vue.delete($delete) 的实现

我们知道,为对象或数组直接添加或删除元素Vue是拦截不到的。我们需要使用Vue.set、Vue.delete去解决,Vue还在实例对象上定义了$set $delete方便我们使用。其实不管是实例方法还是全局方法它们的指向都是一样的。我们来看以下它们的定义。

$set $delete定义在core/instance/state.js文件中的stateMixin方法中

export function stateMixin (Vue: Class<Component>) {
    ...

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

    ...
}

Vue.set和Vue.delete定义在core/global-api/index.js文件中的initGlobalAPI函数中:

export function initGlobalAPI (Vue: GlobalAPI) {
    ...

    Vue.set = set
    Vue.delete = del

    ...
}

可以看到它们的函数值是相同的。 set和del定义在core/observer/index.js文件中。我们先来看一下set的定义

set

从上到下来看set的函数体,显示这个if判断:

if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
) {
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
}
  • isUndef
export function isUndef (v: any): boolean %checks {
  return v === undefined || v === null
}

判断变量是否是未定义,或者值为null。

  • isPrimitive
export function isPrimitive (value: any): boolean %checks {
  return (
    typeof value === 'string' ||
    typeof value === 'number' ||
    // $flow-disable-line
    typeof value === 'symbol' ||
    typeof value === 'boolean'
  )
}

判断变量是否是原始类型。

所以这个if语句的作用就是,如果target是undefined或者null或者它的类型是原始类型,在非生产环境下打印警告信息

再看下一个if语句:

if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
}
  • isValidArrayIndex
export function isValidArrayIndex (val: any): boolean {
  const n = parseFloat(String(val))
  return n >= 0 && Math.floor(n) === n && isFinite(val)
}

判断变量是否是有效的数组索引。

如果target是一个数组,并且key是一个有效的数组索引,就执行if语句块内的代码

我们知道splice变异方法是可以触发响应的,target.splice(key, 1, val) 就利用了替换元素的能力,将指定位置元素的值替换为新值。所以数组就是利用splice添加元素的。另外,当要设置的元素的索引大于数组长度时 splice 无效,所以target的length取两者中的最大值。

if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
}

这个if条件的意思是该属性已经在target对象上有定义了,那么只要重新设置它的值就行了。因为在纯对象中,已经存在的属性就是响应式的了。

const ob = (target: any).__ob__
if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
}
  • target._isVue
    拥有_isVue属性说明这是一个Vue实例‘
  • (ob && ob.vmCount)
    ob就是target.__ob__,ob.vmCount也就是target.__ob__.vmCount。来看一下这段代码:
export function observe (value: any, asRootData: ?boolean): Observer | void {
    if (asRootData && ob) {
        ob.vmCount++
    }
}

asRootData表示是否是根数据对象。什么是根数据对象呢?看一下哪里调用observe函数的时候传递了第二个参数:

function initData (vm: Component) {
    ...

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

在initData中调用observe的时候传递了第二个参数为true,那根数据对象也就是data。也就是说当使用 Vue.set/$set 函数为根数据对象添加属性时,是不被允许的。

所以当target是Vue实例或者是根数据对象时,在非生产环境会打印警告信息。

if (!ob) {
    target[key] = val
    return val
}

!ob为true时,说明不存在__ob__属性,那target也就不是响应式的,直接变更属性值就行。

defineReactive(ob.value, key, val)
ob.dep.notify()

这里就是给对象添加新的属性,并保证新添加的属性是响应式的。ob.dep.notify()触发响应。

del

看完了set,再来看delete操作。

if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot delete reactive property on undefined, null, or primitive value: ${(target: any)}`)
}

这个if判断跟set函数的一样。如果target是undefined、null或者原始类型值,在非生产环境下打印警告信息。

if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.splice(key, 1)
    return
}

当target是数组类型并且key是有效的数组索引值时,也是使用splice来进行删除操作,因为该变异方法可以触发拦截操作。

const ob = (target: any).__ob__
if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid deleting properties on a Vue instance or its root $data ' +
      '- just set it to null.'
    )
    return
}

这一段if判断也是一样的,如果target是Vue实例或者是根数据对象,在非生产环境下打印警告信息。也就是不能删除Vue实例对象的属性,也不能删除根数据对象的属性,因为data本身不是响应式的。

if (!hasOwn(target, key)) {
    return
}

如果target对象上没有key属性,直接返回。

delete target[key]

进行到这里就说明target是一个纯对象,并且有key属性,直接删除该属性。

if (!ob) {
    return
}

如果ob对象不存在,说明target不是响应式的,直接返回。

ob.dep.notify()

如果ob对象存在,说明target是响应式的,触发响应。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容