一种JavaScript响应式系统实现

根据VueJs核心团队成员霍春阳《Vue.js设计与实现》第四章前三节整理而成

1. 响应式数据与副作用函数

1.1 副作用函数

会产生副作用的函数。

如下示例所示:

  1. function effect () {
  2. document.body.innerText = 'hello vue3!'
  3. }

当effect函数执行时,会设置body的文本内容。但是,除了effect函数之外的任何函数都可以读取或者设置body的文本内容。也就是说,effect函数的执行,会直接或间接影响其他函数的执行,这时,我们说effect函数产生了副作用。

  1. // 此方法的结果就受effect函数的影响
  2. function getInnerText () {
  3. return document.body.innerText
  4. }

副作用很容易产生,例如一个函数修改了全局变量,这其实也是一个副作用,如下面代码所示:

  1. let globalValue = 1
  2. function effect () {
  3. globalValue = 3 // 修改全局变量,产生副作用
  4. }

1.2 响应式数据

理解了什么是副作用函数,再来说一说什么是响应式数据。

假设在一个副作用函数中读取了某个对象的属性:

  1. const obj = {
  2. text: 'hello vue2'
  3. }
  4. function effect () {
  5. document.body.innerText = obj.text // effect 函数的执行会读取obj.text
  6. }

如上面代码所示,副作用函数effect会设置body元素的innerText属性,其值为obj.text。当obj.text的值改变时,我们希望副作用函数effect()会重新执行:

  1. obj.text = 'hello vue3' // 修改obj.text的值,同时希望副作用函数重新执行

这句代码修改了字段 obj.text 的值,我们希望当值变化后,副作用函数会重新执行,如果能实现这个目标,那么对象 obj 就是响应式数据。(某数据改变时,依赖该数据的副作用函数会重新执行,该数据即为响应式数据

但是,从上面代码来看,我们还做不到这一点,因为obj是一个普通的对象,当我们修改它的值时,除了值本身发生变化外,不会有任何其他反应。

2. 响应式数据的基本实现

2.1 如何让 obj 变为响应式数据?

通过观察,我们可以发现 2 点线索:

  • 当副作用函数effect()执行时,会触发字段 obj.text 的读取操作;
  • 当修改 obj.text 的值时,会触发字段 obj.text设置操作;

    如果我们能拦截一个对象的读取设置操作,事情就变得简单了。

  • 当读取obj.text 时,我们可以把副作用函数effect()存储到一个“桶”里;

一种JavaScript响应式系统实现 - 图1

  • 当设置obj.text时,再把effect()副作用函数从 “桶” 里取出,并执行;

一种JavaScript响应式系统实现 - 图2

2.2 如何拦截对象属性的读取和设置操作

在 es5 及之前,只能通过 Obj.defineProperty 来实现,这也是vue2中所采用的方式。

es6以后,可以使用代理对象 Proxy 来实现,这是vue3中的所采用的方式。

实现步骤:

  • 创建一个用于存储副作用函数的容器(或者通俗点说,一个桶,vue3里用了bucket这个词,以后都称为桶);
  • 定义一个普通对象obj作为原始数据;
  • 创建 obj 对象的代理作为响应式数据,分别设置 getset 拦截函数,用于拦截读和写操作;
  • 当在effect副作用函数中执行响应式数据读操作时,将effect()副作用函数存到桶里;
  • 当对响应式数据进行写操作时,先更新原始数据,再从桶中取出依赖了响应式数据的函数,进行执行;

接下来,根据以上思路,使用Proxy 实现一下:

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta http-equiv="X-UA-Compatible" content="IE=edge">
  6. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  7. <title>Document</title>
  8. </head>
  9. <body>
  10. <div id="app">
  11. <p id="title"></p>
  12. <button onclick="changeObj()">修改响应式数据</button>
  13. </div>
  14. </body>
  15. </html>
  16. <script>
  17. // 1. 创建一个存储副作用函数的桶
  18. const bucket = new Set()
  19. // 2. 一个普通的对象
  20. const obj = {
  21. text: 'hello vue2'
  22. }
  23. // 3. 普通对象的代理,一个简单的响应式数据
  24. const objProxy = new Proxy(obj, {
  25. get (target, key) {
  26. bucket.add(effect)
  27. return target[key]
  28. },
  29. set (target, key, newValue) {
  30. target[key] = newValue
  31. bucket.forEach((fn) => {
  32. fn()
  33. })
  34. return true
  35. }
  36. })
  37. // 4. 副作用函数 effect(), 给p标签设置值
  38. const effect = function () {
  39. document.getElementById('title').innerText = objProxy.text
  40. }
  41. // 5. 改变代理对象的元素值
  42. const changeObj = function () {
  43. objProxy.text = 'hello vue3!!!!!!'
  44. }
  45. // 首次进入时,给p标签设置值
  46. effect()
  47. </script>

一种JavaScript响应式系统实现 - 图3

2.3 缺陷

  • 副作用函数名字叫effect ,是硬编码的,假如叫其他的名字,就无法正确执行了;

3. 完善的响应式系统

3.1 如何存储一个任意命名(甚至匿名)的副作用函数?

需要提供一个用来注册副作用函数的机制,如以下代码所示:

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta http-equiv="X-UA-Compatible" content="IE=edge">
  6. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  7. <title>Document</title>
  8. </head>
  9. <body>
  10. <div id="app">
  11. <p id="title"></p>
  12. <button onclick="changeObj()">修改响应式数据</button>
  13. </div>
  14. </body>
  15. </html>
  16. <script>
  17. let activeEffect
  18. function effectRegister (fn) {
  19. activeEffect = fn
  20. fn()
  21. }
  22. const bucket = new Set()
  23. const obj = {
  24. text: 'hello vue2'
  25. }
  26. const objProxy = new Proxy(obj, {
  27. get (target, key) {
  28. if (activeEffect) {
  29. bucket.add(activeEffect)
  30. }
  31. return target[key]
  32. },
  33. set (target, key, newValue) {
  34. target[key] = newValue
  35. bucket.forEach((fn) => {
  36. fn()
  37. })
  38. return true
  39. }
  40. })
  41. const changeObj = function () {
  42. objProxy.text = 'hello vue3!!!!!!'
  43. }
  44. effectRegister(() => {
  45. document.getElementById('title').innerText = objProxy.text
  46. })
  47. </script>

以上方式,通过定义一个全局变量activeEffect,用来存储(匿名)副作用函数。并提供一个注册副作用函数的注册函数 effectRegister,该函数是一个高阶函数,接受一个函数作为参数,保存并执行该函数。将(匿名)副作用函数保存到activeEffect 中,当(匿名)副作用函数执行时,触发响应式数据的读操作,此时将activeEffect 存入副作用函数桶中。

缺陷:

以上方案成功解决了匿名的函数的保存问题,但仍存在一个严重问题:

响应式对象内的不同属性和不同副作用函数的对应问题

下面给响应式数据添加一个属性,以上代码微调为如下:

  1. // ... 省略未改变代码
  2. const setObj = function (key, value) {
  3. objProxy[key] = value
  4. }
  5. const changeObj = function () {
  6. setObj('text', 'hello vue3!!!!!!')
  7. setObj('notExist', 'this key is not exist')
  8. console.log(1, bucket)
  9. }
  10. effectRegister(() => {
  11. console.log('执行')
  12. document.getElementById('title').innerText = objProxy.text
  13. })

执行结果为:

一种JavaScript响应式系统实现 - 图4

可以看到,此时副作用函数与obj.notExist 属性并未建立响应关系,但当给 notExist 赋值时,副作用函数也执行了,这显然不对了。

理想情况应该是,a属性与aFunc建立响应式关系,b属性与bFunc建立响应式联系,则 a 改变时,仅 aFunc函数触发执行,b改变时,仅bFunc触发执行。

3.2 如何解决响应式对象内的不同属性和不同副作用函数的对应问题?

问题分析:

导致该问题的根本原因是,我们没有在副作用函数与被操作的目标字段之间建立明确的联系

例如,但读取属性值时,无论读取到哪一个属性,其实都一样,都会把副作用函数收集到“桶”里;无论设置的是哪一个属性,也会把“桶”里面的副作用函数取出并执行。响应数据属性和副作用函数之间没有明确的联系。

解决方案很简单,只需要在副作用函数与被操作的字段之间建立联系即可。

要实现不同属性值与副作用函数对应,Set类型的数据作为桶已经明显不合适了。

科普下es6几大数据类型:

Set:ES6 提供了新的数据结构 Set。它类似于数组,但是成员的值都是唯一的,没有重复的值。

WeakSet:WeakSet 结构与 Set 类似,也是不重复的值的集合。但是,它与 Set 有两个区别。首先,WeakSet 的成员只能是对象,而不能是其他类型的值。其次,WeakSet 中的对象都是弱引用,即垃圾回收机制不考虑 WeakSet 对该对象的引用,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于 WeakSet 之中。

Map:它类似于对象,也是键值对的集合,但是“键”的范围不限于字符串,各种类型的值(包括对象)都可以当作键。也就是说,Object 结构提供了“字符串—值”的对应,Map 结构提供了“值—值”的对应,是一种更完善的 Hash 结构实现。

WeakMapWeakMap结构与Map结构类似,也是用于生成键值对的集合。WeakMapMap的区别有两点。

首先,WeakMap只接受对象作为键名(null除外),不接受其他类型的值作为键名。其次,WeakMap的键名所指向的对象,不计入垃圾回收机制。

新的方案:

首先,来看下如下代码:

  1. effectRegister(function effectFn () {
  2. document.getElementById('title').innerText = objProxy.text
  3. })

这段代码存在三个角色:

  • 被读取的响应式数据 objProxy;
  • 被读取的响应式数据的属性名 text;
  • 使用effectRegister 函数注册的副作用函数 effectFn;

如果使用 target 来表示一个代理对象所代理的原始对象,用key 来表示被操作的字段名,用effectFn 来表示被注册的副作用函数,那么可以为这三个角色建立如下关系:

一种JavaScript响应式系统实现 - 图5

这是一种树型结构,下面举几个例子进行补充说明:

  1. 如果有2个副作用函数同时读取同一个对象的属性值: ```javascript effectRegister(function effectFn1 () { obj.text })

effectRegister(function effectFn2 () { obj.text })

  1. 那么这三者关系如下:
  2. ![](https://cherish-1256678432.cos.ap-nanjing.myqcloud.com/typora/image-20220627214317647.png#alt=image-20220627214317647)
  3. 2.
  4. 如果一个副作用函数中读取了同一个对象的 2 个不同属性:
  5. ```javascript
  6. effectRegister(function effectFn () {
  7. obj.text1
  8. obj.text2
  9. }

那么这三者关系如下:

一种JavaScript响应式系统实现 - 图6

  1. 如果 2 个不同的副作用函数中读取了 2 个不同对象的不同属性: ```javascript effectRegister(function effectFn1 () { obj1.text1 })

effectRegister(function effectFn2 () { obj2.text2 })

  1. <br />那么这三者关系如下:
  2. <br />![](https://cherish-1256678432.cos.ap-nanjing.myqcloud.com/typora/image-20220627215041694.png#alt=image-20220627215041694)
  3. 总之,这其实就是一个树形数据结构。这个联系建立起来后,就可以解决前文提到的问题。上文中,如果我们设置了`obj2.text2`的值,就只会导致 `effectFn2` 函数重新执行,并不会导致 `effectFn1` 函数重新执行。
  4. 接下来,需要重新设计这个桶。根据对以上几种情况的分析,我们可以总结一下这种数据结构的模型:
  5. ![](https://cherish-1256678432.cos.ap-nanjing.myqcloud.com/typora/image-20220628103144724.png#alt=image-20220628103144724)
  6. 首先,使用`WeakMap` 代替 `Set` 来作为桶,将 原始对象 `target` 作为`WeakMap` 的key,使用 `Map` 作为value。在`Map` 中又以属性值 作为 key值,使用 `Set` 存储key 对应的副作用函数 `effectFn`,然后修改 `get/set` 拦截器代码。
  7. 以下,我们将桶命名为 `bucket`, 每个对象的 属性-副作用函数 存储块 命名为 `depsMap`,将depsMap中的value部分(Set类型,存储副作用函数)命名为 `deps`(与vue3响应式实现源码命名保持一致),根据数据结构图,重新编写`get/set` 拦截器代码:
  8. js:
  9. ```javascript
  10. // WeakMap 类型的桶
  11. const bucket = new WeakMap()
  12. // 初始值为undefined
  13. let activeEffect
  14. // 定义2个对象
  15. const obj_1 = {
  16. text1: 'hello vue',
  17. text2: 'hello jquery'
  18. }
  19. const obj_2 = {
  20. text1: 'hello react',
  21. text2: 'hello angular'
  22. }
  23. const objProxy1 = new Proxy(obj_1, {
  24. get (target, key) {
  25. // 如果没有注册的副作用函数,直接返回key对应的value值
  26. if (!activeEffect) {
  27. return target[key]
  28. }
  29. // 获取桶内 target 对象对应的 depsMap
  30. let depsMap = bucket.get(target)
  31. // 如果该对象还没有depsMap,这新建一个
  32. if (!depsMap) {
  33. depsMap = new Map()
  34. bucket.set(target, depsMap)
  35. }
  36. // 从depsMap中取出属性对应的副作用函数集合
  37. let deps = depsMap.get(key)
  38. // 同理,若不存在,则创建
  39. if (!deps) {
  40. deps = new Set()
  41. depsMap.set(key, deps)
  42. }
  43. deps.add(activeEffect)
  44. return target[key]
  45. },
  46. set (target,key, newValue) {
  47. // 给target的key设置新的value
  48. target[key] = newValue
  49. // 取出该target对应的depsMap
  50. let depsMap = bucket.get(target)
  51. // depsMap 不存在或为空直接返回
  52. if (!depsMap) {
  53. return
  54. }
  55. // 取出deps
  56. let deps = depsMap.get(key)
  57. // deps 不存在或为空直接返回
  58. if (!deps) {
  59. return
  60. }
  61. // 依次执行对应副作用函数
  62. deps.forEach((fn) => {
  63. fn()
  64. })
  65. return true
  66. }
  67. })
  68. const objProxy2 = new Proxy(obj_2, {
  69. ...
  70. })
  71. const effectRegister = function (fn) {
  72. activeEffect = fn
  73. fn()
  74. }

演示:

一种JavaScript响应式系统实现 - 图7

3.3 思考:为什么桶的结构使用WeakMap而不是Map?

看一段代码:

  1. const map = new Map()
  2. const weakmap = new WeakMap()
  3. ;(function () {
  4. const foo = { foo: 1}
  5. const bar = { bar: 2}
  6. map.set(foo, 1)
  7. weakmap.set(bar, 2)
  8. })();
  9. console.log(map)
  10. console.log(weakmap)

思考,打印结果分别是啥?

注意:在浏览器环境下,console打印结果表现出异步行为。

一种JavaScript响应式系统实现 - 图8

原因分析:

Map对key是强引用,当立即执行函数结束后,foo仍被map引用,因此map.keys()可以成功打印出key值,而WeakMap对key是弱引用,立即执行函数结束后,bar 失去引用,被垃圾回收器回收掉,因此weakmap.keys()无法取出key值。

结论:

WeakMap经常存储那些只有当key值所引用的对象存在(没有被垃圾回收)时才有价值的信息,例如示例中的target,如果target对象没有任何引用了,说明用户测不需要它了,这是垃圾回收器会完成回收任务。

如果使用Map来代替WeakMap,那即使用户侧没有任何对target的引用,这个target也不会被回收,最终可能导致内存溢出。

4. 总结

以上,我们实现了一个简单的响应系统,核心思路是通过代理来拦截响应数据的读和写的操作,将与对象属性相关的副作用函数进行存储,当对象属性变化时,同步执行相关联的副作用函数,达到响应式的效果。

其实现中混合使用了代理模式和观察者模式。

5. 参考资料

附:程序源码

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta http-equiv="X-UA-Compatible" content="IE=edge">
  6. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  7. <title>Document</title>
  8. </head>
  9. <body>
  10. <div id="app">
  11. <p id="title_1"></p>
  12. <p id="title_2"></p>
  13. <button onclick="setObj_1()">修改响应式数据1</button>
  14. <button onclick="setObj_2()">修改响应式数据2</button>
  15. </div>
  16. </body>
  17. </html>
  18. <script>
  19. let activeEffect
  20. let count = 3
  21. let version = 3
  22. const bucket = new WeakMap()
  23. const obj_1 = {
  24. text1: 'hello vue2',
  25. text2: 'hello jq2'
  26. }
  27. const obj_2 = {
  28. text2: 'hello react16',
  29. text1: 'hello ng2'
  30. }
  31. const getProxy = function (obj) {
  32. return new Proxy(obj, {
  33. get (target, key) {
  34. track(target, key)
  35. return target[key]
  36. },
  37. set (target, key, newValue) {
  38. trigger(target, key, newValue)
  39. return true
  40. }
  41. })
  42. }
  43. const track = function (target, key) {
  44. // 如果无副作用函数,则直接返回原始对象值
  45. if (!activeEffect) {
  46. return
  47. }
  48. // 以该代理对象的原始对象作为key值,获取depsMap (属性和副作用函数之间的对应关系 key --> effectFn)
  49. let depsMap = bucket.get(target)
  50. // 如果不存在depsMap,则新建一个Map与target关联起来
  51. if (!depsMap) {
  52. depsMap = new Map()
  53. bucket.set(target, depsMap)
  54. }
  55. // 根据key从depsMap中获取对应的deps,deps是一个Set
  56. let deps = depsMap.get(key)
  57. // 如果不存在,则新建Set,并与key关联
  58. if (!deps) {
  59. deps = new Set()
  60. depsMap.set(key, deps)
  61. }
  62. // 最后将当前活跃的副作用函数添加到桶里
  63. deps.add(activeEffect)
  64. }
  65. const trigger = function (target, key, newValue) {
  66. target[key] = newValue
  67. let depsMap = bucket.get(target)
  68. if (!depsMap) {
  69. return
  70. }
  71. let deps = depsMap.get(key)
  72. if (!deps) {
  73. return
  74. }
  75. deps.forEach(fn => {
  76. fn()
  77. })
  78. }
  79. const objProxy1 = getProxy(obj_1)
  80. const objProxy2 = getProxy(obj_2)
  81. const effectRegister = function (fn) {
  82. activeEffect = fn
  83. fn()
  84. }
  85. const setObj_1 = function () {
  86. objProxy1.text1 = `hello, vue${count++}`
  87. console.log('bucket-1', bucket)
  88. }
  89. const setObj_2 = function () {
  90. objProxy2.text2 = `hello, react${version++}`
  91. console.log('bucket-2', bucket)
  92. }
  93. console.log('bucket-init', bucket)
  94. effectRegister(function effectFn1 () {
  95. document.getElementById('title_1').innerText = objProxy1.text1
  96. })
  97. effectRegister(function effectFn2 () {
  98. document.getElementById('title_2').innerText = objProxy2.text2
  99. })
  100. </script>