深刻浅出基于“依赖收集”的响应式原理

写于 2017.09.13javascript

每当问到VueJS响应式原理,你们可能都会脱口而出“Vue经过Object.defineProperty方法把data对象的所有属性转化成getter/setter,当属性被访问或修改时通知变化”。然而,其内部深层的响应式原理可能不少人都没有彻底理解,网络上关于其响应式原理的文章质量也是良莠不齐,大可能是贴个代码加段注释了事。本文将会从一个很是简单的例子出发,一步一步分析响应式原理的具体实现思路。java

1、使数据对象变得“可观测”

首先,咱们定义一个数据对象,就以王者荣耀里面的其中一个英雄为例子:数组

const hero = {
  health: 3000,
  IQ: 150
}
复制代码

咱们定义了这个英雄的生命值为3000,IQ为150。可是如今还不知道他是谁,不过这不重要,只须要知道这个英雄将会贯穿咱们整篇文章,而咱们的目的就是经过这个英雄的属性,知道这个英雄是谁。浏览器

如今咱们能够经过hero.healthhero.IQ直接读写这个英雄对应的属性值。可是,当这个英雄的属性被读取或修改时,咱们并不知情。那么应该如何作才可以让英雄主动告诉咱们,他的属性被修改了呢?这时候就须要借助Object.defineProperty的力量了。bash

关于Object.defineProperty的介绍,MDN上是这么说的:网络

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。模块化

在本文中,咱们只使用这个方法使对象变得“可观测”,更多关于这个方法的具体内容,请参考https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty,就再也不赘述了。函数

那么如何让这个英雄主动通知咱们其属性的读写状况呢?首先改写一下上面的例子:学习

let hero = {}
let val = 3000
Object.defineProperty(hero, 'health', {
  get () {
    console.log('个人health属性被读取了!')
    return val
  },
  set (newVal) {
    console.log('个人health属性被修改了!')
    val = newVal
  }
})
复制代码

咱们经过Object.defineProperty方法,给hero定义了一个health属性,这个属性在被读写的时候都会触发一段console.log。如今来尝试一下:优化

console.log(hero.health)

// -> 3000
// -> 个人health属性被读取了!

hero.health = 5000
// -> 个人health属性被修改了
复制代码

能够看到,英雄已经能够主动告诉咱们其属性的读写状况了,这也意味着,这个英雄的数据对象已是“可观测”的了。为了把英雄的全部属性都变得可观测,咱们能够想一个办法:

/** * 使一个对象转化成可观测对象 * @param { Object } obj 对象 * @param { String } key 对象的key * @param { Any } val 对象的某个key的值 */
function defineReactive (obj, key, val) {
  Object.defineProperty(obj, key, {
    get () {
      // 触发getter
      console.log(`个人${key}属性被读取了!`)
      return val
    },
    set (newVal) {
      // 触发setter
      console.log(`个人${key}属性被修改了!`)
      val = newVal
    }
  })
}

/** * 把一个对象的每一项都转化成可观测对象 * @param { Object } obj 对象 */
function observable (obj) {
  const keys = Object.keys(obj)
  keys.forEach((key) => {
    defineReactive(obj, key, obj[key])
  })
  return obj
}
复制代码

如今咱们能够把英雄这么定义:

const hero = observable({
  health: 3000,
  IQ: 150
})
复制代码

读者们能够在控制台自行尝试读写英雄的属性,看看它是否是已经变得可观测的。

2、计算属性

如今,英雄已经变得可观测,任何的读写操做他都会主动告诉咱们,但也仅此而已,咱们仍然不知道他是谁。若是咱们但愿在修改英雄的生命值和IQ以后,他可以主动告诉他的其余信息,这应该怎样才能办到呢?假设能够这样:

watcher(hero, 'type', () => {
  return hero.health > 4000 ? '坦克' : '脆皮'
})
复制代码

咱们定义了一个watcher做为“监听器”,它监听了hero的type属性。这个type属性的值取决于hero.health,换句话来讲,当hero.health发生变化时,hero.type也应该发生变化,前者是后者的依赖。咱们能够把这个hero.type称为“计算属性”。

那么,咱们应该怎样才能正确构造这个监听器呢?能够看到,在设想当中,监听器接收三个参数,分别是被监听的对象、被监听的属性以及回调函数,回调函数返回一个该被监听属性的值。顺着这个思路,咱们尝试着编写一段代码:

/** * 当计算属性的值被更新时调用 * @param { Any } val 计算属性的值 */
function onComputedUpdate (val) {
  console.log(`个人类型是:${val}`);
}

/** * 观测者 * @param { Object } obj 被观测对象 * @param { String } key 被观测对象的key * @param { Function } cb 回调函数,返回“计算属性”的值 */
function watcher (obj, key, cb) {
  Object.defineProperty(obj, key, {
    get () {
      const val = cb()
      onComputedUpdate(val)
      return val
    },
    set () {
      console.error('计算属性没法被赋值!')
    }
  })
}
复制代码

如今咱们能够把英雄放在监听器里面,尝试跑一下上面的代码:

watcher(hero, 'type', () => {
  return hero.health > 4000 ? '坦克' : '脆皮'
})

hero.type

hero.health = 5000

hero.type

// -> 个人health属性被读取了!
// -> 个人类型是:脆皮
// -> 个人health属性被修改了!
// -> 个人health属性被读取了!
// -> 个人类型是:坦克
复制代码

如今看起来没毛病,一切都运行良好,是否是就这样结束了呢?别忘了,咱们如今是经过手动读取hero.type来获取这个英雄的类型,并非他主动告诉咱们的。若是咱们但愿让英雄可以在health属性被修改后,第一时间主动发起通知,又该怎么作呢?这就涉及到本文的核心知识点——依赖收集。

3、依赖收集

咱们知道,当一个可观测对象的属性被读写时,会触发它的getter/setter方法。换个思路,若是咱们能够在可观测对象的getter/setter里面,去执行监听器里面的onComputedUpdate()方法,是否是就可以实现让对象主动发出通知的功能呢?

因为监听器内的onComputedUpdate()方法须要接收回调函数的值做为参数,而可观测对象内并无这个回调函数,因此咱们须要借助一个第三方来帮助咱们把监听器和可观测对象链接起来。

这个第三方就作一件事情——收集监听器内的回调函数的值以及onComputedUpdate()方法。

如今咱们把这个第三方命名为“依赖收集器”,一块儿来看看应该怎么写:

const Dep = {
  target: null
}
复制代码

就是这么简单。依赖收集器的target就是用来存放监听器里面的onComputedUpdate()方法的。

定义完依赖收集器,咱们回到监听器里,看看应该在什么地方把onComputedUpdate()方法赋值给Dep.target

function watcher (obj, key, cb) {
  // 定义一个被动触发函数,当这个“被观测对象”的依赖更新时调用
  const onDepUpdated = () => {
    const val = cb()
    onComputedUpdate(val)
  }

  Object.defineProperty(obj, key, {
    get () {
      Dep.target = onDepUpdated
      // 执行cb()的过程当中会用到Dep.target,
      // 当cb()执行完了就重置Dep.target为null
      const val = cb()
      Dep.target = null
      return val
    },
    set () {
      console.error('计算属性没法被赋值!')
    }
  })
}
复制代码

咱们在监听器内部定义了一个新的onDepUpdated()方法,这个方法很简单,就是把监听器回调函数的值以及onComputedUpdate()打包到一块,而后赋值给Dep.target。这一步很是关键,经过这样的操做,依赖收集器就得到了监听器的回调值以及onComputedUpdate()方法。做为全局变量,Dep.target理所固然的可以被可观测对象的getter/setter所使用。

从新看一下咱们的watcher实例:

watcher(hero, 'type', () => {
  return hero.health > 4000 ? '坦克' : '脆皮'
})
复制代码

在它的回调函数中,调用了英雄的health属性,也就是触发了对应的getter函数。理清楚这一点很重要,由于接下来咱们须要回到定义可观测对象的defineReactive()方法当中,对它进行改写:

function defineReactive (obj, key, val) {
  const deps = []
  Object.defineProperty(obj, key, {
    get () {
      if (Dep.target && deps.indexOf(Dep.target) === -1) {
        deps.push(Dep.target)
      }
      return val
    },
    set (newVal) {
      val = newVal
      deps.forEach((dep) => {
        dep()
      })
    }
  })
}
复制代码

能够看到,在这个方法里面咱们定义了一个空数组deps,当getter被触发的时候,就会往里面添加一个Dep.target。回到关键知识点Dep.target等于监听器的onComputedUpdate()方法,这个时候可观测对象已经和监听器捆绑到一块。任什么时候候当可观测对象的setter被触发时,就会调用数组中所保存的Dep.target方法,也就是自动触发监听器内部的onComputedUpdate()方法。

至于为何这里的deps是一个数组而不是一个变量,是由于可能同一个属性会被多个计算属性所依赖,也就是存在多个Dep.target。定义deps为数组,若当前属性的setter被触发,就能够批量调用多个计算属性的onComputedUpdate()方法了。

完成了这些步骤,基本上咱们整个响应式系统就已经搭建完成,下面贴上完整的代码:

/** * 定义一个“依赖收集器” */
const Dep = {
  target: null
}

/** * 使一个对象转化成可观测对象 * @param { Object } obj 对象 * @param { String } key 对象的key * @param { Any } val 对象的某个key的值 */
function defineReactive (obj, key, val) {
  const deps = []
  Object.defineProperty(obj, key, {
    get () {
      console.log(`个人${key}属性被读取了!`)
      if (Dep.target && deps.indexOf(Dep.target) === -1) {
        deps.push(Dep.target)
      }
      return val
    },
    set (newVal) {
      console.log(`个人${key}属性被修改了!`)
      val = newVal
      deps.forEach((dep) => {
        dep()
      })
    }
  })
}

/** * 把一个对象的每一项都转化成可观测对象 * @param { Object } obj 对象 */
function observable (obj) {
  const keys = Object.keys(obj)
  for (let i = 0; i < keys.length; i++) {
    defineReactive(obj, keys[i], obj[keys[i]])
  }
  return obj
}

/** * 当计算属性的值被更新时调用 * @param { Any } val 计算属性的值 */
function onComputedUpdate (val) {
  console.log(`个人类型是:${val}`)
}

/** * 观测者 * @param { Object } obj 被观测对象 * @param { String } key 被观测对象的key * @param { Function } cb 回调函数,返回“计算属性”的值 */
function watcher (obj, key, cb) {
  // 定义一个被动触发函数,当这个“被观测对象”的依赖更新时调用
  const onDepUpdated = () => {
    const val = cb()
    onComputedUpdate(val)
  }

  Object.defineProperty(obj, key, {
    get () {
      Dep.target = onDepUpdated
      // 执行cb()的过程当中会用到Dep.target,
      // 当cb()执行完了就重置Dep.target为null
      const val = cb()
      Dep.target = null
      return val
    },
    set () {
      console.error('计算属性没法被赋值!')
    }
  })
}

const hero = observable({
  health: 3000,
  IQ: 150
})

watcher(hero, 'type', () => {
  return hero.health > 4000 ? '坦克' : '脆皮'
})

console.log(`英雄初始类型:${hero.type}`)

hero.health = 5000

// -> 个人health属性被读取了!
// -> 英雄初始类型:脆皮
// -> 个人health属性被修改了!
// -> 个人health属性被读取了!
// -> 个人类型是:坦克
复制代码

上述代码能够直接在code pen或者浏览器控制台上执行。

4、代码优化

在上面的例子中,依赖收集器只是一个简单的对象,其实在defineReactive()内部的deps数组等和依赖收集有关的功能,都应该集成在Dep实例当中,因此咱们能够把依赖收集器改写一下:

class Dep {
  constructor () {
    this.deps = []
  }

  depend () {
    if (Dep.target && this.deps.indexOf(Dep.target) === -1) {
      this.deps.push(Dep.target)
    }
  }

  notify () {
    this.deps.forEach((dep) => {
      dep()
    })
  }
}

Dep.target = null
复制代码

一样的道理,咱们对observable和watcher都进行必定的封装与优化,使这个响应式系统变得模块化:

class Observable {
  constructor (obj) {
    return this.walk(obj)
  }

  walk (obj) {
    const keys = Object.keys(obj)
    keys.forEach((key) => {
      this.defineReactive(obj, key, obj[key])
    })
    return obj
  }

  defineReactive (obj, key, val) {
    const dep = new Dep()
    Object.defineProperty(obj, key, {
      get () {
        dep.depend()
        return val
      },
      set (newVal) {
        val = newVal
        dep.notify()
      }
    })
  }
}
复制代码
class Watcher {
  constructor (obj, key, cb, onComputedUpdate) {
    this.obj = obj
    this.key = key
    this.cb = cb
    this.onComputedUpdate = onComputedUpdate
    return this.defineComputed()
  }

  defineComputed () {
    const self = this
    const onDepUpdated = () => {
      const val = self.cb()
      this.onComputedUpdate(val)
    }

    Object.defineProperty(self.obj, self.key, {
      get () {
        Dep.target = onDepUpdated
        const val = self.cb()
        Dep.target = null
        return val
      },
      set () {
        console.error('计算属性没法被赋值!')
      }
    })
  }
}
复制代码

而后咱们来跑一下:

const hero = new Observable({
  health: 3000,
  IQ: 150
})

new Watcher(hero, 'type', () => {
  return hero.health > 4000 ? '坦克' : '脆皮'
}, (val) => {
  console.log(`个人类型是:${val}`)
})

console.log(`英雄初始类型:${hero.type}`)

hero.health = 5000

// -> 英雄初始类型:脆皮
// -> 个人类型是:坦克
复制代码

代码已经放在code pen,浏览器控制台也是能够运行的~

5、尾声

看到上述的代码,是否是发现和VueJS源码里面的很像?其实VueJS的思路和原理也是相似的,只不过它作了更多的事情,但核心仍是在这里边。

在学习VueJS源码的时候,曾经被响应式原理弄得头昏脑涨,并不是一会儿就看懂了。后在不断的思考与尝试下,同时参考了许多其余人的思路,才总算把这一块的知识点彻底掌握。但愿这篇文章对你们有帮助,若是发现有任何错漏的地方,也欢迎向我指出,谢谢你们~

相关文章
相关标签/搜索