浅谈Vue2.0响应式原理

Vue中,响应式是指数据与视图之间建立一种自动关联的关系,当数据发生变化时,视图会自动更新以反应数据的变化,无需开发者手动操作DOM元素更新视图。

响应式是实现数据驱动视图的基础,在Vue中,数据可以理解为状态state,视图就是用户直观看到的页面UI。页面会随着状态的改变而动态变化,因此可以得到以下公式:

UI = render(state)

上述公式中:状态state是输入,页面UI输出,状态输入一旦变化了,页面输出也随之而变化。这种特性就叫数据驱动视图

公式可以进一步拆成三部分:staterender()以及UI。其中stateUI都是用户定的或开发者实现的,而不变的是render()函数。所以Vue就扮演了render()这个渲染的角色,当Vue检测到state变化之后,经过一系列加工,最终将变化响应式地反应在UI界面上。

实现数据的响应式需要解决以下三个问题:

  1. 如何对状态state的变化进行监听?
  2. 如何确定状态state改变之后要更新的视图?
  3. 更新视图的时机是什么时候?

1. 状态state的变化监听

Vue2中,数据监听是借助了javaScript提供的Object.defineProperty()函数实现的。

1. Object.defineProperty()

Object.defineProperty()静态方法用于在一个对象上定义一个新属性或修改该对象的现有属性。

Object.defineProperty(obj, prop, descriptor)

其中, descriptor表示要定义的属性描述符object,存在两种类型:数据描述符访问器描述符

注意: 描述符只能是这两种类型之一,且两者不能混合使用。

  • 数据描述符是一个具有可写或不可写值的属性,对象支持四种键值:valueenumerablewritableconfigurable

    const person = {}
    Object.defineProperty(person, 'age', {
      value: 18, // 定义属性值,默认undefined
      enumerable: true, // 属性可枚举, 默认false
      writable: true, // 属性值可修改, 默认false
      configurable: true, // 属性可删除,默认false
    })
    
    console.log(person.age) // > 18
    person.age = 19
    console.log(person.age) // > 19
    
  • 访问器描述符是由 getter/setter 函数对描述的属性,支持四种键值:enumerableconfigurablegetset

    const person = {}
    let age = 18
    Object.defineProperty(person, 'age', {
      enumerable: true,
      configurable: true,
      get() {
        console.log(`有人读取了person的年龄,当前年龄${age}`)
        return age
      },
      set(value) {
        console.log(`有人修改的person的年龄,新的年龄是${value}`)
        age = value
      },
    })
    
    person.age
    person.age = 19
    age
    

输出结果:

1

2. Vue2.0中Object的变化监听

Vue2.0就是利用了Object.defineProperty方法中的访问器描述符来劫持数据的读写操作。在getter中捕获数据的读取事件,在setter中捕获数据的修改事件,进而对数据的变化进行监听。

对于Object类型的数据,Vue通过递归遍历的方式将数据中的每一个属性设置为getter/setter的形式,使对象的每一个属性都变得可观测

接下来是对Object类型数据监听的简单实现

  • 模拟一个更新视图的函数

    /**
     * @description: 更新视图函数
     */
    function updateView() {
      console.log('收到通知,我去更新视图了')
    }
    
  • 定义Observer

    vue中,所有的响应式数据都是Observer类的实例对象。

    /**
     * @description: 定义Observer类,把一个对象的所有属性转化成可观测对象
     * @return {*}
     */
    class Observer {
      constructor(value) {
        this.value = value
        // 给value新增一个__ob__属性,值为该value的Observer实例
        // 相当于为value打上标记,表示它已经被转化成响应式了,避免重复操作
        Object.defineProperty(value, '__ob__', {
          value: this, 
          enumerable: false,
          writable: true,
          configurable: true,
        })
        this.walk(value)
      }
      walk(value) {
        const keys = Object.keys(value)
        for (let i = 0; i < keys.length; i++) {
          defineReactive(value, keys[i]) // 遍历所有的属性,将所有属性值转为getter/setter形式
        }
      }
    }
    
  • 定义响应式函数

    // 源码位置:/src/core/observer/index.ts
    /**
     * @description: 给对象的属性递归定义响应式,设置getter/setter,使对象属性的读取事件可监听
     */
    function defineReactive(obj, key, value) {
      if (arguments.length === 2) {
        value = obj[key]
      }
      if (   // 如果value值为object或array,递归调用observer函数实现value的响应式
        Object.prototype.toString.call(value) === '[object Object]' ||
        Array.isArray(value)
      ) {
        let childObj = observer(value) // 递归调用监听函数对子属性进行监听
      }
      Object.defineProperty(obj, key, {
        configurable: true,
        enumerable: true,
        get() {
          console.log(`有人读取了${key}属性,属性值为${value}`) // 捕获数据读取事件
          return value
        },
        set(newValue) {
          if (newValue !== value) {
            console.log( 
              `有人修改了${key}属性,修改后的值为${newValue},我要发更新视图的通知!`
            ) // 捕获数据修改事件
            childObj = observer(newValue) // 更新的value值也要监听
            value = newValue
            updateView() // 通知更新视图
          }
        },
      })
    }
    
  • 定义监听函数

    /**
     * @description: 监听函数
     */
    function observer(value) {
      if ( // 是对象或数组类型的数据才需要监听
        Object.prototype.toString.call(value) === '[object Object]' ||
        Array.isArray(value)
      ) {
        if (typeof value.__ob__ !== 'undefined') { // value已经是响应式数据,直接返回observer实例
          return value.__ob__
        }
        return new Observer(value) // 以value为参数构造observer实例并返回
      }
    }
    

验证实现:

const data = {
  name: 'zhangsan',
  age: 19,
  address: {
    city: 'beijing',
    country: 'China',
  },
}
const ob = observer(data)
console.log(ob)

输出结果:

浅谈Vue2.0响应式原理_第1张图片

之后对data中的数据进行一些操作:

const data = {
  name: 'zhangsan',
  age: 19,
  address: {
    city: 'beijing',
    country: 'China',
  },
}

data.name
data.name = 'lisi'
data.address.city
data.address.city = 'shanghai'

输出结果:

浅谈Vue2.0响应式原理_第2张图片

可以看到,我们捕获了读取对象属性和修改对象属性值的事件。对于嵌套的对象,也可以实现被嵌套对象属性值的读取与修改监听。

⚠️值得注意的是,被嵌套对象内部属性值的修改,外层对象是感应不到的,这就是为什么在监听函数watch中,想要实现深度监听必须使用deep的原因了。

过程总结:

对于一个数据,首先调用observer函数实现监听,如果传入的数据是未被监听过的对象,则new一个可被监听的Observe实例对象并返回,否则,返回该数据所对应的observer实例对象。

Observer类的constructor内,给传入的对象新增一个__ob__属性,值为该对象的observer实例对象,作用是给该对象打上标记,表明这个对象已经被转化成响应式了,避免重复的操作。

之后调用walk函数将每一个属性转换成getter/setter的形式来监听数据的变化。

最后,在defineReactive函数中,当传入的属性值还是一个object时,递归使用observer函数来监听该对象的嵌套对象,这样就可以把data中的所有属性(包括深层属性)都转换成getter/seter的形式来深度监听对象数据的变化。

3. Vue2.0中Array的变化监听

Array本身也是一个对象,也支持使用defineProperty设置getter/setter对元素进行响应式监听,但使用defineProperty会出现哪些问题呢?

举个栗子:

定义data的属性hobbies值为数组['swimming', 'football'],当数组最前面插入一个元素running,看看发生了什么?

const data = {
  name: 'zhangsan',
  age: 19,
  address: {
    city: 'beijing',
    country: 'China',
  },
  hobbies: ['swimming', 'football'],
}
observer(data)
data.hobbies.unshift('running')
console.log(data.hobbies)

输出结果:

浅谈Vue2.0响应式原理_第3张图片

首先,当在数组的第一位插入一个元素后,触发了两次视图更新,这是因为在对hobbies数组进行监听时,将数组的0 ,1两个key分别进行了监听,所以当数组最前面插入了一个值以后,原来索引对应的值全部发生了改变,所以就触发了两次视图更新。尽管Vue采用了异步更新策略,这两次更新会被合并,影响不大。但如果数组的数据量非常大或数组的元素是深层对象的话,性能损耗将会是巨大的。

其次,我们可以看到,更新后的hobbies的第三个元素football并非是响应式的,这是因为数组在调用observer函数实现响应式监听的时候只有01两个索引,因此只把这两个key转化成了getter/setter形式进行监听,所以后续数组无论增加多少元素,都不再是响应式的了。

因此,如果使用defineProperty对数组元素进行监听的话,是监听不到新增元素变化的,许多操作数组的API也无从使用,而且还会触发多次视图更新,这并不是一个好的策略。

︎ 插一嘴,这个时候如果通过索引修改元素值的话,其实是可以监听到的。

const data = {
  name: 'zhangsan',
  age: 19,
  address: {
    city: 'beijing',
    country: 'China',
  },
  hobbies: ['swimming', 'football'],
}
observer(data)
data.hobbies[0] = 'running'
console.log(data.hobbies)

输出结果:

浅谈Vue2.0响应式原理_第4张图片
Vue2.0中,作者采用了重写原地修改数组的七种方法实现数组数据的监听。

1. 数组方法拦截器

Vue2.0中,定义了一个数组方法拦截器,拦截在数组实例与Array.property之间,并在拦截器内部重写了操作数组的一些方法,当数组实例使用操作数组方法时,使用的就是拦截器中重写的方法,而不再使用Array.prototype上的原生方法。

浅谈Vue2.0响应式原理_第5张图片

// 源码位置:/src/core/observer/array.ts
const arrayProto = Array.prototype
// 创建一个对象作为拦截器
 export const arrayMethods = Object.create(arrayProto)
// 改变数组自身内容的7个方法
const methodsToPatch = [
	'push',
	'pop',
	'shift',
	'unshift',
	'splice',
	'sort',
	'reverse',
]

/**
 * @description: 在arrayMethods拦截器对象上定义并重写7种方法
 * @return {*}
 */
methodsToPatch.forEach((method) => {
  const original = arrayProto[method] // 缓存原生方法
  Object.defineProperty(arrayMethods, method, {
    enumerable: false,
    writable: true,
    configurable: true,
    value: 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) // 调用observe函数将新增的元素转化成响应式
      console.log(
        `有人调用了重写的${method}方法改变数组数据,可以通知更新视图了`
      )
      updateView()
      return result
    },
  })
})

const array = [1, 2]
Object.setPrototypeOf(array, arrayMethods)
array.push(3)
console.log(array)

输出结果:

浅谈Vue2.0响应式原理_第6张图片
2. 使用拦截器

Observer类中判断value的类型,如果是数组,将value__proto__指针指向拦截器,同时调用observeArray()函数对value进行监听

/**
 * @description: 定义Observer类,把一个对象的所有属性转化成可观测对象
 * @return {*}
 */
class Observer {
  constructor(value) {
    // ...
    if (Array.isArray(value)) {
      // setPrototypeOf是es6新增的语法
      // 还可以判断浏览器是否支持__proto__语法,支持则value.__proto__ = arrayMethods
      // 否则,遍历arrayMethods中的方法,逐一添加到value对象本身
      Object.setPrototypeOf(value, arrayMethods)
      this.observeArray(value) // 将数组中的所有元素都转化为可被侦测的响应式
    } else {
      this.walk(value)
    }
  }
  observeArray(value) {
    for (let i = 0, l = value.length; i < l; i++) {
      observer(value[i])
    }
  }
}

const data = {
  name: 'zhangsan',
  age: 19,
  address: {
    city: 'beijing',
    country: 'China',
  },
  hobbies: ['swimming', 'football'],
}
observer(data)

data.hobbies.unshift('running')
console.log('-----------------------------------------------------')
console.log(data.hobbies)
console.log('-----------------------------------------------------')
data.hobbies[0] = 'drawing'
console.log('-----------------------------------------------------')
console.log(data.hobbies)

输出结果:

浅谈Vue2.0响应式原理_第7张图片

observer函数实现了对数组七种操作方法的监听。此时,通过索引修改数组元素就无法被监听到啦,可以使用splice替代。

到此,我们已经实现了对象和数组的数据监听,解决了响应式的第一个问题:

  • 如何对state的变化进行监听?

那么监听到了状态的变化之后,vue怎么知道要更新哪些视图呢?

2. 依赖的收集与触发

vue组件在渲染的过程中,会调用数据的getter函数来读取数据并渲染在页面上,那么就可以在数据被读取的时候,将调用者收集起来,等到数据发生变化的时候,即setter函数被触发的时候,通知所有的调用者去更新视图,就实现了数据的响应式更新。

我们称数据的调用者为数据的依赖项。

核心在于:getter中收集依赖,在setter中触发依赖

浅谈Vue2.0响应式原理_第8张图片
在模板编译的过程中,会为每一个调用state的元素实例化一个watcher实例;

这个watcher实例在构造的过程中首先把自己设置到全局唯一的指定位置window.target,然后读取依赖的数据。读取数据就触发了数据的getter,在getter函数中会读取window.target的值获取到当前数据的依赖项watcher,之后调用dep.depend()把这个watcher收集到依赖收集器dep.subs中去就完成了依赖的收集;

当数据发生变化时会调用数据的setter函数,在setter会调用dep.notify()向收集器dep.subs中的每个watcher实例发送更新通知, 每个watcher实例收到通知后就会调用自身的update函数就行视图的更新,这样就完成了依赖的触发,进而实现了数据的响应式。

1. 定义Dep类

// 源码位置:src/core/observer/dep.js
/**
 * @description: 定义Dep类
 * @return {*}
 */
class Dep {
  constructor() {
    this.subs = [] //内部存放所有观测该依赖数据的watcher实例
  }
  /**
   * @description: 通知更新函数
   * @return {*}
   */
  notify() {
    this.subs.forEach((sub) => {
      sub.update() //watcher实例身上的update方法
    })
  }
  /**
   * @description: 依赖收集函数
   * @return {*}
   */
  depend() {
    if (window.target) {
      //watcher在调用依赖项getter时会把自身放在window.target上,供依赖收集器获取,读取完之后删掉
      this.addSub(window.target)
    }
  }
  /**
   * @description: subs数组中添加一个依赖
   * @param {*} sub 一个watcher实例
   * @return {*}
   */
  addSub(sub) {
    this.subs.push(sub)
  }
  /**
   * @description: subs数组中移除一个依赖
   * @param {*} sub 一个watcher实例
   * @return {*}
   */
  removeSub(sub) {
    if (this.subs.length) {
      const index = this.subs.indexOf(sub)
      if (index > -1) {
        return this.subs.splice(index, 1)
      }
    }
  }
}

2. 定义Watcher类

// 源码位置:src/core/observer/watcher.js
/**
 * @description: 定义Watcher类
 * @return {*}
 */
class Watcher {
  constructor(vm, expOrFn, cb) {
    This.vm = vm
    this.cb = cb
    this.getter = parsePath(expOrFn) // 根据表达式获取对象属性值的函数
    this.value = this.get() // 构造的时候直接调用执行
  }
  /**
   * @description: watcher实例的数据获取
   * @return {*}
   */
  get() {
    window.target = this // 先将自身挂载到window.target上
    let value = this.getter.call(this.vm) // 读取依赖数据,触发依赖收集
    window.target = null // 依赖收集完毕之后window.target置空
    return value // 获取到的数据的值,用于页面渲染
  }
  /**
   * @description: watcher实例的视图更新
   * @return {*}
   */
  update() {
    const oldValue = this.value
    this.value = this.get()
    this.cb.call(this.vm, this.value, oldValue) // 新旧虚拟dom对比或用户定义的回调函数等
  }
}

/**
 * Parse simple path.
 * 把一个形如'data.a.b.c'的字符串路径所表示的值,从真实的data对象中取出来
 * 例如:
 * data = {a:{b:{c:2}}}
 * parsePath('a.b.c')(data)  // 2
 */
function parsePath(path) {
  const segments = path.split('.')
  return function (obj) {
    for (let i = 0, len = segments.length; i < len; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}

3. 收集&触发依赖

/**
 * @description: 给对象的属性定义响应式,是对象属性的读取事件可监听
 */
function defineReactive(obj, key, value) {
  // ...
  let childOb = observer(value)
  const dep = new Dep() // 实例化依赖收集器
  Object.defineProperty(obj, key, {
    get() {
      dep.depend() // 收集依赖
      if(childOb){ // 当前值的子元素如果是响应式的也需要收集依赖
        childOb.dep.depend()
      }
      return value
    },
    set(newValue) {
      if (newValue !== value) {
        chileOb = observer(newValue) // 更新的value值也要监听
        value = newValue
        dep.notify() // 触发依赖,通知更新视图
        if (childOb) { // 当前值的子元素如果是响应式的也需要通知更新
          childOb.dep.notify()
        }
      }
    },
  })
  return dep
}

到此,我们已经解决了响应式实现的第二个问题:

  • 如何确定state改变之后要更新的视图?

3. 异步更新队列

Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一个事件循环中发生的所有数据变更。

如果在同一个eventLoop中同一个 watcher 被多次触发,只会被推入到队列中一次。

然后,在下一个的事件循环tick中,Vue 刷新队列并执行实际 (已去重的) 工作。

Vue在内部对异步队列尝试使用原生的 Promise.then()MutationObserver()setImmediate(),如果执行环境不支持,则会采用 setTimeout() 代替。

如果想基于更新后的DOM进行操作,则应该使用Vue.nextTick()函数来进行。该函数的回调函数会在页面刷新后调用。

第三个问题也解决了:

  • 更新视图的时机是什么时候?

以上就是数据驱动视图的全过程啦!

4. 响应式修改数据的方法

由于Vue2.0采用的是javaScript提供的Object.defineProperty来实现数据的监听

存在无法监测以下变化的缺点:

  • 对象:
    1. 添加新的key/value键值对
    2. 删除已有的key/value键值对
  • 数组:
    1. 通过索引修改数组元素值
    2. 修改数组长度

解决办法:

  • 对象

    1. Vue.set(object, key, value)vm.$set(object, key, value) 添加和修改属性
    2. Vue.delete(object, key)vm.$delete(object, key) 删除属性
    3. object = Object.assign({}, newObject)更新整个对象
  • 数组

    1. array.splice(index, 1, value)
    2. Vue.set(array, index, value)vm.$set(array, index, value)
    3. array = newArray 更新整个数组

Vue3.0采用了ES6新增的Proxy进行数据监听,不存在以上问题。

5. 总结

Vue2.0采用了数据劫持和消息发布订阅模式实现了数据的响应式。

当一个Vue实例创建时,Vue会遍历data中的数据,用 Object.defineProperty将它们转为 getter/setter,并且在内部追踪相关依赖(消息订阅),捕获数据的访问事件并收集依赖,捕获数据的修改事件并触发依赖

每个组件实例都有相对应的 watcher实例,它会在组件渲染的过程中把属性记录为依赖,当依赖数据的setter被调用时,会通知watcher更新页面(消息发布),进而实现“数据驱动视图”。
浅谈Vue2.0响应式原理_第9张图片

你可能感兴趣的:(ZhXIn的前端学习笔记,前端,vue)