资讯专栏INFORMATION COLUMN

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

AlphaWallet / 1332人阅读

摘要:三依赖收集我们知道,当一个可观测对象的属性被读写时,会触发它的方法。依赖收集器的就是用来存放监听器里面的方法的。

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

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

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

const hero = {
  health: 3000,
  IQ: 150
}

我们定义了这个英雄的生命值为3000,IQ为150。但是现在还不知道他是谁,不过这不重要,只需要知道这个英雄将会贯穿我们整篇文章,而我们的目的就是通过这个英雄的属性,知道这个英雄是谁。

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

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

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

在本文中,我们只使用这个方法使对象变得“可观测”,更多关于这个方法的具体内容,请参考https://developer.mozilla.org...,就不再赘述了。

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

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

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

二、计算属性

现在,英雄已经变得可观测,任何的读写操作他都会主动告诉我们,但也仅此而已,我们仍然不知道他是谁。如果我们希望在修改英雄的生命值和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属性被修改后,第一时间主动发起通知,又该怎么做呢?这就涉及到本文的核心知识点——依赖收集。

三、依赖收集

我们知道,当一个可观测对象的属性被读写时,会触发它的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或者浏览器控制台上执行。

四、代码优化

在上面的例子中,依赖收集器只是一个简单的对象,其实在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,浏览器控制台也是可以运行的~

五、尾声

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

在学习VueJS源码的时候,曾经被响应式原理弄得头昏脑涨,并非一下子就看懂了。后在不断的思考与尝试下,同时参考了许多其他人的思路,才总算把这一块的知识点完全掌握。希望这篇文章对大家有帮助,如果发现有任何错漏的地方,也欢迎向我指出,谢谢大家~

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/92373.html

相关文章

  • 【Vue原理响应原理 - 白话版

    摘要:所以我今后打算把每一个内容分成白话版和源码版。有什么错误的地方,感谢大家能够指出响应式系统我们都知道,只要在实例中声明过的数据,那么这个数据就是响应式的。什么是响应式,也即是说,数据发生改变的时候,视图会重新渲染,匹配更新为最新的值。 写文章不容易,点个赞呗兄弟专注 Vue 源码分享,文章分为白话版和 源码版,白话版助于理解工作原理,源码版助于了解内部详情,让我们一起学习吧研究基于 V...

    gggggggbong 评论0 收藏0
  • 深入浅出Vue响应原理

    摘要:总结最后我们依照下图参考深入浅出,再来回顾下整个过程在后,会调用函数进行初始化,也就是过程,在这个过程通过转换成了的形式,来对数据追踪变化,当被设置的对象被读取的时候会执行函数,而在当被赋值的时候会执行函数。 前言 Vue 最独特的特性之一,是其非侵入性的响应式系统。数据模型仅仅是普通的 JavaScript 对象。而当你修改它们时,视图会进行更新。这使得状态管理非常简单直接,不过理解...

    yiliang 评论0 收藏0
  • Vue 数据响应原理

    摘要:接下来,我们就一起深入了解的数据响应式原理,搞清楚响应式的实现机制。回调函数只是打印出新的得到的新的值,由执行后生成。及异步更新相信读过前文,你应该对响应式原理有基本的认识。 前言 Vue.js 的核心包括一套响应式系统。 响应式,是指当数据改变后,Vue 会通知到使用该数据的代码。例如,视图渲染中使用了数据,数据改变后,视图也会自动更新。 举个简单的例子,对于模板: {{ name ...

    Mike617 评论0 收藏0
  • 【Vue原理】Watch - 白话版

    摘要:而是在初始化时,在读取了监听的数据的值之后,便立即调用一遍你设置的监听回调,然后传入刚读取的值设置了时,如何工作我们都知道有一个选项,是用来深度监听的。 写文章不容易,点个赞呗兄弟专注 Vue 源码分享,文章分为白话版和 源码版,白话版助于理解工作原理,源码版助于了解内部详情,让我们一起学习吧研究基于 Vue版本 【2.5.17】 如果你觉得排版难看,请点击 下面链接 或者 拉到 下...

    hzx 评论0 收藏0
  • 响应数据与数据依赖基本原理

    摘要:响应式数据响应式数据不是凭空出现的。对于对象而言,如果是之前不存在的属性,首先可以将进行响应化处理比如调用,然后将对具体属性定义监听比如调用函数,最后再去做赋值,可能具体的处理过程千差万别,但是内部实现的原理应该就是如此仅仅是猜测。 前言   首先欢迎大家关注我的Github博客,也算是对我的一点鼓励,毕竟写东西没法获得变现,能坚持下去也是靠的是自己的热情和大家的鼓励。   国内前端算...

    or0fun 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<