Vue3响应式对象是如何实现的(1)
目录
- 简单的响应式实现
- Proxy与响应式
- 为什么需要Proxy?
- Proxy创建的代理对象与原始对象有何不同?
- 多副作用函数的响应式实现
简单的响应式实现
为了方便说明,先来看一个简单的例子。
const obj = { text: 'hello vue' } function effect() { document.body.innerText = obj.text }
这段代码中,如果obj
是一个响应式数据,会产生什么效果呢?当obj.text
中的内容改变时,document.body.innerText
也会随之改变,从而修改页面上显示的内容。因此,如果仅从这个简单的例子出发,在修改obj
后,再次执行effect()
,就能够实现响应式。此时,effect()
被称为副作用函数,其副作用体现在document.body.innerText
这个非effect()
函数作用域内的值被修改了。
实际使用中,响应式实现要复杂的多。为了进一步实现响应式,让我们基于上文的例子,再来捋一捋响应式实现需要什么。我们需要在修改响应式数据后触发副作用函数。仔细思考这句话,这里其实要求了两个功能:
- 当
obj
被读取时,收集副作用函数; - 当
obj
被修改时,触发收集的副作用函数;
问题更清晰了,我们只需要拦截读取和修改操作分别进行收集和触发,就能够实现响应式了。这里我们可以使用Proxy
Proxy与响应式
为什么需要Proxy?
Vue3的核心特征之一就是响应式,而实现数据响应式依赖于底层的Proxy。因此,想要完成Vue的响应式功能,首先需要理解Proxy。
以reactive
为例,当想要创建一个响应式对象时,仅需要使用reactive
对原始对象进行包裹。
例如:
const user = reactive({ age: 25 })
在Vue3的官方文档中,对reactive
的描述是:返回对象的响应式副本。既然是响应式副本,就需要产生一个与原始对象含有相同内容的响应式对象,之后使用这个响应式对象替代原始对象,代替原始对象去做事情。这就体现了Proxy的价值——创建原始对象的代理。
Proxy创建的代理对象与原始对象有何不同?
先来看看如何使用Proxy创建代理对象。
let proxy = new Proxy(target, handler)
Proxy可以接收两个参数,其中,target
表示被代理的原始对象,handler
表示代理配置,代理配置中的捕捉器允许我们拦截并重新定义针对代理对象的操作。因此,如果在Proxy中,不进行任何代理配置,Proxy仅会成为原始对象的一个透明包装器(仅创建原始对象副本,但不产生任何其它功能)。因此,为了利用Proxy实现响应式对象,我们必须使用Proxy中的代理配置。
在JavaScript规范中,存在一个描述JavaScript运行机制的“内部方法”。其中,读取属性的操作被称为[[Get]]
,设置属性的操作被称为[[Set]]
。通常,我们无法直接使用这些“内部方法”,但Proxy给我们提供了捕捉器,使得对读取和设置操作的拦截成为可能。
const p = new Proxy(obj, { get(target, property, receiver) {/*拦截读取属性操作*/} set(target, property, value, receiver) {/*拦截设置属性操作*/} })
其中,target
为被代理的原始对象,property
为原始对象的属性名,value
为设置目标属性的值receiver
是属性所在的this
对象,即Proxy代理对象。
Proxy
是由JavaSciprt引擎实现的ES6特性,因此,没有ES5的polyfill,也无法使用Babel转译。这也是Vue3不支持低版本IE的主要原因。
let fn const data = { text: 'hello vue' } const obj = new Proxy(data, { get(target, key) { fn = effect return target[key] }, set(target, key, newValue) { target[key] = newValue fn() return true } })
多副作用函数的响应式实现
如果响应式数据有多个相关的副作用函数应该如何处理。
在上例中,存储一个副作用函数我们可以使用一个fn
进行缓存,多个副作用函数,我们在收集时用数组把它装起来就好,需要触发时,再遍历数组挨个触发收集到的副作用函数。
const fns = [] const data = { text: 'hello vue' } const obj = new Proxy(data, { get(target, key) { fns.push(effect) return target[key] }, set(target, key, newValue) { target[key] = newValue fns.forEach(fn => fn()) return true } })
这样可以吗?可以是可以,功能上已经实现了。接下来让我们来看看有没有哪些地方可以优化。
第一个可以优化的点是,需要考虑被重复收集的函数。
举个例子:
const effect1 = () => { document.body.innerText = obj.text } const effect2 = effect1
在这个例子中,effect1
与effect2
都表示同一个函数,但在收集时会被重复收集,执行时也会被重复执行。这些重复显然是不必要的,因此,我们需要在收集副作用函数的时候,干掉重复的函数。去重可以考虑Set
。
const fns = new Set() const data = { text: 'hello vue' } const obj = new Proxy(data, { get(target, key) { fns.push(effect) return target[key] }, set(target, key, newValue) { target[key] = newValue fns.forEach(fn => fn()) return true } })
不知道大家是否注意到,为了演示方便,我们在收集元素时,都默认被收集的副作用函数名是effect
。但实际开发中,函数名肯定不会是固定的或是有规律可循的,我们肯定不能按函数名一个个手动收集,因此,我们要想一种方式能够不依赖于函数名进行副作用函数收集。为了实现这一点,我们将副作用函数进行包裹,用一个activeEffect
统一注册。
let activeEffect function effect(fn) { activeEffect = fn fn() } const fns = new Set() const data = { text: 'hello vue' } const obj = new Proxy(data, { get(target, key) { if(activeEffect) { fns.add(activeEffect) } return target[key] }, set(target, key, newValue) { target[key] = newValue fns.forEach(fn => fn()) return true } }) function fn() { document.body.innerText = obj.text } effect(fn)
到这里,我们已经把容易想到的优化都处理了。但这还没完,这里还有个隐蔽的点没有考虑到。此时,我们的响应式粒度还不够细,副作用函数的收集和触发的最小单位是响应式对象,这会导致不必要的副作用函数更新。例如,我们给出一个obj
上不存在的属性obj.notExist
,对其进行赋值操作。为了方便演示,我们在fn()
中加一条打印语句,观察结果。
function fn() { document.body.innerText = obj.text console.log('Done!') }
可以看到,副作用函数被触发了。这里obj.notExist
属性在obj
上是不存在的,更谈不上对应了哪个副作用函数,因此,这里的副作用函数不应该被触发,或者换句话说,副作用函数仅能被对应的响应式对象的属性影响,且仅在该属性被修改时触发。
更细粒度的触发条件就要求我们收集副作用函数时,针对响应式对象的属性进行收集。听上去有点无从下手,让我们再来捋一捋。既然是响应式对象,首先还是要保证利用响应式对象的Proxy
来进行收集和触发,但在收集的时候,就不能一股脑的把所有属性对应的副作用函数塞到一块了,需要把谁是谁的分清楚(即一个属性对应了哪些副作用函数),让每个属性拥有自己收集和触发副作用函数的能力,具体对应关系如图所示。
让我们从后往前看。首先,属性的副作用函数收集器我们可以沿用上文的思路,使用Set
实现。再往前,每个属性都需要配备一个副作用函数收集器,这是一个一对一的关系,我们可以使用将属性值作为key,副作用收集器作为value,使用Map
实现,而这个Map
就包含了单个响应式对象的全部内容。
回看我们上面的代码,其实我们在进行副作用函数收集的时候,仅使用了一个全局的容器承载。在实际开发中,我们需要尽量避免大量出现全局定义。因此,我们将响应式对象放进一个全局容器中统一管理。我们需要将每一个对象和其对应的Map
建立一对一的映射关系。整体关系如下图所示:
按图中思路,我们在代码中重新组织数据结构。
let activeEffect function effect(fn) { activeEffect = fn fn() } const objsMap = new Map() // 全局容器 const data = { text: 'hello vue' } const obj = new Proxy(data, { get(target, key) { if(!activeEffect) return let propsMap = objsMap.get(target) // 属性容器 if(!propsMap) { objsMap.set(target, (propsMap = new Map())) } let fns = propsMap.get(key) // 副作用函数收集器 if(!fns) { propsMap.set(key, (fns = new Set())) } fns.add(activeEffect) return target[key] }, set(target, key, newValue) { target[key] = newValue const propsMap = objsMap.get(target) // 属性容器 if(!propsMap) return const fns = propsMap.get(key) // 副作用函数收集器 fns && fns.forEach(fn => fn()) return true } }) function fn() { document.body.innerText = obj.text console.log('Done!') } effect(fn)
执行我们更新的代码,我们就能避免无效更新,只触发与属性相关的更新了。
到这结束了吗?我们其实还能再做一点优化。如果一个响应式对象没有被引用时,说明这个对象不被使用,不被使用的对象应该被JavaScript的垃圾回收器回收,避免造成内存占用。但Map
的特性(Map
会被其key值持续引用)决定了,即使没有任何引用,Map
中的对象也不能被垃圾回收器回收。解决这个问题,只需要用WeakMap
来代替Map
,WeakMap
的key是弱引用。到这里我们终于大功告成了,把功能实现完了。
功能实现完接下来要干嘛?对,要重构,要看有没有能重构的地方。get
和set
中的内容有点臃肿了,想想一开始说的,get
时收集,set
时触发。最后,就让我们来抽离封装这两部分。
let activeEffect function effect(fn) { activeEffect = fn fn() } const objsMap = new WeakMap() const data = { text: 'hello vue' } const obj = new Proxy(data, { get(target, key) { track(target, key) return target[key] }, set(target, key, newValue) { target[key] = newValue trigger(target, key) return true } }) // 收集 function track(target, key) { if(!activeEffect) return let propsMap = objsMap.get(target) if(!propsMap) { objsMap.set(target, (propsMap = new Map())) } let fns = propsMap.get(key) if(!fns) { propsMap.set(key, (fns = new Set())) } fns.add(activeEffect) } // 触发 function trigger(target, key) { const propsMap = objsMap.get(target) if(!propsMap) return const fns = propsMap.get(key) fns && fns.forEach(fn => fn()) } function fn() { document.body.innerText = obj.text console.log('Done!') } effect(fn)
到此这篇关于Vue3响应式对象是如何实现的的文章就介绍到这了,更多相关Vue3响应式内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!