1、响应式数据的基本实现
响应式要符合两个基本特征,在方法执行时里面的字段时需要触发读操作,修改字段时要触发写操作。用两幅图来表达一下(es2015之前只能使用Object.defineProperty实现 Vue2,es2015之后可以可以使用Proxy Vue3)
2、实现一个微型的响应式系统
<body></body>
<script>
// 创建一个副作用桶
const tong = new Set()
// 原始数据
const data = { text: 'hello' }
// 对原始数据代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 放到桶里
tong.add(effect)
// 返回属性值
return target[key]
},
// 拦截写入操作
set(target, key, newValue) {
// 设置属性值
target[key] = newValue
// 副作用取出来并执行
tong.forEach(fn => fn())
// 返回true表示成功
return true
}
})
// 测试代码
// 副作用函数
function effect() {
document.body.innerText = obj.text
}
// 执行副作用函数,触发读取
effect()
// 1 秒后修改响应式数据
setTimeout(() => {
obj.text = 'hello yyx'
}, 1000)
</script>
完善响应式系统,添加activeEffect,使响应式系统不受方法名限制。
<body></body>
<script>
// 创建一个副作用桶
const tong = new Set()
// 原始数据
const data = { text: 'hello' }
// 对原始数据代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 放到桶里(但是换人了 activeEffect 哟~)
if (activeEffect) {
tong.add(activeEffect)
}
// 返回属性值
return target[key]
},
// 拦截写入操作
set(target, key, newValue) {
// 设置属性值
target[key] = newValue
// 副作用取出来并执行
tong.forEach(fn => fn())
// 返回true表示成功
return true
}
})
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
activeEffect = fn
// 执行fn
fn()
}
// 匿名函数方式,触发读取
effect(
// 一个匿名的副作用函数
() => {
console.log('effect run')
document.body.innerText = obj.text
}
)
// 1 秒后修改响应式数据
setTimeout(() => {
obj.text = 'hello yyx'
}, 1000)
</script>
再次优化,定时器尝中试修改obj中,不存在的字段,作用桶应该只调用一次,但是调用了两次,不存在的属性是不可以再次调用到了桶里的方法,所以需要进一步优化。
<body></body>
<script>
// 桶改为WeakMap map有key,可以区分桶中的东西
const tong = new WeakMap()
// 原始数据
const data = { text: 'hello' }
// 对原始数据代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 没activeEffect就 return
if (!activeEffect) {
return target[key]
}
// 桶中取得depsMap, 是map 类型 :key --> effects
let depsMap = tong.get(target)
// 如果不存在 depsMap,则新建一个Map 并与target 关联
if (!depsMap) {
tong.set(target, (depsMap = new Map()))
}
//再根据key从depsMap中取得deps, 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, newValue) {
// 设置属性值
target[key] = newValue
// 根据 target 从桶中取得 depsMap
const depsMap = tong.get(target)
if (!depsMap) return
// 根据key取得所有副作用函数
const effects = depsMap.get(key)
// 执行副作用
effects && effects.forEach(fn => fn())
}
})
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
activeEffect = fn
// 执行fn
fn()
}
// 匿名函数方式,触发读取
effect(
// 一个匿名的副作用函数
() => {
console.log('effect run')
document.body.innerText = obj.text
}
)
// 1 秒后修改响应式数据
setTimeout(() => {
obj.notExist = 'hello yyx'
}, 1000)
</script>
这回在没有对应字段时,不会调用两次作用方法了
下图为WeakMap结构图
拓展:WeakMap与Map的区别
WeakMap不会长期持有对象,对key是弱引用,不影响垃圾回收机制。Map则不管target是否有用仍然长期持有。
3、封装触发(trigger)方法和追踪(track)方法
封装set方法和get方法内部的操作,使其更加灵活。
<body></body>
<script>
// 桶改为WeakMap map有key,可以区分桶中的东西
const tong = new WeakMap()
// 原始数据
const data = { text: 'hello' }
// 对原始数据代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
track(target, key)
// 返回属性值
return target[key]
},
// 拦截写入操作
set(target, key, newValue) {
// 设置属性值
target[key] = newValue
// 副作用方法桶里取出并执行
trigger(target, key)
}
})
// 封装方法 get部分调用track方法追踪
function track(target, key){
// 没activeEffect就 return
if (!activeEffect) {
return target[key]
}
// 桶中取得depsMap, 是map 类型 :key --> effects
let depsMap = tong.get(target)
// 如果不存在 depsMap,则新建一个Map 并与target 关联
if (!depsMap) {
tong.set(target, (depsMap = new Map()))
}
//再根据key从depsMap中取得deps, deps是Set类型
// 里面存储着所有与当前 key 相关联的作用函数:effects
let deps = depsMap.get(key)
// 如果deps 不存在,新建一个 set 与key关联
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 最后激活的副作用方法塞到同理
deps.add(activeEffect)
}
//触发方法 set调用
function trigger(target, key) {
// 根据 target 从桶中取得 depsMap
const depsMap = tong.get(target)
if (!depsMap) return
// 根据key取得所有副作用函数
const effects = depsMap.get(key)
// 执行副作用
effects && effects.forEach(fn => fn())
}
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
activeEffect = fn
// 执行fn
fn()
}
// 匿名函数方式,触发读取
effect(
// 一个匿名的作用函数
() => {
console.log('effect run')
document.body.innerText = obj.text
}
)
// 1 秒后修改响应式数据
setTimeout(() => {
obj.notExist = 'hello yyx'
}, 1000)
</script>
4、分支切换和clean up
分支切换:举个简单的例子effectFn函数中存在三元表达式,在分支切换时会遗留作用。简单修改一下代码,在之前的obj中添加ok字段,通过ok进行三元表达式的判断。
effect(function effectFn() {
console.log('effect run')
document.body.innerText = obj.ok ? obj.text : 'not'
})
setTimeout(() => {
obj.ok = false
obj.text = 'hello vue3'
}, 1000)
修改了ok为false,触发更新,页面显示not,修改text字符串页面不会产生变化,但是仍然触发了更新,只是由于ok始终是false所以页面没有产生变化。
产生了不必要的更新,ok没有变化,由ok决定的变化不需要更新,所以响应式系统仍然需要继续改善。
响应式系统中副作用函数应该能明确知道包含哪些依赖集合,在每次执行副作用函数前先进行依赖集合的移除,解决了遗留副作用函数的问题,改造的关系图和代码如下
// 桶改为WeakMap map有key,可以区分桶中的东西
const tong = new WeakMap()
// 原始数据
const data = {ok: true, text: 'hello world' }
// 对原始数据代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
track(target, key)
// 返回属性值
return target[key]
},
// 拦截写入操作
set(target, key, newValue) {
// 设置属性值
target[key] = newValue
// 副作用方法桶里取出并执行
trigger(target, key)
}
})
// 封装方法 get部分调用track方法追踪
function track(target, key){
// 没activeEffect就 return
if (!activeEffect) return
// 桶中取得depsMap, 是map 类型 :key --> effects
let depsMap = tong.get(target)
// 如果不存在 depsMap,则新建一个Map 并与target 关联
if (!depsMap) {
tong.set(target, (depsMap = new Map()))
}
//再根据key从depsMap中取得deps, deps是Set类型
// 里面存储着所有与当前 key 相关联的副作用函数:effects
let deps = depsMap.get(key)
// 如果deps 不存在,新建一个 set 与key关联
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 最后激活的副作用方法塞到同理
deps.add(activeEffect)
//deps就是一个与当前作用函数存在联系的依赖
//添加到activeEffect.deps数组中
activeEffect.deps.push(deps)
}
//触发方法 set调用
function trigger(target, key) {
// 根据 target 从桶中取得 depsMap
const depsMap = tong.get(target)
if (!depsMap) return
// 根据key取得所有副作用函数
const effects = depsMap.get(key)
// 执行作用
effects && effects.forEach(fn => fn())
}
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
function effect(fn) {
const effectFn = () => {
//清除
cleanup(effectFn)
//当 effectFn 执行时,将其设置为激活当前的副作用函数
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖聚合
effectFn.deps = []
// 执行作用函数
effectFn()
}
//cleanup函数
function cleanup(effectFn) {
// 遍历 effectFn.deps 数组
for (let i = 0; i < effectFn.deps.length; i++) {
// deps是依赖集合
const deps = effectFn.deps[i]
// 将 effectFn 从依赖集合中移除
deps.delete(effectFn)
}
// 最后重置effectFn.deps数组
effectFn.deps.length = 0
}
上面代码仍然存在问题,运行时会产生无限循环的问题。调用副作用函数时会调用cleanup,清除操作就是effect副作用函数剔除,执行副作用函数又会加回来。解决的方法就是新增一个Set
//触发方法 set调用
function trigger(target, key) {
// 根据 target 从桶中取得 depsMap
const depsMap = tong.get(target)
if (!depsMap) return
// 根据key取得所有作用函数
const effects = depsMap.get(key)
const effectsToRun = new Set(effects)
effectsToRun.forEach(effectFn => effectFn())
}
添加了新的Set就可以避免遍历没结束就清空,导致无限循环的问题
5、嵌套effect和effect栈
当前的响应式系统仍然存在着问题,这个问题就是,不能嵌套,如果当前的响应式系统执行嵌套操作就会出现如下效果
补充如下代码进行测试
const data = { foo: true, bar: true }
let temp1, temp2
effect(function effectFn1() {
console.log('effectFn1 执行')
effect(function effectFn2() {
console.log('effectFn2 执行')
temp2 = obj.bar
})
temp1 = obj.foo
})
obj.foo = false
添加了全局的变量,和嵌套操作,前面可以顺利执行,但当修改foo值的时候出现了问题,foo在嵌套的外层,应该再次执行的是effectFn1,但是实际执行的却是effectFn2
问题出现在这里,effectFn直接进行了覆盖,那么执行的肯定永远是最里层的方法。
//当 effectFn 执行时,将其设置为激活当前的副作用函数
activeEffect = effectFn
fn()
解决的方法很简单就是加入栈的结构。
/ 用一个全局变量存储当前激活的 effect 函数
let activeEffect
// effect栈
const effectStack = []
function effect(fn) {
const effectFn = () => {
//清除
cleanup(effectFn)
//当 effectFn 执行时,将其设置为激活当前的作用函数
activeEffect = effectFn
//调用之前作用函数压入栈
effectStack.push(effectFn)
fn()
//执行完毕之后出栈
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// activeEffect.deps 用来存储所有与该作用函数相关联的依赖聚合
effectFn.deps = []
// 执行作用函数
effectFn()
}
嵌套最里层在栈顶,每执行完成一个方法之后弹出一个栈中元素。
6、避免无限递归
响应式系统的设计需要注意许多细节,当前状态的响应式系统存在着无限递归的问题,举个栗子
const data = { foo: 1}
effect(() => obj.foo++)
修改数据,进行自增操作,会产生报错,超出了栈的大小。
出现这个问题的原因如下:
obj.foo++实际是两步操作,是obj.foo = obj.foo + 1
设置值会调用trigger方法,读取会触发track方法,此时在设置读取值的时候又设置了值,会在“桶”中调用同一个副作用函数,上一个操作没有执行完毕,又执行另一个操作,反复使用这个副作用函数,造成无限递归,导致栈的溢出。
解决方案:添加判断,在当前副作用函数没有执行完毕时,不可以执行相同的副作用函数
修改后的具体代码如下:
//触发方法 set调用
function trigger(target, key) {
// 根据 target 从桶中取得 depsMap
const depsMap = tong.get(target)
if (!depsMap) return
// 根据key取得所有副作用函数
const effects = depsMap.get(key)
const effectsToRun = new Set()
effect && effects.forEach(effectFn => {
//如果trigger出发执行的作用函数,与正在执行的相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => effectFn())
}
7、lazy与计算属性
lazy:就是不需要立即执行副作用函数,其具体实现过程大致如下
vue中的lazy通过使用options.lazy设置懒加载。需要在effect函数中补充options属性,在非lazy的时候才执行effect,手动执行作用函数拿到其返回值。
//修改后的trigger包含调度器
//触发方法 set调用
function trigger(target, key) {
const depsMap = tong.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
//修改的effect函数
function effect(fn, options = {}) {
const effectFn = () => {
//清除
cleanup(effectFn)
//当 effectFn 执行时,将其设置为激活当前的作用函数
activeEffect = effectFn
//调用之前作用函数压入栈
effectStack.push(effectFn)
//将fn的执行结果存储到res中
const res = fn()
//执行完毕之后出栈
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
return res
}
effectFn.options = options
effectFn.deps = []
//只有非lazy的时候才执行
if (!options.lazy) {
effectFn()
}
return effectFn
}
const effectFn = effect(() => {
obj.foo = obj + 7
console.log(obj.foo)
},
{
lazy: true
}
)
调用返回结果,lazy成功
计算属性
定义一个computed函数,getter函数作为参数,把getter作为副作用函数创建一个lazy的effect,设置dirty和value,dirty是一个标识判断是否重新计算,value用来缓存上一次计算的值
//计算属性部分
function computed(getter) {
// 缓存上一次计算的值
let value
// dirty标志,判断是否需要重新计算值,true为“脏”,需要重新计算
let dirty = true
// 把getter作为作用函数创建一个lazy的effect
const effectFn = effect(getter, {
lazy:true,
//添加调度器,在调度器中将dirty重置为true
scheduler() {
if (!dirty) {
dirty = true
trigger(obj, 'value')
}
}
})
const obj = {
// 当读取value时才执行 effectFn
get value() {
if (dirty) {
value = effectFn()
//将dirty设置为false,下一次访问直接使用缓存到value中的值
dirty = false
}
track(obj, 'value')
return value
}
}
return obj
}
const sumRes = computed(() => obj.foo + obj.bar)
effect(function effectFn() {
console.log(sumRes.value)
})
8、watch实现原理
watch的本质是利用effect和option.scheduler调度器选项
//watch 接受两个参数,source是响应式数据,callback是回调函数
function watch(source, callback) {
effect(
// 触发读取操作,建立联系
// 调用traverse 递归读取
() => traverse(source),
{
scheduler() {
// 当数据变化时,调用回调函数
callback()
}
}
)
}
function traverse(value, seen = new Set()) {
// 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
if (typeof value !== 'object' || value == null || seen.has(value)) {
return
}
// 将数据添加到seen中,代表遍历的读取过,避免循环引用引起死循环
seen.add(value)
//暂时不考虑其他结构
for (const k in value) {
traverse(value[k], seen)
}
return value
}
watch内部的effect调用traverse函数,traverse函数内部遍历,可以读取对象上的任意属性。
vue中的watch不仅可以观测响应式数据,也可以接收getter函数,还可以获取新值(newValue)和旧值(oldValue)。
getter改良
首先要判断source的类型,判断是否是函数,如果是函数则说明用户直接传递了getter函数,直接使用用户的getter函数,否则仍然执行traverse函数进行遍历操作,具体代码如下:
//getter改良版本
// watch 接受两个参数,source是响应式数据,callback是回调函数
function watch(source, callback) {
// 定义getter
let getter
// 如果是函数则说明传的是getter,直接把source赋值给getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
effect(
// 触发读取操作,建立联系
// 调用traverse 递归读取
() => getter(),
{
scheduler() {
// 当数据变化时,调用回调函数
callback()
}
}
)
}
旧值oldValue与新值newValue改良
通过lazy懒执行effect,手动调用effectFn函数的返回值就是oldValue,当值发生变化触发scheduler调用effectFn函数得到newValue,将oldValue和newValue当做参数传给回调函数,最后,把新值赋值给旧值newValue = oldValue。具体代码如下:
//旧值oldValue与新值newValue改良
// watch 接受两个参数,source是响应式数据,callback是回调函数
function watch(source, callback) {
// 定义getter
let getter
// 如果是函数则说明传的是getter,直接把source赋值给getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
// 使用effect注册作用函数时开启lazy选项,并把返回值存储到effect中以便手动调用
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler() {
// scheduler执行的作用函数,拿到的是newValue
newValue = effectFn()
// 传给回调函数
callback(newValue, oldValue)
//更新旧值,否则下一次会得到错误的旧值
oldValue = newValue
}
}
)
// 手动调用作用函数,拿到的就是旧值
oldValue = effectFn()
}
9、watch的立即执行和回调的执行时机
immediate实现原理就是把scheduler抽出来,在初始化执行一次,在值产生变化后再执行。
//immediate
// watch 接受两个参数,source是响应式数据,callback是回调函数
function watch(source, callback) {
// 定义getter
let getter
// 如果是函数则说明传的是getter,直接把source赋值给getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
// 使用effect注册作用函数时开启lazy选项,并把返回值存储到effect中以便手动调用
// 提取 scheduler 调度函数为一个独立的 job 函数
const job = () => {
newValue = effectFn()
callback(newValue, oldValue)
oldValue = newValue
}
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler: job
}
)
if (options.immediate) {
// 当 immediate 为 true 时立即执行 job,从而触发回调执行
job()
} else {
// 手动调用作用函数,拿到的就是旧值
oldValue = effectFn()
}
}
Vue3中watch可以通过flush指定调用时机
flush本质上是在指定调度函数scheduler的调用时机,
//flush
// watch 接受两个参数,source是响应式数据,callback是回调函数
function watch(source, callback, options = {}) {
// 定义getter
let getter
// 如果是函数则说明传的是getter,直接把source赋值给getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
// 使用effect注册作用函数时开启lazy选项,并把返回值存储到effect中以便手动调用
// 提取 scheduler 调度函数为一个独立的 job 函数
const job = () => {
newValue = effectFn()
callback(newValue, oldValue)
oldValue = newValue
}
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler: () => {
// 判断flush是否为;'post',如果是放到微任务队列中执行
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if (options.immediate) {
// 当 immediate 为 true 时立即执行 job,从而触发回调执行
job()
} else {
// 手动调用作用函数,拿到的就是旧值
oldValue = effectFn()
}
}
10、过期的副作用
竟态问题经常出现在多线程或者多进程中,前端中遇到的场景可能是这样的:
修改obj 发送请求A ,再次修改obj 发送请求B ,请求A成功返回结果,赋值给finalData , 请求B成功返回结果,赋值给finalData。这是正常的流程,但当前的响应式系统执行的顺序,并不一定是这个流程,很有可能会出现B请求成功结束率先返回,之后A请求返回,最后finalData可能不是我们需要的B而是A。一幅图来解释:
Vue中解决这个问题通过的是watch函数回调的第三个参数onInvalidate,onInvalidate的原理是:在watch内部每次监测到改变后,在副作用函数重新执行之前,会先调用onInvalidate注册的过期回调。代码如下:
//处理过期副作用
// watch 接受两个参数,source是响应式数据,callback是回调函数
function watch(source, callback, options = {}) {
// 定义getter
let getter
// 如果是函数则说明传的是getter,直接把source赋值给getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
// cleanup用来存储用户过期的回调
let cleanup
// 定义onInvalidate函数
function onInvalidate(fn) {
// 将过期回调存储到 cleanup 中
cleanup = fn
}
// 提取 scheduler 调度函数为一个独立的 job 函数
const job = () => {
newValue = effectFn()
// 在调用回调函数callback之前,先调用过期回调
if (cleanup) {
cleanup()
}
// 将 onInvalidate 作为回调函数的第三个参数,方便用户使用
callback(newValue, oldValue, onInvalidate)
oldValue = newValue
}
// 使用effect注册作用函数时开启lazy选项,并把返回值存储到effect中以便手动调用
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler: () => {
// 判断flush是否为;'post',如果是放到微任务队列中执行
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if (options.immediate) {
// 当 immediate 为 true 时立即执行 job,从而触发回调执行
job()
} else {
// 手动调用作用函数,拿到的就是旧值
oldValue = effectFn()
}
}
总结
本章学习了:
1、副作用函数和响应式数据的概念
响应式数据最本质就是对读取和设置的拦截,在读取时把副作用函数放入“桶”中,在设置时从“桶”中取出副作用函数并执行
2、调整“桶”的结构,Map改为WeakMap
3、分支切换和冗余副作用,避免不必要的更新,在重新执行副作用函数之前,清除之前建立的联系
4、嵌套副作用函数,避免嵌套副作用函数出现问题,通过effect栈解决
5、响应式的可调度性,trigger再次执行副作用函数之前,有能力决定副作用函数的执行时机
6、lazy和计算属性,lazy通过scheduler实现,计算属性通过懒执行副作用函数实现
7、watch的实现原理,通过调度器scheduler,处理watch中注册的回调函数
8、过期的副作用函数,会引起竟态问题,onInvalidate注册过期的回调,在watch执行回调之前先执行注册过期的回调。