基于Proxy从0到1实现响应式数据
一、前言
基于Proxy从0到1实现响应式数据,读完本文你会收获:
- 什么是响应式数据
- 响应式数据的实现原理
- 在通过Proxy实现响应式数据时,Proxy中的
get
和set
都分别做了什么
二、副作用函数
在本文开始前我们先理解一个概念副作用函数
副作用函数是什么?
副作用的函数不仅仅只是返回了一个值,而且还做了其他的事情:
- 修改了一个变量
- 直接修改数据结构
- 设置一个对象的成员
- 抛出一个异常或以一个错误终止
- 打印到终端或读取用户输入
- 读取或写入一个文件
通俗点理解就是副作用函数就是会产生副作用的函数
如下所示:
function effect() {
document.getElementById('text').innerHTML = obj.text
}
除了effect 函数之外的任何函数都可以读取或设置body的文本内容,也就是说,effect函数的执行会直接或间接影响其他函数的执行,这时就可以说effect函数产生了副作用
三、响应式数据
什么是响应式数据?
假设在一个副作用函数中读取了某个对象的属性:
const obj = { text: 'hello anju' }
function effect() {
document.getElementById('text').innerHTML = obj.text
}
这时我们希望当obj.text 发生变化时,副作用函数 effect会重新执行
obj.text = 'hello world'
如果能实现这个目标,那对象obj就是响应式数据
实现一个基础版响应式系统
观察如下代码:
const obj = { text: 'hello anju' }
function effect() {
document.getElementById('text').innerHTML = obj.text
}
1、当副作用函数effect执行
时,会触发字段 obj.text
的 读取 操作;
2、当修改
obj.text的值时,会触发字段obj.text
的 设置 操作
那么如果我们能拦截一个对象的
读取
和设置
操作是不是就可以让事情变得简单? 如何拦截?
这里我们看一下vue的拦截方式:
1、vue2:Object.defineProperty()
2、vue3:Proxy
- Proxy 对象用于定义基本操作的自定义行为(如属性查找、赋值、枚举、函数调用等);
- Proxy 用于修改某些操作的默认行为,也可以理解为在
目标对象之前架设一层拦截
,外部所有的访问都必须先通过这层拦截,因此提供了一种机制,可以对外部的访问进行过滤和修改
因为Object.defineProperty()
存在一定的缺陷,所以这里我们采用Proxy
来实现
首先我们定义一个存储副作用函数的桶
// 存储副作用函数的桶
const bucket = new Set()
// 原始数据
const data = { text: 'hello anju' }
使用Proxy代理原始数据
// 代理原始数据
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数存储至桶中
bucket.add(effect)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 把副作用函数从桶里取出并执行
bucket.forEach(fn => fn())
// 返回true 代表设置成功
return true
}
})
我们执行下实例代码(因为是在vue项目里写的实例代码,所以这里用onMounted
):
function effect() {
document.getElementById('text').innerHTML = obj.text
}
onMounted(() => {
effect()
setTimeout(() => {
obj.text = 'hello vue3'
}, 2000)
})
看下执行效果:
成功(。◝‿◜。),至此,一个基础版的响应式系统就实现了
设计一个完善的响应式系统
我们目前实现的只是一个基础版的响应式系统,那跟完善的响应式系统相比我们还差哪些东西?
首先,我们可以看到我们刚实现的基础版的响应式系统存在一个硬编码
的问题,耦合度高,过度依赖副作用函数的名称(effect)
所以我们要优先解决下硬编码
的问题,这里我们再次的观察一下我们刚实现的基础版响应式数据
,想一想一个响应式系统的工作流程
是什么?
// 存储副作用函数的桶
const bucket = new Set()
// 原始数据
const data = { text: 'hello anju' }
// 代理原始数据
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数存储至桶中
bucket.add(effect)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 把副作用函数从桶里取出并执行
bucket.forEach(fn => fn())
// 返回true 代表设置成功
return true
}
})
响应式系统的工作流程:
- 读取操作发生时,将副作用函数存储在桶中;
- 设置操作发生时,将副作用函数从桶中取出并执行
所以这里我们就要提供一个机制,能去注册副作用函数
:
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
// 调用effect 注册副作用函数时,将副作用函数fn 赋值给activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}
加入此机制,重新改善下我们基础版的响应式系统:
// 触发响应式数据obj.text 的读取操作,
// 进而触发代理对象Proxy的 get 拦截函数
effect(
// 匿名的副作用函数
() => {
document.getElementById('text').innerHTML = obj.text
}
)
const obj = new Proxy(data, {
get(target, key) {
// 将activeEffect中存储的副作用函数收集至桶中
if (activeEffect) {
bucket.add(activeEffect)
}
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
bucket.forEach(fn => fn())
return true
}
})
(。◝‿◜。),至此,一个解决了硬编码问题的响应式系统就实现了
但是,到这里我们的响应式系统还是不够完善,如果我们给响应式数据obj上设置一个不存在的属性时,会发生什么呢?
effect(
() => {
console.log('effect run')
document.getElementById('text').innerHTML = obj.text
}
)
setTimeout(() => {
obj.notExist = 'hello vue3'
})
执行结果:
这里我们会发现匿名副作用函数执行了两遍
。在副作用函数里我们并没有读取 obj.notExist 的值,理论上 obj.notExist 并没有与副作用建立响应关系
,因此定时器内的语句执行不应该触发匿名副作用函数重新执行
,所以造成这样的原因是什么呢?
首先我们看下我们设计存储副作用函数的桶用的是什么数据结构:
const bucket = new Set()
所以问题的根本原因是:我们没有在副作用函数与被操作的目标字段之间建立明确的关系
当读取
属性时,无论读取的是哪一个属性,其实都一样,都会把副作用函数收集到桶中
当设置
属性时,无论设置的是哪一个属性,也都会把桶里的副作用函数取出并执行
副作用函数与被操作的字段直接没有明确的联系
,所以我们要在副作用函数
与被操作字段
之间建立联系
即可,我们需要重新设计我们的桶的数据结构,不能简单的使用一个Set类型的数据作为桶
那我们应该设计一个什么样的数据结构呢?
首先,观察如下代码:
effect(
effectFn(()=> {
document.getElementById('text').innerHTML = obj.text
})
)
上述代码存在三个角色:
- 被操作(读取)的代理对象 obj
- 被操作(读取)的字段名 text
- 使用effect 函数注册的副作用函数effectFn
那么这三者的关系是什么?
这里我们
用 target 来表示一个代理对象所代理的原始对象
用 key 来表示被操作的字段名
用 effectFn 来表示被注册的副作用函数
关系如下:
如果有两个副作用函数同时读取了同一个对象的属性值
effect(
effectFn1(()=>{
obj.text
})
)
effect(
effectFn2(()=>{
obj.text
})
)
关系如下:
一个副作用函数读取了同一个对象的两个不同属性值
effect(
effectFn(()=>{
obj.text1
obj.text2
})
)
关系如下:
综上所述,这其实就是一个树形数据结构,建立起这个关系,就可以解决我们的问题
首先我们需要 使用 weakMap
代替 Set
作为桶的数据结构(weakMap对key是弱引用,不影响垃圾回收器的工作)
// 存储副作用函数的桶
const bucket = new WeakMap()
然后修改 get/set拦截器的代码
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
//没有 activeEffect 直接return
if(!activeEffect) return
// 根据 target 从桶中取得 depsMap, 它也是一个 Map 类型:key --> effects
let depsMap = bucket.get(target)
// 如果不存在 depsMap,那么新建一个Map 并与 target 关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 再根据key从 depsMap中取得 deps,它是一个set 类型
// 里面存储着所有与当前 key 相关联的副作用函数 effects
let deps = depsMap.get(key)
// 如果不存在 deps,同样新建一个 Set 并与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 最后将当前激活的副作用函数添加到 桶中
deps.add(activeEffect)
// 返回值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 根据target 从桶中取得 depsMap, 它是 key --> effects
const depsMap = bucket.get(key)
if(!depsMap) return
// 根据key 取得所有副作用函数 effects
const effects = depsMap.get(key)
// 执行副作用函数
effect && effect.forEach(fn => fn())
}
})
在上述代码内,我们分别使用了 WeakMap
、Map
、Set
- WeakMap 由
target
--->Map
构成 - Map 由
key
--->Set
构成
其中 WeakMap 的键是原始对象 target
, 值是一个Map实例
Map的键是原始对象 target 的key
,值是一个由副作用函数组成的Set
三者关系如下:
最后,我们在提取封装下我们的代码:
将把副作用函数收集至桶中的逻辑封装至 track(追踪)
函数
把触发副作用函数重新执行的逻辑封装至 trigger(触发)
函数
const obj = new Proxy(data, {
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
tarck(target, key)
// 返回属性值
return target[key]
},
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 取出副作用函数并执行
trigger(target, key)
}
})
track
function track(target, key) {
if(!activeEffect) return
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
}
trigger
function trigger(target, key) {
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
结尾
(。◝‿◜。),至此我们本篇《基于Proxy从0到1实现响应式数据》就结束了,感谢大家阅读(。◝‿◜。),如果有任何问题欢迎在评论区指出
参考内容:
[1] 霍春阳《Vue.js 设计与实现》
相关文章
- Wildfly 跨域相关响应头配置
- SpringBoot:如何优雅地进行响应数据封装、异常处理?
- 响应式架构,也许只是杯有毒的美酒(上)
- MySQL数据库:第十五章:MySQL安装到最后一步未响应MySQL Server Instance Configuration Wizard
- [029] 微信公众帐号开发教程第5篇-各种消息的接收与响应[通俗易懂]
- Vue响应式依赖收集原理分析
- SwiftUI 与 Core Data —— 安全地响应数据
- 表格集算表高性能原理——怎样实现纯前端百万行数据秒级响应
- 《爆肝整理》保姆级系列教程-玩转Charles抓包神器教程(10)-Charles如何修改请求参数和响应数据-下篇
- 开关电源中输出电容的ESR会影响负载的动态响应
- window + apache + django + mod_wsgi 一直响应中的解决办法
- 响应式一些知识详解编程语言
- 优化Linux系统瞬时响应时间(linux中断响应时间)
- 响应MySQL服务停止响应:解决措施分析(mysql服务没有)
- 实现快速响应将数据缓存到Redis(缓存到redis数据结构)
- 从Redis缓存拉取后端响应数据一种实现方式(后端响应的Redis值)
- 使用Redis预加载实现快速响应(redis预加载数据)
- Redis倾斜性与响应的妥协(redis 请求倾斜)
- js获取键盘按键响应事件(兼容各浏览器)