用 Proxy 简单实现 Vue 3 的 Reactive
这里要给同学们分享的是 Proxy 与双向绑定,我们对大部分的 JavaScript 的这种基础库其实已经在其他文章中做过一些讲解了,或者是在我们编程的时候有所接触了。唯有这个 Proxy 我们之前是非常的回避的,因为在业务中也不太推荐大量的使用 Proxy。
Proxy 的设计其实是一种,强大且危险的一种设计。因为应用了 Proxy 的一些代码,它的 "预期性" 会变差,所以 proxy 这个特性是专门为底层库而设计的。
Proxy 基本用法
这里我们就一起学习一下 proxy 的基本用法,在后面我们会一起实现一下 Vue 3.0
的 reactive
的模型。当然这里实现的 reactive
并不是一个生产可用的代码,只是写一个概念版或者是玩具版的一个 reactive
。主要还是用它去认识和学习一下 proxy 有哪些强大的用途。
这里我们边写代码边了解 Proxy 的一个整体特性。首先我们先创建一个 object
,然后我们给这个 object
一些属性。
现在如果我们去访问这个 object
的 a
属性和 b
属性,这个中间其实是有一个获取过程,但是在 JavaScript 的底层是一个写死的方法,也就是说我们无法去干预或者监听这个获取对象属性的过程的代码。
那么这个 object
它就是一个不可 observe (观察)
的对象。所以就是一个单纯的数据存储。这也是 JavaScript 最底层的机制,我们是没有办法去改变的。
那么如果我们想有一个对象,我们既想它拥有普通对象一样的特性,又想让它能够被监听,那么我们可以怎么做呢?这个时候我们就可以通过一个
proxy
来给object
做一层包裹。
那么接下来我们就用 proxy 来实现一个这样的对象。
首先我们需要创建一个
Proxy()
并且第一个参数需要把我们的
object
传进去然后第二个参数是一个
config
的配置对象这个
config
对象里面就包含了所有的我们针对proxy
对象的钩子
这里我们就做一个最简单的钩子
set
—— 当我们去设置对象的一个属性的时候就会触发我们的set
函数这个
set
函数会接收我们当前对象
、属性名
、属性值
等三个参数
这个时候我们把这个代码在浏览器运行一下,这里我们运行一个 po.a = 6
。
这里同学们可以看到,如果 po
是一个普通对象的话这里应该什么代码都不会去执行的,除非 a
它本身就是一个 setter
。但是在我们编写的这个 proxy
对象上,不管我们去设置哪一个属性,都会运行我们的 set
函数,并且获得不一样的值。
我们来尝试设置一个 po
对象中没有的属性看看。
首先 proxy 跟 getter 和 setter 最主要的一个区别就是,proxy 对象上即使我们设置一个没有的属性,它也会默认触发这个 set 的方法。
我们的 proxy 里面不只提供了 get
、set
这些属性的钩子,其实里面还可以拦截并且改变原生的操作或者是对对象进行操作的内置函数的行为。
如果我们上 MDN 的网站上是可以看到所有 proxy 所支持的钩子。这里列出的有 apply
、construct
、defineProperty
、deleteProperty
等等这一系列的内置或者原生的操作进行拦截并且改变它们的行为。**所以说 proxy
对象是一个非常强大的对象**。
回到我们的例子中,我们 proxy
实际上就是代理了 object
这个对象。如果我们去调用原始的 object
上的值,并不会触发 proxy
上的 hook (钩子)
里面的函数。
只有使用我们的 po
(也就是我们定义的一个 object
对象的 proxy
代理对象) 才会最后去执行到 proxy 对象的拦截行为,而 object 还是原来的 object。
所以我们可以把
po
理解成一个特殊的对象,而po
上面所有的行为都是可以被重新去指定的。这个也就是为什么我们一开始的时候会说,object 中使用了 proxy 之后对象行为的可预测性就会降低。因为我们看到的一个代码,比如po.a = 6
在执行的时候也许背后就做了一系列很复杂的操作,这些我们是不会知道的。所以 proxy 的这个特性是一个非常危险的特性。
接下来我们来看看 Proxy 的一些应用。
模仿 Reactive 实现原理
这里我们尝试给对象做一个简单的包装。 Vue 3.0 其中一个改动就是把 Vue 原来的能力拆了一个包,产生了一个叫 reactive
的这一个单独的包。
Reactive
是一个 Vue 3.0 中非常好的东西,这里我们就尝试去模仿一下它在 Vue 中的实现原理。如果有看过 Vue 3.0 源码的同学应该都会知道,Vue 3.0 中的 reactive
是使用 proxy
来实现的。
那么我们就来一起实现一个玩具版的 reactive 的小练习,从而我们更能了解 proxy 的实际应用场景。
首先我们要知道,一般对 proxy 的使用,都是会对对象做某种监听或者是改变他行为的事情。所以说对 proxy 的封装是不会像我们这样,直接用 new Proxy
这样的方式。我们都会把它包进一个函数里面,跟我们的 Promise
比较类似。
封装 reative 函数
所以这里我们先来实现一个包裹起来的 reactive
函数:
reactive
函数会接收一个object
作为参数然后我们的
proxy
对象就是这个函数的返回值之前的
Proxy
的config
中我们写了set
, 这里我们加上一个get
方法然后我们就可以把
po
改为使用reactive(object)
来监听它所有的属性相关的操作了
就是这样我们就把
new Proxy
给包装起来了,我们可以看到如果我们想去包装多个object
的话就可以继续去复用这个reactive
的代码。
然后我们来看看在浏览器中运行的效果:
这里我们执行以下 po.a = 666
这里我们就可以得到 set
函数中的 console.log
打印出来的内容了。但是这里面其实还有一个问题,如果我们在 console 中打印 object ,就会发现我们的 object 原来它并没有变化。就是我们执行的 po.a = 666
并没有在 object 中生效。
所以这里我们需要在 set
函数中把这个执行改变的代码加上,让它实际的去操作这个 object 改变的行为。然后我们同时可以把 get
的功能也实现了。
这时候我们执行 po.x = 666
,我们就会发现原始被代理的对象 object 上面已经添加了新的属性 x
,同样我们也是可以去改原来的变量的,如果我们执行 po.b = 777
那么 object 中的属性也会跟着发生变化。
这里我们就实现了一个 po
对 object
的一个完全的代理,当然如果我们想真正做一个完整的代理我们是需要把 proxy 中所有的 hook 都要考虑清楚。因为有的时候我们去访问一个对象或者改变一个对象的时候,其实并不是说通过这种表面的 get
、set
的属性的方式去访问的。
我们还是可以通过一些内置的方法,比如说 defineProperty
,需要对我们的对象发生作用,这个时候我们就需要把所有的 hook 都补全了。
但是我们可以忽略一些 hook 不去处理,比如说 apply
和 construct
,因为它们管的是用 new
去调用这个对象和对象后面加圆括号产生的结果。
学习到这里我们已经获得了一个基本的,能够代理 object
行为并且可以去监听 object
,并且包含了所有设置属性或者改变属性的行为的一个 proxy 对象。
接下来我们一起来尝试给他再加入真正的 reactive 特性,让事件可以变得可监听。
实现事件监听
我们有了 reactive 这样一个函数之后,我们可以考虑一下如何去监听。当然我们可以给 po
上面去加 addEventListener
类似的操作,但是在 Vue 当中他们用了一个特别有意思的 API。
就是我们可以直接通过 effect
传一个函数进入来监听 po
上面的一个属性,以此来代替这个事件监听的机制。那么下面我们来尝试实现一个 "粗糙版"。
因为这个
effect
是接收一个回调函数的,所以我们这里需要再写一个effect
函数然后我们的
effect
函数需要接收一个callback
参数我们需要一个全局的
callbacks
数组变量来储存我们所有的 callback 函数在
effect
函数中我们把传入进来的 callback 函数给 push 到我们的 callback 数组中储存起来这样的话,我们在
set
的时候就直接遍历callbacks
并执行里面所有的回调函数即可
这个就是一个非常粗糙的实现了 reactive 中属性的监听事件。接下来我们来看看实际效果如何:
这里可以看到我们加入的 effect()
回调函数确实被执行了。如果我们只考虑实现的正确性,而不考虑性能的话我们就已经完成了 reactive
的操作。但是这个里面显然它有一个严重的性能问题的,比如说我们有 100 个对象,并且给 100 个对象设置了 100 个 effect,那么每次执行一遍就要调一万遍。因为每次它都把我们全局变量 callbacks
中记录的回调函数都执行一遍。
显然我们实现的这个 reactive
只是一个中间步骤,它并不是一个最终结果。那么我们接下来就去尝试解决这个问题,看看能不能做到仅传一个函数就能让它只有在对应的变量变化的时候,触发这个函数的调用。
建立 reactive 与 effect 连接
上一部分我们建立了对象属性的监听,这里我们给 reactive
对象属性和 effect
函数之间建立独立的连接。之前我们的 effect
函数与我们 reactive
对象属性是没有一对一的关系的。这样 100 个对象就会绑定 100 effect
,所以这里就会有一个性能隐患。
也就是说如果我们监听了 po.a
的话,当我们执行 po.a = 2
的时候,我们的 effect
回调函数就会被执行。但是如果我们执行的是 po.b = 3
时,就不应该执行我们的 effect
函数,因为 po.b
并没有被监听。
如果我们想实现这样的效果,我们就需要一个对象属性
与effect
之间的依赖关系,它们之间有一个一对一的关联关系,互相响应。
让我们先来尝试一下建立一个 userReactivities
来储存我们的监听对象属性。
首先我们需要准备一个
usedReactivities
的全局变量,来储存我们需要监听的对象和对象的属性接着我们尝试在
effect
里面去调用一次这个代理对象的属性,比如po.a
,这样就触发了这个属性的监听,因为我们调用了po.a
,也就是一个获取变量值的动作,所以这里就会调用到我们reactive
中的get
。这里我们把对象和对象属性都注册进入usedReactivies
这个变量里面然后我们改造一下我们的
effect
函数,在这里我们首先需要清除一次我们的usedReactivities
,保证每次注册的时候都是全新的,这样才会清除掉之前监听的对象属性。
这里我们可以看到,在 effect 被调用的时候,我们的对象和对象的属性都被正确的注入到 usedReactivities
之中。这里我们只是做了一个简单的对象和对象属性的存储,并不能让我们建立对象属性与 effect
函数的依赖关系。我们需要另外把所有 callbacks
储存起来,从而让他们与我们的对象属性建立依赖关系。
接下来我们可以使用
callbacks
这个全局变量来存储我们的依赖关系,所以这里我们就需要把它改造成一个new Map()
来存,因为我们需要把object
对象作为一个key
,这样我们才可以用它来找到对应的reactivities
(对象属性的对应 callback 函数)。然后我们就可以去改造我们的
effect
函数,在我们调用了callback()
之后,我们的usedReactivites
中就会拥有我们需要监听的对象和对象属性了。接着我们就需要注入我们的对象属性与 effect 依赖关系到callbacks
里面。我们的
对象属性
与effect
的依赖数据是以对象和对象属性为 key,*key*[0]
是我们的对象,*key*[1]
是我们的*对象属性*,我们的 value 就是我们的callback
回调函数有了这个依赖关系,我们就需要在
reactive
触发set
的时候根据当前对象和对象属性找到对应的callback
函数来执行,如果找不到就是这个对象属性没有被监听,不需要执行回调函数。
最后我们在浏览器中运行,我们就会发现执行 po.a=3
触发了我们 effect
回调函数,但是 po.b=6
并没有触发。这个就是我们想要的效果了,但是我们的代码还是写的比较粗糙的,也没有考虑到解除的效果。不过我们这段代码已经演示了 reactivity
的实现原理。
优化 reactive
到了这里我们的 effect
和 reactive
已经可以跑起来了,但是其实里面还是有一些小问题的。
比如说现在我们的 object 中的 a
也是一个对象:
然后我们在 effect
里面,调用了 po.a.b
这样的连级对象调用,那么这个对象它是一个监听不到 a
里面的 b
属性的。
所以说我们有必要对它再进行一些处理,让它能够支持 po.a.b
这种形式的调用。要满足这样的功能,我们就要对 reactive
的 get
和 set
有一定的要求。
当我们 get
中的 obj[prop]
是一个对象的时候,我们就需要给它套一个 reactivity
。也就是说当我们检测到 prop
是一个 object 的话,我们就给它返回一个 reactive(obj[prop])
。
那么我们来改造一下 reactive
中的 get
方法:
这样的改造虽然是可以让我们对象中的对象也被代理了,但是我们会一个问题,就是我们的 reactive
是会返回一个新的 proxy
的。那就意味着,po.a.b
拿到的 proxy
和 po.a
不是同一个 proxy
。
所以这里我们就需要把 proxy
对象放入一个全局的暂存变量里面,方便我们调用的时候在缓存数据里面重新拿出来。我们就声明一个 reativities
,默认值为 new Map()
。
当每个对象去调用 reactivity
的时候,我们会加一个缓存,因为 proxy
本身它是不存储任何状态的,而所有的状态都会代理到 object
上。某种意义上讲 reactive
其实是一个无状态的函数,所以我们可以对它进行缓存。
我们就在 reactive
的函数开始的位置,加入一个判断,如果我们缓存变量 reactivies
中有这个 object
,我们就直接返回。如果没有我们就执行我们的新 proxy
生成并且把它存入 reactivies
。
好,我们来看看代码是怎么实现的。
这样我们就完成了 reactive
的逻辑,我们来看看实际效果是否正确。
这里我们可以看到,无论是我们直接去改变级联对象中的 b
,还是给 a
重新赋值一个 {b: 66}
都是可以触发我们的 effect
回调函数的。
这就意味着我们最后的 function 已经能够成功地调用和执行了。到这里为止我们的 proxy
和 reactive
的实现和基本的模型就已经有了。当然还有很多细节,是需要我们用大量的 test case 去保证一些边缘的情况的。如果大家想看看一个真正完成的 reactivity
这个库是怎么写的,那么我们可以参考 Vue 的源代码。大家就可以看到这个代码量是我们这个的好几倍不止。
所以讲原理和实际操作还是有比较大的区别的,希望大家在学习的时候都理解这一点,要不然我们直接把这些代码拿过去生产使用,那就要出问题了。
Reactive 响应式对象
接下来我们来考虑一下 Reactive
到底有什么应用场景。这个也是很多同学在 Vue 3.0 出来了以后,在疯狂的问的一个问题。
其实
Reactive
它是一个半成品的双向绑定,它可以负责从数据到 DOM 元素这一条线的监听。从 DOM 元素到数据的这一条线的监听其实很简单,因为 DOM 元素本来就有事件。然后任何的原生输入都可以代理到这个reactive
的代理里面。
我们接下来就考虑一个实际的例子,这里我们来做一个输入的单向绑定来看看。
我们给我们之前实现的
reactive
中加入一个input
元素我们给这个
input
绑上一个id="r"
改造一下我们的
object
数据结构在我们的
effect
当中加入单向绑定(从数据到 input)
然后我们在浏览器运行时,我们会看到 input 中的数字是 1
,然后在 console
中输入 po.r = 10
,我们就会发现数据会被同步到我们的 input 中。
就是这样我们就已经实现了初步的数据绑定了。那么如果我们想实现 双向绑定
需要怎么做呢?其实也很简单,我们只需要加入 addEventListener
即可实现双向绑定。
在我们的 effect
调用的后面加入下面一行代码即可:
这个时候我们回到我们的浏览器,在 input 中尝试输入数字,我们就会发现我们的 po.r
的值也会响应到值的变化。
接下来我们尝试实现一个 "响应的颜色选择器":
我们之前已经有一个属性
r
,现在我们在object
里面补上b
和g
。也同时加上这两个单独的 input 元素,分别的 id 是
id="b"
和id="g"
然后我们再多加两个
effect
各自给到b
和g
的 input 元素的。给 input 元素中加入
type="range"
,并且给他们都加上最大与最小值数据双绑定的代码也要给
b
和g
加上建立一个
div
元素,加上属性id="color"
,style="width:100px; height: 100px"
最后我们需要加入一个全局的 effect,这里面需要在任何 input 输入变动的时候,响应改变我们 div 盒子的背景颜色
我们来把这些逻辑写成代码:
在浏览器中运行,我们就可以看到上方有三个滑块,通过拖动改变每一个滑块的值,下面的盒子的背景颜色就会根据 r
、g
、b
三个值而变化。
我们这里所写的代码,仅仅是对它的变量和值进行了一下简单的绑定关系。
如果说我们再配合一定的语法糖,比如说我们的 build compiler
那么我们就完全可以把它变成一个零代码的双向绑定模式。
这也正是双向绑定一个强大之处,在很多时候我们交互不需要使用代码,即可实现交互。其实想想以前我们用 Jquery 做很多的交互逻辑代码,我们需要写很多的逻辑和 update 代码来实现一个交互的过程,而有了双向绑定后,我们可以花更多时间精力专注于编写 Vue 和输入的关系。
这一切都是基于我们拥有了 reactivity
这种响应式对象,那么 Vue 的 reactivity 包被拆出来之后,也会给大家带来更有意思的想法和实践。
博主开始在B站直播学习,欢迎过来《直播间》一起学习。
我们在这里互相监督,互相鼓励,互相努力走上人生学习之路,让学习改变我们生活!
学习的路上,很枯燥,很寂寞,但是希望这样可以给我们彼此带来多一点陪伴,多一点鼓励。我们一起加油吧! (๑ •̀ㅂ•́)و
我是来自《技术银河》的*三钻*,一位正在重塑知识的技术人。下期再见。
版权声明: 本文为 InfoQ 作者【三钻】的原创文章。
原文链接:【http://xie.infoq.cn/article/1701c4dbdba9c562d677b8670】。文章转载请联系作者。
评论