书接上篇,了解了Vue是怎么渲染到页面之后,来学习一个非常重要的概念:响应系统
什么是响应系统
MVVM&响应式数据&副作用函数
- vue是一个MVVM框架,那什么是MVVM呢,MVVM实际上是一种架构,Model-View-ViewModel
- Model:数据层,可能是固定的死数据,更多的是来自服务器,从网络上请求下来的数据
- View:视图层,前端开发中,通常就是DOM层,主要的作用是给用户展示各种信息
- View-Model:视图模型层 ,视图模型层是View和Model沟通的桥梁。主要的功能有两个,一是Data Binding(数据绑定) ,将Model的改变实时的反应到View中。二是DOM Listener(DOM监听),监听DOM发生一些事件(点击、滚动、触摸等),并在需要的情况下改变对应的Data
- 响应式数据在vue2中是data中return的数据,vue3中是ref、reactive、computed包装的数据,具体的实现vue2基于Object.defineProperty,vue3是Proxy,从页面表现来讲,修改数据页面元素也跟着改变的这部分数据就是响应式数据
- 副作用函数,直接或间接影响其他函数的执行
实现一个响应系统
vue3是基于Proxy实现响应式数据,Proxy是一个代理对象,可以拦截对象属性的操作,如读取(get)、设置(set)等
- 参数:target handler
- target是需要被代理的对象
- handler是对被代理的对象的拦截操作
- handler
- set 是个函数,接收三个参数,分别是target(被代理的对象)、key(写入的属性)、val(修改后的值)、receiver(代理对象)
- get 是个函数,接收两个参数,分别是target(被代理的对象)、key(读取的属性)、receiver(代理对象)
- 简单举例
js
const obj = { a: 1 }
const proxyObj = new Proxy(obj, {
// 拦截读取操作
get(target, key, receiver) {
console.log(target === obj) // true
console.log(proxyObj === receiver) // true
return target[key]
},
// 拦截写入操作
set(target, key, val, receiver) {
target[key] = val
return true
}
})
proxyObj.a
上述代码中,obj是被代理的对象,proxyObj是代理对象,get和set中的target其实就是obj,receiver则是proxyObj
1. 简易版
期望达到的效果:实现一个响应式数据,修改后页面显示也跟着改变
实现思路:get拦截读取操作,把依赖存下来(存储到桶【bucket】里),set拦截设置操作,执行依赖
- ① 创建一个响应式对象
- ② 监听响应式对象的get和set
- ③ 调用副作用函数读取元素属性,并存储副作用函数
- ④ 修改数据执行副作用函数
js
const bucket = new Set()
const data = {a: 1}
const handler = {
// 拦截读取操作 存储副作用函数
get(target, key) {
bucket.add(effect)
return target[key]
},
// 拦截写入操作 执行副作用函数
set(target, key, value) {
target[key] = value
bucket.forEach(fn => fn())
return true
}
}
const obj = new Proxy(data, handler)
// 副作用函数
function effect() {
document.documentElement.innerText = obj.a
}
effect()
setTimeout(() => {
obj.a = '响应式'
}, 2000)
2.完善版
- 副作用函数应该是个匿名函数
上述示例中传入的effect函数是写死的,实际场景中需要灵活处理函数,所以这里通过匿名函数来增加灵活性
js
let activeEffect
function effect(fn) {
activeEffect = fn
fn()
}
effect(() => { document.documentElement.innerText = obj.a })
const handler = {
// 拦截读取操作 存储副作用函数
get(target, key) {
if (activeEffect) {
bucket.add(activeEffect)
}
return target[key]
},
// 拦截写入操作 执行副作用函数
set(target, key, value) {
target[key] = value
bucket.forEach(fn => fn())
return true
}
}
- 桶的数据结构
- 理想的数据结构应该是根据被读取的对象obj 的字段名key 来收集当前key的所有依赖effectFn ,在vue3中创建响应式数据通过ref、reactive、computed创建的实际就是一个代理对象
- 所以首先需要以被存储的对象作为key,key对应的value又需要通过对象的key来区分,对象的每个key对应不同的依赖
- 第一层:对象作为key,可以想到Map、WeakMap数据结构,通过WeakMap实现
- 第二层:对象的key需要与effectFn形成一对多的关系,通过Map实现
- 第三层:effectFn是收集的依赖,一个key可以产生多个依赖,通过Set实现
- 具体实现
js
const bucket = new WeakMap()
let activeEffect
function effect(fn) {
activeEffect = fn
fn()
}
const 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 就是一个与当前副作用函数存在联系的依赖集合
deps.add(activeEffect)
}
const trigger = (target, key, value) => {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
if (!effects) return
effects.forEach(fn => fn())
}
const handler = {
// 拦截读取操作 存储副作用函数
get(target, key) {
track(target, key)
return target[key]
},
// 拦截写入操作 执行副作用函数
set(target, key, value) {
target[key] = value
trigger(target, key, value)
}
}
const data = { a: 1 }
const obj = new Proxy(data, handler)
effect(() => { document.documentElement.innerText = obj.a })
obj.a = 2
细节
1. 分支切换与cleanup
- 问题1:现在有一个三元表达式如下所示,执行拦截操作的时候因为ok的值为true所以会分别收集ok和text的依赖,当ok的值变为false时理想情况时只剩下ok的依赖,但上述实现的effectFn函数还做不到这一点,它还是保存着ok和text的依赖,所以当text的值发生改变时,effectFn函数还是会被执行一遍,这个时候无论text的值怎么变化页面始终显示的都是not,所以并不需要再执行effectFn函数
js
const handler = {
// 拦截读取操作 存储副作用函数
get(target, key) {
console.log(key, 'get');
track(target, key)
return target[key]
},
// 拦截写入操作 执行副作用函数
set(target, key, value) {
console.log(key, 'set');
target[key] = value
trigger(target, key, value)
}
}
const data = { ok: true, text: 'hello world' }
const obj = new Proxy(data, handler)
effect(function effectFn() {
document.body.innerText = obj.ok ? obj.text : 'not'
})
obj.ok = false
obj.text = 'hello world1212' // 这句代码不应该再触发ok的get
- 思路:每次副作用函数执行时,先将与之相关的依赖删除,执行完毕后会重新建立联系
js
const cleanup = (effectFn) => {
for(let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
const bucket = new WeakMap()
let activeEffect
function effect(fn) {
const effectFn = () => {
cleanup(effectFn) // 新增 删除之前相关的依赖
activeEffect = fn
fn()
}
effectFn.deps = []
effectFn()
}
const 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)
activeEffect.deps.push(deps) // 新增 添加与当前副作用函数存在联系的依赖集合
}
const trigger = (target, key, value) => {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
if (!effects) return
effects.forEach(fn => fn())
}
- 问题2:上面的代码会触发死循环,分两种情况分析
- 1、没有修改ok的值时 - (1)、cleanup删除上次的依赖 - (2)、把活跃副作用函数设置成当前的effectFn - (3)、执行effectFn,也就是document.body.innerText = obj.ok ? obj.text : 'not'
- (4)、执行effectFn会触发ok和text的读取操作,也就是track函数,track函数会把依赖添加到bucket中,这时候bucket中的值如下图所示 - 2、修改ok的值 - (1)、首先先正常执行上述修改之前的流程 - (2)、修改ok的值,触发ok的设置操作,进入到trigger函数,trigger执行与ok相关的所有副作用函数,也就是effects.forEach(fn => fn())
,这时候effects还是上述bucket中与ok相关的副作用函数 - (3)、执行副作用函数时,先通过cleanup删除上次的依赖 ,也就是把上次的副作用函数effectFn删除了,再把活跃副作用函数设置成当前的effectFn,执行副作用函数,触发了ok的读取操作,进入到track函数,track函数把effectFn添加到bucket中 ,effects这时候又变成了上图中与ok相关的副作用函数,进而再次触发fn() ,又会按上述的过程执行,导致了死循环 - 根本原因:forEach 遍历 Set 集合 时,如果一个值已经被访问过了,但该值被删除并重新添加到集合, 如果此时 forEach 遍历没有结束,那么该值会重新被访问
- 思路:不要通过原来的Set执行副作用函数,创建一个新的Set存储这次需要执行的副作用函数
js
const trigger = (target, key, value) => {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
if (!effects) return
// effects.forEach(fn => fn()) // 删除
const effectsToRun = new Set(effects) // 新增 这次需要执行的副作用函数
effectsToRun.forEach(effectFn => effectFn()) // 新增 只执行这次需要执行的
}
2. 嵌套的effect
- 组件之间是可以嵌套的,上述实现的effect并不能实现,先看一段测试代码,这段代码期望实现的效果是修改 obj.foo 时会触发 effectFn1,由于 effectFn2 嵌套在 effectFn1 里,所以会间接触发 effectFn2 执行,而当修改 obj.bar 时,只会触发 effectFn2 执行
js
// 原始数据
const data = { foo: true, bar: true }
// 代理对象
const obj = new Proxy(data, handler)
// 全局变量
let temp1, temp2
effect(function effectFn1() {
console.log('effectFn1', '执行');
effect(function effectFn2() {
console.log('effectFn2', '执行');
// 在 effectFn2 中读取 obj.bar 属性
temp2 = obj.bar
})
// 在 effectFn1 中读取 obj.foo 属性
temp1 = obj.foo
})
obj.foo = false
// 打印结果
// 'effectFn1 执行'
// 'effectFn2 执行'
// 'effectFn2 执行'
- 问题:effect执行时设置的activeEffect只有一个,内层的副作用函数会覆盖外层的副作用函数,所以导致最后执行的是内层的副作用函数
- 思路:创建一个副作用函数栈effectStack,副作用函数执行时压入栈中,执行完毕弹出,activeEffect始终指向栈顶
js
let activeEffect
let effectStack = [] // 新增 创建副作用栈
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn) // 新增 执行前压入
fn()
effectStack.pop() // 新增 执行完弹出
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.deps = []
effectFn()
}
3. 无限递归
- 自增操作obj.foo++实际上是obj.foo = obj.foo + 1,会触发读取+设置
js
const data = { foo: 1 }
const obj = new Proxy(data, handler)
effect(() => obj.foo++)
- 问题:上述代码会导致控制台报错,原因是执行obj.foo++会先触发obj.foo的track操作,把副作用函数存入bucket中,接着加一后执行obj.foo的trigger操作,取出副作用函数并执行,但此时副作用函数还没执行完,就要开始下一次的执行,会导致无限递归调用自己,所以产生了栈溢出
- 思路:trigger执行前加一个守卫,如果trigger触发执行的副作用函数与正在执行的副作用函数相同,则不执行
js
const trigger = (target, key) => {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
if (!effects) return
const effectsToRun = new Set()
effects.forEach(effectFn => { // 新增
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => effectFn())
}