add by zhj: 文章写的很通俗易懂,明白了Object.defineProperty的用法

原文:https://zhuanlan.zhihu.com/p/29318017

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

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

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

  1. const hero = {
  2. health: 3000,
  3. IQ: 150
  4. }

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

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

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

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

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

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

  1. let hero = {}
  2. let val = 3000
  3. Object.defineProperty(hero, 'health', {
  4. get () {
  5. console.log('我的health属性被读取了!')
  6. return val
  7. },
  8. set (newVal) {
  9. console.log('我的health属性被修改了!')
  10. val = newVal
  11. }
  12. })

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

  1. console.log(hero.health)
  2. // -> 3000
  3. // -> 我的health属性被读取了!
  4. hero.health = 5000
  5. // -> 我的health属性被修改了

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

  1. /**
  2. * 使一个对象转化成可观测对象
  3. * @param { Object } obj 对象
  4. * @param { String } key 对象的key
  5. * @param { Any } val 对象的某个key的值
  6. */
  7. function defineReactive (obj, key, val) {
  8. Object.defineProperty(obj, key, {
  9. get () {
  10. // 触发getter
  11. console.log(`我的${key}属性被读取了!`)
  12. return val
  13. },
  14. set (newVal) {
  15. // 触发setter
  16. console.log(`我的${key}属性被修改了!`)
  17. val = newVal
  18. }
  19. })
  20. }
  21. /**
  22. * 把一个对象的每一项都转化成可观测对象
  23. * @param { Object } obj 对象
  24. */
  25. function observable (obj) {
  26. const keys = Object.keys(obj)
  27. keys.forEach((key) => {
  28. defineReactive(obj, key, obj[key])
  29. })
  30. return obj
  31. }

现在我们可以把英雄这么定义:

  1. const hero = observable({
  2. health: 3000,
  3. IQ: 150
  4. })

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

二、计算属性

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

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

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

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

  1. /**
  2. * 当计算属性的值被更新时调用
  3. * @param { Any } val 计算属性的值
  4. */
  5. function onComputedUpdate (val) {
  6. console.log(`我的类型是:${val}`);
  7. }
  8. /**
  9. * 观测者
  10. * @param { Object } obj 被观测对象
  11. * @param { String } key 被观测对象的key
  12. * @param { Function } cb 回调函数,返回“计算属性”的值
  13. */
  14. function watcher (obj, key, cb) {
  15. Object.defineProperty(obj, key, {
  16. get () {
  17. const val = cb()
  18. onComputedUpdate(val)
  19. return val
  20. },
  21. set () {
  22. console.error('计算属性无法被赋值!')
  23. }
  24. })
  25. }

现在我们可以把英雄放在监听器里面,尝试跑一下上面的代码:

  1. watcher(hero, 'type', () => {
  2. return hero.health > 4000 ? '坦克' : '脆皮'
  3. })
  4. hero.type
  5. hero.health = 5000
  6. hero.type
  7. // -> 我的health属性被读取了!
  8. // -> 我的类型是:脆皮
  9. // -> 我的health属性被修改了!
  10. // -> 我的health属性被读取了!
  11. // -> 我的类型是:坦克

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

三、依赖收集

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

由于监听器内的onComputedUpdate()方法需要接收回调函数的值作为参数,而可观测对象内并没有这个回调函数,所以我们需要借助一个第三方来帮助我们把监听器和可观测对象连接起来。

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

现在我们把这个第三方命名为“依赖收集器”,一起来看看应该怎么写:

  1. const Dep = {
  2. target: null
  3. }

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

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

  1. function watcher (obj, key, cb) {
  2. // 定义一个被动触发函数,当这个“被观测对象”的依赖更新时调用
  3. const onDepUpdated = () => {
  4. const val = cb()
  5. onComputedUpdate(val)
  6. }
  7. Object.defineProperty(obj, key, {
  8. get () {
  9. Dep.target = onDepUpdated
  10. // 执行cb()的过程中会用到Dep.target,
  11. // 当cb()执行完了就重置Dep.target为null
  12. const val = cb()
  13. Dep.target = null
  14. return val
  15. },
  16. set () {
  17. console.error('计算属性无法被赋值!')
  18. }
  19. })
  20. }

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

重新看一下我们的watcher实例:

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

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

  1. function defineReactive (obj, key, val) {
  2. const deps = []
  3. Object.defineProperty(obj, key, {
  4. get () {
  5. if (Dep.target && deps.indexOf(Dep.target) === -1) {
  6. deps.push(Dep.target)
  7. }
  8. return val
  9. },
  10. set (newVal) {
  11. val = newVal
  12. deps.forEach((dep) => {
  13. dep()
  14. })
  15. }
  16. })
  17. }

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

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

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

  1. /**
  2. * 定义一个“依赖收集器”
  3. */
  4. const Dep = {
  5. target: null
  6. }
  7. /**
  8. * 使一个对象转化成可观测对象
  9. * @param { Object } obj 对象
  10. * @param { String } key 对象的key
  11. * @param { Any } val 对象的某个key的值
  12. */
  13. function defineReactive (obj, key, val) {
  14. const deps = []
  15. Object.defineProperty(obj, key, {
  16. get () {
  17. console.log(`我的${key}属性被读取了!`)
  18. if (Dep.target && deps.indexOf(Dep.target) === -1) {
  19. deps.push(Dep.target)
  20. }
  21. return val
  22. },
  23. set (newVal) {
  24. console.log(`我的${key}属性被修改了!`)
  25. val = newVal
  26. deps.forEach((dep) => {
  27. dep()
  28. })
  29. }
  30. })
  31. }
  32. /**
  33. * 把一个对象的每一项都转化成可观测对象
  34. * @param { Object } obj 对象
  35. */
  36. function observable (obj) {
  37. const keys = Object.keys(obj)
  38. for (let i = 0; i < keys.length; i++) {
  39. defineReactive(obj, keys[i], obj[keys[i]])
  40. }
  41. return obj
  42. }
  43. /**
  44. * 当计算属性的值被更新时调用
  45. * @param { Any } val 计算属性的值
  46. */
  47. function onComputedUpdate (val) {
  48. console.log(`我的类型是:${val}`)
  49. }
  50. /**
  51. * 观测者
  52. * @param { Object } obj 被观测对象
  53. * @param { String } key 被观测对象的key
  54. * @param { Function } cb 回调函数,返回“计算属性”的值
  55. */
  56. function watcher (obj, key, cb) {
  57. // 定义一个被动触发函数,当这个“被观测对象”的依赖更新时调用
  58. const onDepUpdated = () => {
  59. const val = cb()
  60. onComputedUpdate(val)
  61. }
  62. Object.defineProperty(obj, key, {
  63. get () {
  64. Dep.target = onDepUpdated
  65. // 执行cb()的过程中会用到Dep.target,
  66. // 当cb()执行完了就重置Dep.target为null
  67. const val = cb()
  68. Dep.target = null
  69. return val
  70. },
  71. set () {
  72. console.error('计算属性无法被赋值!')
  73. }
  74. })
  75. }
  76. const hero = observable({
  77. health: 3000,
  78. IQ: 150
  79. })
  80. watcher(hero, 'type', () => {
  81. return hero.health > 4000 ? '坦克' : '脆皮'
  82. })
  83. console.log(`英雄初始类型:${hero.type}`)
  84. hero.health = 5000
  85. // -> 我的health属性被读取了!
  86. // -> 英雄初始类型:脆皮
  87. // -> 我的health属性被修改了!
  88. // -> 我的health属性被读取了!
  89. // -> 我的类型是:坦克

上述代码可以直接在code pen点击预览或者浏览器控制台上执行。

四、代码优化

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

  1. class Dep {
  2. constructor () {
  3. this.deps = []
  4. }
  5. depend () {
  6. if (Dep.target && this.deps.indexOf(Dep.target) === -1) {
  7. this.deps.push(Dep.target)
  8. }
  9. }
  10. notify () {
  11. this.deps.forEach((dep) => {
  12. dep()
  13. })
  14. }
  15. }
  16. Dep.target = null

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

  1. class Observable {
  2. constructor (obj) {
  3. return this.walk(obj)
  4. }
  5. walk (obj) {
  6. const keys = Object.keys(obj)
  7. keys.forEach((key) => {
  8. this.defineReactive(obj, key, obj[key])
  9. })
  10. return obj
  11. }
  12. defineReactive (obj, key, val) {
  13. const dep = new Dep()
  14. Object.defineProperty(obj, key, {
  15. get () {
  16. dep.depend()
  17. return val
  18. },
  19. set (newVal) {
  20. val = newVal
  21. dep.notify()
  22. }
  23. })
  24. }
  25. }
  26. class Watcher {
  27. constructor (obj, key, cb, onComputedUpdate) {
  28. this.obj = obj
  29. this.key = key
  30. this.cb = cb
  31. this.onComputedUpdate = onComputedUpdate
  32. return this.defineComputed()
  33. }
  34. defineComputed () {
  35. const self = this
  36. const onDepUpdated = () => {
  37. const val = self.cb()
  38. this.onComputedUpdate(val)
  39. }
  40. Object.defineProperty(self.obj, self.key, {
  41. get () {
  42. Dep.target = onDepUpdated
  43. const val = self.cb()
  44. Dep.target = null
  45. return val
  46. },
  47. set () {
  48. console.error('计算属性无法被赋值!')
  49. }
  50. })
  51. }
  52. }

然后我们来跑一下:

  1. const hero = new Observable({
  2. health: 3000,
  3. IQ: 150
  4. })
  5. new Watcher(hero, 'type', () => {
  6. return hero.health > 4000 ? '坦克' : '脆皮'
  7. }, (val) => {
  8. console.log(`我的类型是:${val}`)
  9. })
  10. console.log(`英雄初始类型:${hero.type}`)
  11. hero.health = 5000
  12. // -> 英雄初始类型:脆皮
  13. // -> 我的类型是:坦克

代码已经放在code pen点击预览,浏览器控制台也是可以运行的~

五、尾声

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

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

深入浅出Vue基于“依赖收集”的响应式原理(转)的更多相关文章

  1. 【Vue源码学习】响应式原理探秘

    最近准备开启Vue的源码学习,并且每一个Vue的重要知识点都会记录下来.我们知道Vue的核心理念是数据驱动视图,所有操作都只需要在数据层做处理,不必关心视图层的操作.这里先来学习Vue的响应式原理,V ...

  2. Vue.js学习 Item12 – 内部响应式原理探究

    深入响应式原理 大部分的基础内容我们已经讲到了,现在讲点底层内容.Vue.js 最显著的一个功能是响应系统 —— 模型只是普通对象,修改它则更新视图.这让状态管理非常简单且直观,不过理解它的原理也很重 ...

  3. Vue.2.0.5-深入响应式原理

    大部分的基础内容我们已经讲到了,现在讲点底层内容.Vue 最显著的一个功能是响应系统 -- 模型只是普通对象,修改它则更新视图.这会让状态管理变得非常简单且直观,不过理解它的原理以避免一些常见的陷阱也 ...

  4. vue源码解析之响应式原理

    关于defineReactive等使用细节需要自行了解 一些关键知识点 $mount时 会 new Watcher 把组件的 updateComponent 方法传给watcher 作为getter ...

  5. Vue 数据响应式原理

    Vue 数据响应式原理 Vue.js 的核心包括一套“响应式系统”.“响应式”,是指当数据改变后,Vue 会通知到使用该数据的代码.例如,视图渲染中使用了数据,数据改变后,视图也会自动更新. 举个简单 ...

  6. 详解Vue响应式原理

    摘要: 搞懂Vue响应式原理! 作者:浪里行舟 原文:深入浅出Vue响应式原理 Fundebug经授权转载,版权归原作者所有. 前言 Vue 最独特的特性之一,是其非侵入性的响应式系统.数据模型仅仅是 ...

  7. 深入解析vue响应式原理

    摘要:本文主要通过结合vue官方文档及源码,对vue响应式原理进行深入分析. 1.定义 作为vue最独特的特性,响应式可以说是vue的灵魂了,表面上看就是数据发生变化后,对应的界面会重新渲染,那么响应 ...

  8. Vue.js依赖收集

    写在前面 因为对Vue.js很感兴趣,而且平时工作的技术栈也是Vue.js,这几个月花了些时间研究学习了一下Vue.js源码,并做了总结与输出.文章的原地址:https://github.com/an ...

  9. Vue.js响应式原理

      写在前面 因为对Vue.js很感兴趣,而且平时工作的技术栈也是Vue.js,这几个月花了些时间研究学习了一下Vue.js源码,并做了总结与输出. 文章的原地址:answershuto/learnV ...

随机推荐

  1. kalman滤波

    kalman滤波原理(通俗易懂) 1. 在学习卡尔曼滤波器之前,首先看看为什么叫“卡尔曼”.跟其他著名的理论(例如傅立叶变换,泰勒级数等等)一样,卡尔曼也是一个人的名字,而跟他们不同的是,他是个现代人 ...

  2. Unity应用架构设计(7)——IoC工厂理念先行

    一谈到 『IoC』,有经验的程序员马上会联想到控制反转,将创建对象的责任反转给工厂.IoC是依赖注入 『DI』 的核心,大名鼎鼎的Spring框架就是一个非常卓越的的控制反转.依赖注入框架.遗憾的是, ...

  3. Redis数据结构详解,五种数据结构分分钟掌握

    redis数据类型分为:字符串类型.散列类型.列表类型.集合类型.有序集合类型.redis这么火,它运行有多块?一台普通的笔记本电脑,可以在1秒钟内完成十万次的读写操作.原子操作:最小的操作单位,不能 ...

  4. linux安全配置检查脚本_v0.8

    脚本环境:RHEL6.* 脚本说明:该脚本作用为纯执行检测不涉及更改配置等操作,与直接上来就改安全配置等基线脚本相比相对安全一些.虽然如此,在你执行该脚本之前仍然建议你备份或快照一下目标系统. 代码部 ...

  5. Java编程的逻辑 (88) - 正则表达式 (上)

    本系列文章经补充和完善,已修订整理成书<Java编程的逻辑>,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接:http:/ ...

  6. 通过灰度线性映射增强图像对比度实现PS中的色阶

    通过灰度线性映射增强图像对比度 Halcon中如何通过灰度线性映射增强图片对比度呢?不急,我先讲点其他的. 如果你用过Photoshop,那么想必对增强图像的对比度很熟悉.在Photoshop中,我们 ...

  7. ZipUtil

    /* * To change this license header, choose License Headers in Project Properties. * To change this t ...

  8. MapReduce 计数器简介

    转自:http://my.oschina.net/leejun2005/blog/276891?utm_source=tuicool&utm_medium=referral 1.计数器 简介 ...

  9. Swagger UI 与SpringMVC的整合 II

    pom.xml <!-- swagger开始 --> <dependency> <groupId>io.springfox</groupId> < ...

  10. tensorflow中moving average的用法

    一般在保存模型参数的时候,都会保存一份moving average,是取了不同迭代次数模型的移动平均,移动平均后的模型往往在性能上会比最后一次迭代保存的模型要好一些. tensorflow-model ...