vue2源码分析-响应式原理

lxf2023-04-18 12:26:02

我报名参加1期挑战——瓜分10万奖池,这是我的第3篇文章,点击查看活动详情

简介

最近看了一些关于vue2响应式相关的面试文章,大多数文章对于对象的响应式没什么争议,都是使用Object.defineProperty对数据进行劫持。但是到数组的响应式就有很多个版本了。有的说数组不会对数组元素进行劫持,有的说数组也是使用Object.defineProperty进行劫持的,还有的说数组只重写了原型上的七个方法

带着这些疑问,我们通过阅读源码的方式来一探究竟。

本文vue版本为2.6.14

initData

上次讲到vue2源码分析-data、props、methods、computed属性可以重名吗中就有提及到initData方法,这是vuedata进行响应式的入口。

// src/core/instance/state.js

function initData (vm: Component) {
  // 获取我们定义的data
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
    
  // ...

  // 进行响应式
  observe(data, true /* asRootData */)
}

我们再来看看observe方法。

observe

observe它的核心是new Observer()

// src/core/observer/index.js

export function observe (value: any, asRootData: ?boolean): Observer | void {

  // 这层拦截很重要,因为后面递归会频繁直接调用该方法。
  // 不是对象或虚拟DOM则直接return
  if (!isObject(value) || value instanceof VNode) {
    return
  }

  let ob: Observer | void
  // 监听过就会有__ob__属性,直接获取
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  // 没监听过,new一个Observer对象
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  
  // 会返回ob对象
  return ob
}

isObject

我们来看看observe方法里面用的到isObject方法的实现。用的是typeof,并排除了特殊的null

// src/shared/util.js

export function isObject (obj: mixed): boolean %checks {
  return obj !== null && typeof obj === 'object'
}

接下来,我们再看看Observer

Observer

这是响应式非常重要的一个类,观察者类

// src/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()
    // 定义一个__ob__属性,所以经过vue监听的对象都会有这个属性
    def(value, '__ob__', this)
    // ...
    // 如果是数组,根data肯定不是数组,所以不会进来
    // 递归的时候,如果根data有属性值是数组,则会进来
    if (Array.isArray(value)) {
      // hasProto 其实就是判断浏览器支不支持原型
      // 支持原型,就直接修改原型。不支持就遍历赋值。
      // arrayMethods是修改后的Array的prototype方法
      if (hasProto) {
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      // 对数组每一项进行监听
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  // 遍历对象,进行响应式
  walk (obj: Object) {
    // 这里用到了Object.keys来遍历,所以不会获取到原型上的属性、不会获取到不可枚举属性、不会获取到symbol属性
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }

  // 对数组每一项进行 observe
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

其实看到这,看到observeArray方法,对于文章开始提出的疑问,我们就有了答案了。对于数组,里面的每一项是会进行响应式的。这也就是为什么我们通过下标修改数组中某一对象的属性值,页面是会响应式更新的原因。

注意:对于数组本身,并没有像对象一样,使用Object.defineProperty对数组和下标进行劫持。

const arr = [1, 2, 3]
// 其实数组也是可以通过遍历,然后使用Object.defineProperty进行劫持的
arr.forEach(((value, index)) => {
  Object.defineProperty(arr, index, {get(){}, set(){}})
})

至于为什么没有这样做,笔者想的是,一般一个对象的属性个数不会太多,所以递归遍历劫持没什么太大问题。但是对于数组,里面的元素可能达到几百上千,如果去遍历劫持的话会导性能变低,收益不是很大。

因为没有对数组使用Object.defineProperty进行劫持,这也就导致了我们直接通过下标直接修改数组元素是没法达到响应式的。

hasProto、protoAugment、copyAugment

为了更好的理解,我把hasProto、protoAugment、copyAugment的定义贴出来。

// src/core/util/env.js

// 判断支不支持原型
export const hasProto = '__proto__' in {}
// src/core/observer/index.js

// 修改原型,一步到位
function protoAugment (target, src: Object) {
  target.__proto__ = src
}

// 遍历赋值
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])
  }
}

arrayMethods

我们来重点看看arrayMethods,这是数组响应式的另外一个核心。

// src/core/observer/array.js

// 拿到数组的原型的浅拷贝,然后进行修改
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

// 只修改了这七个方法
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

// 遍历七个方法
methodsToPatch.forEach(function (method) {
  // 缓存原始方法
  const original = arrayProto[method]
  // 重新定义 arrayMethods 这七个方法
  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)
    // 不管调用哪个方法都会派发更新
    ob.dep.notify()
    // 返回值
    return result
  })
})

可以看到,除了对数组里面的每一项进行响应式监听外,还会对数组的push、pop、shift、unshift、sort、reverse、splice七个方法进行重写。

这里有两个重点:

第一是对涉及到新增的方法push、unshift、splice进行了特殊处理,取到新添加的元素进行响应式监听。

第二在最后,不管调用了这七个中的哪个方法,都会调用 ob.dep.notify() 去通知 watcher 数据发生了改变。所以这也是为什么数组使用这七个方法页面能响应式更新的原因。

def

为了方便理解我们来看看上面用到的def的定义。

// src/core/util/lang.js

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

接下来我们看看walk方法里面调用的defineReactive方法

defineReactive

defineReactive里面主要是对属性get、set的劫持。

// src/core/observer/index.js

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()

  // ...

  // 获取原始get set方法
  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 () {
      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
    },
    set: function reactiveSetter (newVal) {
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      // 这里很重要,如果不是浅监听,对新赋的值也会进行监听
      // 这也是为什么我们给某属性赋值对象,对象的修改页面会响应式更新
      childOb = !shallow && observe(newVal)
      // 派发更新
      dep.notify()
    }
  })
}

这个方法里面有三点非常重要:

第一就是对对象属性的劫持,也就是重写get、set方法。

第二是对属性值的递归处理,如果值是对象则又会进行劫持。

第三是对新赋值的属性值也会进行劫持。

今天我们只看到对get、set的劫持即可。对于依赖收集和派发更新笔者后面会再出文章详细说明。

总结

对于对象会遍历它所有的属性,然后使用Object.defineProperty重写get、set方法,对对象的每个属性进行劫持。如果属性值还是对象,则会进行递归。

由于vue2使用Object.defineProperty方法,会重写get、set方法,提前将数据进行劫持。这也就导致了在后面给对象添加新属性和直接删除属性是(删除不会触发set方法)不能响应式

对于数组,也会遍历它所有的元素,然后使用Object.defineProperty方法对每个元素进行劫持。然后还会重写数组原型上push、pop、shift、unshift、sort、reverse、splice七个方法。

对于数组本身,并没有像对象一样,使用Object.defineProperty对自身和下标重写get、set方法。这也就导致了我们直接通过下标赋新值或直接删除值是不能响应式

注意,对于数组我们不要以为通过下标修改数据就一定不能响应式,这里要格外注意:

如果数组元素是引用数据类型,恰巧只需要修改该引用数据类型某属性,是可以直接通过下标更改的。因为前面说了,虽然数组并没有像对象一样,使用Object.defineProperty对自身和下标重写get、set方法,但是它会遍历它所有的元素,然后使用Object.defineProperty方法对每个元素进行劫持。所以我们更改某引用数据类型某属性是可以响应式的。

{
  data() {
    return {
      arr: [123, {name: 'randy'}]
    }
  },
  
  methods: {
    updateArr(){
      this.arr[1]['name'] = 'demi' // 这样是可以响应式的
    }
  }
}

扩展

上面说了,对象不能直接添加、删除属性,数组不能通过下标操作值,如果在开发过程中,真的有这样的需求那该怎么处理呢?

其实很简单,我们来看看vue给我们提供的$set/$delete

$set原理分析

上面我们分析了,对于对象和数组新添加的属性是没法响应式的。所以vue给我们提供了Vue.set()this.$set()方法来添加新属性/元素。

下面我们来分析下set方法的原理。

export function set (target: Array<any> | Object, key: any, val: any): any {
  // 开发环境下如果是null、undefined或者基本数据类型会抛出警告
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }
  
  // 如果是数组,并且下标是有效的
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    // 修改数组长度,取下标和长度较大值
    target.length = Math.max(target.length, key)
    // 直接通过splice进行修改,前面分析了splice方法被我们重写了,所以
    // 直接调用该方法即可,该方法里会直接派发更新
    target.splice(key, 1, val)
    return val
  }
  
  // 如果是对象
  // 如果 key 存在与 target中,我们直接修改就可以了
  // 如果是响应式对象,修改后会自动响应式更新。因为前面劫持过了
  // 如果是普通对象只需要修改即可
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  
  // 获取 target 的 __ob__ 属性,前面分析到经过数据劫持的对象都会有__ob__属性
  const ob = (target: any).__ob__
  // 避免向 Vue 实例或者$data根数据对象上使用 $set
  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 不是响应式的话,说明只是一个普通对象
  // 只需要使用 key 和 val 设置新值就行了
  if (!ob) {
    target[key] = val
    return val
  }
  
  // 到这里说明这个对象被劫持过,但是属性又是个新属性
  // 所以我们使用前面的 defineReactive 将新键值劫持
  defineReactive(ob.value, key, val)
  // 然后派发更新即可
  ob.dep.notify()
  return val
}

总结:

  1. 判断是不是null、undefined、基本数据类型,如果是则抛出警告直接返回。
  2. 判断是不是数组,是数组直接调用splice方法,因为该方法已被重写,所以能直接派发更新。
  3. 判断key是否已经在于对象上。如果存在则设置新值即可。这里要分两种情况,一种是该对象是普通对象,我们直接修改即可。第二种该对象是一个响应式对象,因为前面已经劫持过,所以我们设置新值的时候会自动派发更新。
  4. 判断对象是不是响应式对象,再判断是不是vue实例或者根data,如果是则抛出警告直接返回。
  5. 如果既不是响应式对象,key又不存在于对象,就说明是一个普通对象。我们给该对象新属性赋新值即可。
  6. 如果是响应式对象,key又不存在于对象,我们就会使用defineReactive对新属性进行劫持。然后派发更新。

$delete原理分析

上面我们分析了,对于对象和数组属性的删除也是没法响应式的。所以vue给我们提供了Vue.delete()this.$delete()方法来删除属性/元素。

export function del (target: Array<any> | Object, key: any) {
  // 开发环境下如果是null、undefined或者基本数据类型会抛出警告
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot delete reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }
  
  // 如果是数组,并且下标是有效的
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    // 直接通过splice进行修改,前面分析了splice方法被我们重写了,所以
    // 直接调用该方法即可,该方法里会直接派发更新
    target.splice(key, 1)
    return
  }
  
  // 获取 target 的 __ob__ 属性,前面分析到经过数据劫持的对象都会有__ob__属性
  const ob = (target: any).__ob__
  // 避免向 Vue 实例或者$data根数据对象上使用 $delete
  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 (!hasOwn(target, key)) {
    return
  }
  
  // 否则删除该属性
  delete target[key]
  
  // 不是响应式对象直接返回就可以了
  if (!ob) {
    return
  }
  
  // 否则是响应式对象,再派发更新
  ob.dep.notify()
}

总结:

  1. 判断是不是null、undefined、基本数据类型,如果是则抛出警告直接返回。
  2. 判断是不是数组,是数组直接调用splice方法,因为该方法已被重写,所以能直接派发更新。
  3. 判断是不是vue实例或者根data,如果是则抛出警告直接返回。
  4. 判断该对象是否有该属性,没有直接返回。
  5. 删除该属性。
  6. 该对象不是响应式对象直接返回。
  7. 该对象是响应式对象则进行派发更新。

系列文章

vue2源码分析-data、props、methods、computed属性可以重名吗

vue2源码分析-响应式原理

vue2源码分析-依赖收集

vue2源码分析-派发更新

vue2源码分析-VNode和diff算法

vue2源码分析-keep-alive组件

浅谈Vue-Router原理

浅谈Vuex原理

后记

感谢小伙伴们的耐心观看,本文为笔者个人学习笔记,如有谬误,还请告知,万分感谢!如果本文对你有所帮助,还请点个关注点个赞~,您的支持是笔者不断更新的动力!