响应式系统的作用和实现
响应系统是Vue.js的重要组成部分,主要由两部分组成,一是响应式数据,二是副作用函数,副作用函数本质上就是函数包裹器,用于跟踪正在运行的函数,在函数调用前启动跟踪,在Vue派发更新时就能找到这些被收集起来的副作用函数,当数据发生更新时就会重新执行它
响应式系统的工作流程:当读取操作时,将副作用函数收集到"桶"中;当设置操作时,从桶中取出副作用函数并执行
下面是组件自身初始化的部分代码
由上述的组件对象,可以执行以下操作
const { data , render } = 组件对象
(1) 调用data函数,将其包装成响应式数据
const state = reactive(data())
effect(()=>{
(2) 调用render函数得到subTree
const subTree = render.call(state,state)
(3) patch
patch(null,subTree)
})
这样实现了组件自身的响应式数据发生变化,那么组件就会自动执行渲染函数,从而实现更新
1.响应式数据和副作用函数
副作用函数:会产生副作用的函数(废话)
响应式数据:在我们读取某值是,添加副作用函数,待值发生改变,即设置操作,可以使副作用函数重新执行
2.响应式数据的基本实现
如何将数据obj变成响应式?
主要两个步骤(还是上面所述):
- 读取obj时,将副作用函数effect存储在某个数据结构“桶”中(weakMap)
- 当进行设置操作时,在将副作用函数effect从“桶”中取出并执行
Vue2中采用的Object.definePrototype函数实现,而Vue3中采用的是代理的方式
首先我么需要知道Object.definePrototype函数的所出现的问题:
- Object.defineProperty()监听对象的属性而非对象本身
- 这就导致了必须遍历对象中的每个属性
- 如果对象中某属性依旧是对象,需要递归调用
- 对于动态插入对象的属性,需要手动添加监听
- 无法监听数组变化,这也就是Vue2为何重写了数组中七个方法的原因(push,pop,unshift,shift,reverse,sort,splice)这些方法调用原数组会发生改变
为了解决上述一些问题,Vue3引入了Proxy,那么Proxy有哪些优点? - Proxy可以直接监听数组变化
- Proxy可以直接监听对象而非对象属性
- Proxy有13种拦截方法,更加丰富
通过一个简单的例子了解一下:
const bucket = new Set()
const data = {
text:'hello'
}
let proxy = new Proxy(data,{
get(target,key){
bucket.add(effect)
return target[key]
},
set(target,key,newValue){
target[key] = newValue
bucket.forEach(fn=>fn())
return true
}
})
//注册副作用函数
function effect(){
document.body.innerHTML = proxy.text
}
//执行副作用函数,触发读取操作
effect()
setTimeout(()=>{
proxy.text = 'Vue'
},1000)
3.完善的响应式系统
上面代码是个小型的简单的响应系统,出现的问题已经还是很多,硬编码了副作用函数,我们需要的是哪怕副作用函数是一个匿名函数,也可以被正确的收集到“桶”数据结构中,另外,如何设计这个“桶”结构又是个问题
首先需要设计一个注册副作用函数的effect函数,那么即使是匿名函数可以先注册,再被收集
//使用全局变量存储被注册的副作用函数
let activeEffect
//注册副作用函数
function effect(fn){
activeEffect = fn
fn()
}
桶的设计需要将副作用函数和被操作的目标字段之间建立明确联系,所以需要在三个角色中建立联系,被操作的代理对象obj,被操作的字段名text,使用effect函数注册的副作用函数effectFn,Vue3中采用的WeakMap,WeakMap由target和Map组成,Map由key和Set组成,WeakMap的键是目标对象target,值是一个Map实例,而Map实例时原始对象target的key,Map的值是由一个副作用函数组成的Set
至于为什么使用WeakMap原因:
- 这是ES6新增的弱映射,什么叫弱,描述的是JS垃圾回收程序对待弱映射中键的方式
- WeakMap的键必须是对象,所引用的对象是弱引用,如果对象的其他引用被删除,那么回收机制就会释放该对象所占的内存
- 对象作为WeakMap的键,如果没有指向该对象的应用,那么当代吗执行完,这个对象键就会消失,然后这个键/对就会从弱映射中消失,成为一个空映射,那么就会变成垃圾回收的目标
- 可以设想,如果target对象没有任何应用,说明用户并不需要它了,那么垃圾回收机制就会完成回收任务,但是如果通过Map来代替WeakMap,那么用户的代码没有任何应用,这个target也不会被回收,最终就会导致内存的溢出
将上述代码进行补充和封装,得到代码如下:
const proxy = new Proxy(data,{
get(target,key){
track(target,key)
return target[key]
},
set(target,key,newValue){
target[key] = newValue
trigger(target,key)
}
})
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)
}
function trigger(target,key){
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn());
}
4.computed和lazy
调度器scheduler用来控制副作用函数执行的时机和方式
基本流程:
首先定义一个conputed函数,接收参数为getter函数,将getter函数作为副作用函数,用它来创建一个带有Lazy的effect.computed函数会返回一个对象,该对象的value属性是一个访问器属性,只有在读取value值时,才会手动调用effectFn函数并返回结果。另外,在调度器中增加dirty属性标识确定是否需要重新计算,如果dirty为true时,表示值被污染,那么需要重新计算。computed的值可以被缓存就是这个原理
function computed(getter){
let value,dirty = true
const effectFn = effect(getter,{
lazy:true,
scheduler(){
if(!dirty){
dirty = true
//当计算属性依赖的响应式数据发生变化,手动调用trigger函数触发响应
trigger(obj,'value')
}
}
})
const obj = {
get value(){
if(dirty){
value = effectFn()
dirty = false
}
//当读取value时,手动调用track函数进行跟踪
track(obj,'value')
return value
}
}
return obj
}
5.watch实现
本质上就是检测响应式数据,当数据发生变化时通知并执行响应的回调函数
定义一个watch函数,接收的参数是source和cb,如果source是函数,那么将其传给getter;如果是对象,则递归读取,通过调度器执行回调函数cb
watch(
() => obj.foo,
(newValue,oldValue) => {
console.log(newValue,oldValue)//1,2
}
)
obj.foo ++
function watch(source,cb){
let getter
if(typeof source === 'function'){
getter = source
}else{
getter = ()=> traverse(source)
}
//定义新值和旧值
let oldValue , newValue
const effectFn = effect(
() => getter(),
{
lazy:true,
scheduler(){
newValue = effectFn()
cb(newValue,oldValue)
oldValue = newValue
}
}
)
oldValue = effectFn()
}
//如果传入的参数是对象,这样就能读取对象上的任意属性,从而属性发生变化都能触发回调函数的执行
function traverse(value,seen = new Set()){
//如果读取的值是原始值或者已经读取过了,那么直接跳过
if(typeof value !== 'object' || value == null || seen.has(value)) return
seen.add(value)
for(let k in value){
traverse(value[k],seen)
}
return value
}
非原始值的响应式方案
Proxy和Reflect
代理和反射是为开发者提供拦截并向基本操作嵌入额外行为的能力
代理是对目标对象的一种关联的代理对象,而这个代理对象可以作为抽象的目标对象来进行使用
const target = {
id:'001'
}
const handler = {}
const proxy = new Proxy(target,handler)
console.log(target.id)//001
console.log(proxy.id)//001
代理是目标对象的抽象,使用代理的目标就是为了定义捕获器,捕获器就是在处理程序对象中定义基本操作的拦截器,每个捕获器都对应一种基本操作,可以直接在代理对象上调用;处理程序对象中所有可以捕获的方法都有响应的反射(Reflect)API方法,这些方法与捕获器拦截的方法具有想用的名称和函数签名,而且也具有与被拦截方法相同的行为
1.get()
const target = { }
const proxy = new Proxy(target,{
get( target,property,receiver){
console.log('get()')
return Reflect.get(...arguments)
}
})
proxy.foo//get()
//捕获器处理程序参数
--target:目标对象
--property:应用目标对象上的字符串键属性
--receiver代理对象或继承代理对象的对象
2.set()
const target = { }
const proxy = new Proxy(target,{
set( target,property,value,receiver){
console.log('set()')
return Reflect.set(...arguments)
}
})
proxy.foo = 'bar'//set()
//拦截操作
--proxy.property = value
--proxy[property] = value
//捕获器处理程序参数
--target:目标对象
--property:应用目标对象上的字符串键属性
--value:赋值给属性的值
--receiver代理对象或继承代理对象的对象
3.has()
const target = { }
const proxy = new Proxy(target,{
has( target,property){
console.log('has()')
return Reflect.has(...arguments)
}
})
'foo' in proxy//has()
那么为什么Proxy要配合Reflect一起使用呢
- 触发代理对象劫持时保证正确的this上下文指向
在内部打印
console.log(receiver === proxy)//true
表示这里的receiver的确是代理对象,可以将Reflect.get(target,key,receiver)理解为target[key].call(receiver),this变成了代理对象,才会在副作用函数和响应式数据之间建立响应联系,从而达到收集依赖的效果。
2. 框架的健壮性
使用 Object.defineProperty() 重复声明的属性 报错了,因为 JavaScript 是单线程语言,一旦抛出异常,后边的任何逻辑都不会执行,所以为了避免这种情况,我们在底层就要写 大量的 try catch 来避免,不够优雅。使用 **Reflect.defineProperty() 是有返回值的,所以通过 返回值 来判断你当前操作是否成功
代理对象
对象必要的内部使用方法
- [[GetPrototypeOf]] 查明为该对象提供继承属性的对象
- [[SetPrototypeOf]] 将该对象与提供继承属性的另一个对象向关联
- [[IsExtensible]] 查明是否允许向该对象添加其他属性
- [[PreventExtensions]] 控制是否允许向该对象添加其他属性
- [[GetOwnProperty]] 返回该对象自身属性的描述符
- [[DefineOwnProperty]] 创建或更改自己的属性
- [[HasProperty]] 返回该对象是否已经拥有键为propertyKey的自己的或继承的属性
- [[Get]]
- [[Set]]
- [[Delete]]
- [[OwnPropertyKeys]] 返回list,元素为对象自身的属性键
- [[Call]] 将运行代码和this对象关联
- [[Constructor]] 创建一个对象,
代理对象本质上就是进行“读取”操作,响应系统拦截一切读取操作,数据发生变化时触发响应
正常对象的可能读取操作如下: - 访问属性:obj.foo
- 判断给定key是否在对象或原型上:key in obj
- 使用for…in循环遍历对象:for(let key in obj){}
对于正常的额访问属性,直接通过get拦截函数实现
const obj = {foo:1}
const p = new Proxy(obj,{
get( target,key,receiver){
//建立联系
track(target,key)
//返回属性值
return Reflect.get(target,key,receiver)
}
})
in关键字如何拦截,通过has
const obj = {foo:1}
const p = new Proxy(obj,{
has(target,key){
track(target,key)
return Reflect.has(target,key)
}
})
for…in循环
const obj = {foo:1}
const ITERATE_KEY = Symbol()
let p = new Proxy(obj,{
ownKeys(target){
track(target,ITERATE_KEY)
return Reflect.ownKeys(target)
}
})