当前位置: 首页 > news >正文

《Vuejs 设计与实现》第 4 章(响应式系统)( 下 )

目录

4.6 避免无限递归循环

4.7 调度执行

4.8 计算属性 computed 与 lazy

4.9 watch 的实现原理

4.10 立即执行的 watch 与回调执行时机

4.11 过期副作用与竞态问题

总结


4.6 避免无限递归循环

在实现完善响应式系统时,需要注意避免无限递归循环。以以下代码为例:

const data = { foo: 1 }
const obj = new Proxy(data, { /*...*/ })effect(() => obj.foo++) // 既会读取 obj.foo 的值,又会设置 obj.foo 的值

上述代码,effect 注册的副作用函数会触发栈溢出。为什么呢?
其实,我们可以将 obj.foo++ 分解为看作是两个步骤:读取 obj.foo 的值并给它增加 1:

effect(() => {// 语句obj.foo = obj.foo + 1
})

上述代码,我们首先读取 obj.foo 的值,触发数据追踪(track)操作,将当前的副作用函数添加到依赖列表。
然后,我们对 obj.foo 赋值,这会触发触发器(trigger)操作,从依赖列表中取出并执行所有的副作用函数。
这就引发了问题,因为我们正在执行的副作用函数还没结束,就开始了下一次的执行,从而导致了无限递归调用,最终引发栈溢出。

解决办法是在 trigger 动作发生时增加守卫条件,如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。代码如下:

function trigger(target, key) {const depsMap = bucket.get(target)if (!depsMap) returnconst effects = depsMap.get(key)const effectsToRun = new Set()effects && effects.forEach(effectFn => {// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行if (effectFn !== activeEffect) { // 新增effectsToRun.add(effectFn)}})effectsToRun.forEach(effectFn => effectFn())
}

通过这种方式,我们可以避免无限递归调用和栈溢出。

4.7 调度执行

调度性是响应式系统的重要特性,它允许我们决定副作用函数执行的时机、次数和方式。以以下的代码为例:

const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })effect(() => {console.log(obj.foo)
})obj.foo++console.log('结束了')

现在,假设我们希望改变输出顺序,但不改变代码结构。这就需要在响应系统中支持调度。
为了实现可调度性,我们可以为 effect 函数添加一个选项参数 options,允许用户指定调度器:

effect(() => {console.log(obj.foo)},{scheduler(fn) {// ...}}
)

在调用 effect 函数注册副作用函数时,用户可以传入第二个参数 options。
这是一个对象,可以指定 scheduler 调度函数。同时,我们需要将 options 选项绑定到对应的副作用函数上:

function effect(fn, options = {}) {const effectFn = () => {cleanup(effectFn)// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffectactiveEffect = effectFn// 在调用副作用函数之前将当前副作用函数压栈effectStack.push(effectFn)fn()// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值effectStack.pop()activeEffect = effectStack[effectStack.length - 1]}// 将 options 挂载到 effectFn 上effectFn.options = options // 新增// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合effectFn.deps = []// 执行副作用函数effectFn()
}

有了 调度函数,我们在 trigger 函数中触发副作用函数重新执行时,就可以直接调用用户传递的调度器函数,从而把控制权交给用户:

function trigger(target, key) {const depsMap = bucket.get(target)if (!depsMap) returnconst 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() // 新增}})
}

这样,当触发副作用函数时,我们首先检查副作用函数是否有调度器。
如果有,我们调用调度器函数,并将当前的副作用函数作为参数传递,由用户自己控制执行方式;
否则,我们保持默认行为,即直接执行副作用函数。

有了上面基础设施的支持下,我们使用 setTimeout 开启一个宏任务来执行副作用函数 fn,这样就能更灵活控制代码的执行顺序了。

const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })effect(() => {console.log(obj.foo)},{scheduler(fn) {setTimeout(fn)}}
)obj.foo++console.log('结束了')

输出结果:

1
'结束了'
2

通过调度器,我们还可以控制副作用函数的执行次数。这是一个重要的特性,如下所示:

const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })effect(() => {console.log(obj.foo)
})obj.foo++
obj.foo++

在这个例子中,obj.foo 的值从 1 增加到 3,2 只是过渡状态。
如果我们只关心最终结果而不关心过程,那么打印过渡状态就是多余的。我们希望输出:'1','3'。基于调度器,我们可以轻松实现:

// 定义一个任务队列
const jobQueue = new Set()
// 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列
const p = Promise.resolve()// 一个标志代表是否正在刷新队列
let isFlushing = false
function flushJob() {// 如果队列正在刷新,则什么都不做if (isFlushing) return// 设置为 true,代表正在刷新isFlushing = true// 在微任务队列中刷新 jobQueue 队列p.then(() => {jobQueue.forEach(job => job())}).finally(() => {// 结束后重置 isFlushingisFlushing = false})
}effect(() => {console.log(obj.foo)},{scheduler(fn) {// 每次调度时,将副作用函数添加到 jobQueue 队列中jobQueue.add(fn)// 调用 flushJob 刷新队列flushJob()},}
)obj.foo++
obj.foo++

上述代码,我们首先定义了一个任务队列 jobQueue,利用了 Set 数据结构的自动去重能力。
在调度函数中,我们将当前的副作用函数添加到 jobQueue 队列,再调用 flushJob 函数刷新队列。
在 flushJob 函数内部,该函数通过 isFlushing 标志判断是否需要执行,只有当其为 false 时才需要执行,而一旦 flushJob 函数开始执行,isFlushing 标志就会设置为 true,意思是无论调用多少次 flushJob 函数,在一个周期内都只会执行一次。
需要注意的是,在 flushJob 内通过 p.then 将一个函数 添加到微任务队列,在微任务队列内完成对 jobQueue 的遍历执行。
结果是,连续两次自增操作,虽然调度函数执行了两次,但因为 Set 的去重能力,jobQueue 中只有一个副作用函数。
类似地,flushJob 也会同步且连续地执行两次,但由于 isFlushing 标志的存在,实际上 flushJob 函数在一个事件循环内只会执行一次, 即在微任务队列内执行一次。
当微任务队列开始执行时,就会遍历 jobQueue 并执行里面存储的副作用函数。
由于此时 jobQueue 队列内只有一个副作用函数,所以只会执行一次,并且当它执行时,字段 obj.foo 的值已经是 3 了,这样我们就实现了期望的输出:

1
3

这个功能类似于 Vue.js 中,连续多次修改响应式数据只会触发一次更新。
实际上,Vue.js内部实现了一个更加完善的调度器,其思路与我们刚才介绍的一致。

4.8 计算属性 computed 与 lazy

在某些场景下,我们希望副作用函数不立即执行,而是在需要时执行,例如计算属性。
这时,我们可以在 options 中添加 lazy 属性来实现,如下:

effect(// 指定了 lazy 选项,这个函数不会立即执行() => {console.log(obj.foo)},// options{lazy: true}
)

我们可以修改 effect 函数的实现逻辑,当 options.lazy 为 true 时,不立即执行副作用函数:

function effect(fn, options = {}) {const effectFn = () => {cleanup(effectFn)activeEffect = effectFneffectStack.push(effectFn)fn()effectStack.pop()activeEffect = effectStack[effectStack.length - 1]}effectFn.options = optionseffectFn.deps = []// 只有非 lazy 的时候,才执行if (!options.lazy) {// 执行副作用函数effectFn()}// 将副作用函数作为返回值返回return effectFn
}

当调用 effect 函数时,通过其返回值能够拿到对应的副作用函数,这样我们就能手动执行该副作用函数了:

const effectFn = effect(() => {console.log(obj.foo)
}, { lazy: true })// 手动执行副作用函数
effectFn()

如果仅仅能够手动执行副作用函数,意义并不大。
但如果我们把传递给 effect 的函数看作一个 getter,那么这个 getter 函数可以返回任何值,例如:

const effectFn = effect(// getter 返回 obj.foo 与 obj.bar 的和() => obj.foo + obj.bar,{ lazy: true }
)

这样我们在手动执行副作用函数时,就能够拿到其返回值:

const effectFn = effect(// getter 返回 obj.foo 与 obj.bar 的和() => obj.foo + obj.bar,{ lazy: true }
)// value 是 getter 的返回值
const value = effectFn()

为了实现这个目标,我们需要再次修改 effect 函数:

function effect(fn, options = {}) {const effectFn = () => {cleanup(effectFn)activeEffect = effectFneffectStack.push(effectFn)// 将 fn 的执行结果存储到 res 中const res = fn()effectStack.pop()activeEffect = effectStack[effectStack.length - 1]// 将 res 作为 effectFn 的返回值return res // 新增}effectFn.options = optionseffectFn.deps = []if (!options.lazy) {effectFn()}return effectFn
}

通过这些修改,我们可以在执行 effectFn 时,获取到真正副作用函数 fn 的执行结果。
现在我们已经实现了具有懒执行的副作用函数,我们就可以实现计算属性了:
 

function computed(getter) {// 创建一个 lazy 的 effect,getter 作为副作用函数const effectFn = effect(getter, { lazy: true });const obj = {// effectFn 仅在访问 value 时执行get value() {return effectFn();}};return obj;
}

这里,我们首先定义了一个 computed 函数,接收一个 getter 函数作为参数。在访问对象的 value 属性时,执行 effectFn 并返回其结果。

我们可以使用 computed 函数来创建计算属性:

const data = { foo: 1, bar: 2 };
const obj = new Proxy(data, { /* ... */ });const sumRes = computed(() => obj.foo + obj.bar);console.log(sumRes.value) // 3

这里实现的计算属性进行了懒计算,即只有当你真正读取 sumRes.value 时,才会进行计算并得到值。
但是,它不能缓存值。即使 obj.foo 和 obj.bar 的值没有变化,每次访问 sumRes.value 都会触发多次计算:

console.log(sumRes.value) // 输出 3
console.log(sumRes.value) // 输出 3
console.log(sumRes.value) // 输出 3

为了解决这个问题,我们在实现 computed 函数时,需要添加缓存机制:

function computed(getter) {let value;  // 用于缓存上次计算的值let dirty = true;  // 代表是否需要重新计算的标志const effectFn = effect(getter, {lazy: true,// 添加调度器,在调度器中将 dirty 重置为 truescheduler() {dirty = true;}});const obj = {get value() {// 只有“脏”时才计算值,并将得到的值缓存到 value 中if (dirty) {value = effectFn();dirty = false;}return value;}};return obj;
}

现在我们访问 sumRes.value 都只会在第一次访问时进行实际计算,后续直接从缓存读取 value 值。
当我们修改 obj.foo 或 obj.bar 的值,就会发现 sumRes.value 的值也发生了改变。
但是,如果我们在另一个 effect 中读取计算属性的值,当计算属性的值变化时,它并不会触发副作用函数的执行,如下所示:

const sumRes = computed(() => obj.foo + obj.bar)effect(() => {// 在该副作用函数中读取 sumRes.valueconsole.log(sumRes.value)
})// 修改 obj.foo 的值
obj.foo++

本质上看这就是一个典型的 effect 嵌套。一个计算属性内部拥有自己的 effect,并且它是懒执行的,只有当真正读取计算属性的值时才会执行。
对于计算属性的 getter 函数来说,它里面访问的响应式数据只会把 computed 内部的 effect 收集为依赖。而当把计算属性用于另外一个 effect
时,就会发生 effect 嵌套,外层的 effect 不会被内层 effect 中的响应式数据收集。
解决这个问题的方法是,在读取计算属性的值时,手动调用 track 函数进行追踪;
当计算属性依赖的响应式数据发生变化时,手动调用 trigger 函数触发响应:

function computed(getter) {let value;let 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;
}

这样,无论我们在哪里读取计算属性的值,只要计算属性的值发生变化,都会触发相应的响应。
当读取一个计算属性的 value 值时,我们手动调用 track 函数,把计算属性返回的对象 obj 作为 target,作为第一个参数传递给 track 函数。
当计算属性所依赖的响应式数据变化时,会执行调度器函数,在调度器函数内手动调用 trigger 函数触发响应即可。这时,对于如下代码来说:

effect(function effectFn() {console.log(sumRes.value)
})

它会建立这样的联系:

computed(obj)└── value└── effectFn

image.png


这样,在其他 effect 中访问计算属性的值时,该 effect 就会因为计算属性值的变化而触发:

const sumRes = computed(() => obj.foo + obj.bar)effect(() => {console.log(sumRes.value)
})obj.foo++

在这段代码中,修改 obj.foo 的值会触发 effect。

4.9 watch 的实现原理

所谓 watch,其本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。以此为例:

watch(obj, () => {console.log('数据发生变化')
})// 当响应数据的值改变时,回调函数将被触发
obj.foo++

实际上,watch 的实现本质上就是利用了 effect 以及 options.scheduler 选项:

effect(() => {console.log(obj.foo)
}, {scheduler() {// 当 obj.foo 的值改变时,scheduler 调度函数将被执行}
})

当响应式数据发生变化时,会触发 scheduler 调度函数执行,而非直接触发副作用函数执行。
从这个角度来看,其实 scheduler 调度函数就相当于一个回调函数,而 watch 的实现就是利用了这个特点。下面是最简单的 watch 函数的实现:

// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {effect(// 触发读取操作,从而建立联系() => source.foo,{scheduler() {// 当数据变化时,调用回调函数 cbcb()}})
}

我们可以使用 watch 函数如下:

const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })watch(obj, () => {console.log('数据变化了')
})obj.foo++

上面这段代码能正常工作,但在之前的 watch 函数的实现中,硬编码了对 source.foo 的读取操作,我们需要封装一个通用的读取操作:

function watch(source, cb) {effect(// 调用 traverse 递归地读取() => traverse(source),{scheduler() {// 当数据变化时,调用回调函数 cbcb()}})
}function traverse(value, seen = new Set()) {// 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做if (typeof value !== 'object' || value === null || seen.has(value)) return// 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环seen.add(value)// 暂时不考虑数组等其他结构// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理for (const k in value) {traverse(value[k], seen)}return value
}

上述代码,调用 traverse 函数进行递归的读取,这样就能读取一个对象任意属性,从而当任意属性发生变化时都能够触发回调函数执行。
watch 函数除了可以观测响应式数据,还可以接收一个 getter 函数:

watch(// getter 函数() => obj.foo,// 回调函数() => {console.log('obj.foo 的值变了')}
)

传递给 watch 函数变成了 getter 函数,在 getter 函数内部,用户可以指定该 watch 依赖哪些响应式数据,只有当这些数据变化时,才会触发回
调函数执行。如下代码实现了这一功能:

function watch(source, cb) {// 定义 getterlet getter// 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getterif (typeof source === 'function') {getter = source} else {// 否则按照原来的实现调用 traverse 递归地读取getter = () => traverse(source)}effect(// 执行 getter() => getter(),{scheduler() {cb()}})
}

判断 source 类型,如果是函数类型,说明用户直接传递了 getter 函数,这时直接使用用户的 getter 函数,如果不是函数则使用原来做法。
这样就实现了自定义 getter 的功能

目前 watch 的回调函数拿不到旧值与新值。
那么如何获得新值与旧值呢?这需要充分利用 effect 函数的 lazy 选项:

function watch(source, cb) {let getterif (typeof source === 'function') {getter = source} else {getter = () => traverse(source)}// 定义旧值与新值let oldValue, newValue// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用const effectFn = effect(() => getter(),{lazy: true,scheduler() {// 在 scheduler 中重新执行副作用函数,得到的是新值newValue = effectFn()// 将旧值和新值作为回调函数的参数cb(newValue, oldValue)// 更新旧值,不然下一次会得到错误的旧值oldValue = newValue}})// 手动调用副作用函数,拿到的值就是旧值oldValue = effectFn()
}

我们手动调用 effectFn 函数得到的返回值就是旧值,即第一次执行得到的值。
当变化发生并触发 scheduler 调度函数执行时,会重新调用 effectFn 函数并得到新值,这样我们就拿到了旧值与新值,将它们作为参数传递给回调函数 cb 就可以了。
最后一件非常重要的事情是,不要忘记使用新值更新旧值,否则下一次变更发生时会得到错误的旧值。
这样,我们就完成了一个简化版的 watch 函数实现。

4.10 立即执行的 watch 与回调执行时机

默认情况下的 watch 回调函数,watch 的回调只在被观察的响应式数据发生变化时触发:

// 回调函数仅在响应式数据 obj 发生变化时执行
watch(obj, () => {console.log('变化了')
})

在 Vue.js 中,我们可以通过 immediate 参数来设定回调函数是否需要立即执行:

watch(obj, () => {console.log('变化了')
}, {// 创建 watch 时,回调函数会立即执行一次immediate: true
})

当 immediate 参数为 true,回调函数在创建该 watch 时就会立即执行。实际上,回调函数的立即执行与后续执行并没有本质区别。
因此,我们可以将调度函数 scheduler 封装为一个通用函数 job,让它在初始化和数据变更时都会被执行:

function watch(source, cb, options = {}) {let getter;if (typeof source === 'function') {getter = source;} else {getter = () => traverse(source);}let oldValue, newValue;// 将 scheduler 的调度功能抽取为一个独立的 job 函数const job = () => {newValue = effectFn();cb(newValue, oldValue);oldValue = newValue;};const effectFn = effect(// 执行 getter() => getter(),{lazy: true,// 使用 job 函数作为调度函数scheduler: job});if (options.immediate) {// 当 immediate 为 true,立即执行 job,从而触发回调函数执行job();} else {oldValue = effectFn();}
}

这样,我们就实现了回调函数的立即执行功能。由于回调函数在首次执行时并无旧值可言,此时的 oldValue 值为 undefined,这符合预期的行为。

除此之外,我们还可以指定 watch 回调的执行时机。例如,在 Vue3 中,我们可以使用 flush 参数来设定:

watch(obj, () => {console.log('变化了');
}, {// 创建 watch 时,回调函数会立即执行一次flush: 'pre' // 可选 'post' 或 'sync'
});


flflush 选项本质上是控制调度函数的执行时机。之前我们已经讲过如何在微任务队列中执行调度函数 scheduler,这与 flush 的功能相同。
当 flush 设为 'post',意味着调度函数会将副作用函数放到微任务队列中,等待 DOM 更新后再执行,如下所示:

function watch(source, cb, options = {}) {let getterif (typeof source === 'function') {getter = source} else {getter = () => traverse(source)}let oldValue, newValueconst job = () => {newValue = effectFn()cb(newValue, oldValue)oldValue = newValue}const effectFn = effect(// 执行 getter() => getter(),{lazy: true,scheduler: () => {// 在调度函数中判断 flush 是否为 'post',如果是,将其放到微任务队列中执行if (options.flush === 'post') {const p = Promise.resolve()p.then(job)} else {job()}},})if (options.immediate) {job()} else {oldValue = effectFn()}
}

上述代码中,我们修改了 scheduler 的实现。如果 options.flush 设为 'post',则 job 函数会放入微任务队列,从而实现异步延迟执行。
否则,job 函数会直接执行,相当于 'sync' 的实现机制,即同步执行。
对于 options.flush 设为 'pre' 的情况,我们目前无法模拟,因为这涉及到组件的更新时机。
'pre' 和 'post' 本质上指的是组件更新前和更新后,但这不影响我们理解如何控制回调函数的执行时机。

4.11 过期副作用与竞态问题

在多进程或多线程编程中,竞态问题常见,尽管前端工程师讨论较少,但在实际场景中也会遇到类似问题。以下是一个例子:

let finalData;watch(obj, async () => {const res = await fetch('/path/to/request');finalData = res;
});

在这段代码中,每次 obj 发生变化时,都会发送网络请求并将结果赋值给 finalData。但这可能导致竞态问题。
 

image.png


上面我们认为应该 请求B 的数据才是最终赋值给 finalData 的值。
为了解决这个问题,我们需要实现一个让副作用过期的手段。Vue.js 的 watch 函数提供了 onInvalidate 参数来解决这个问题:

watch(obj, async (newValue, oldValue, onInvalidate) => {// 定义一个标志,代表当前副作用函数是否过期,默认为 false,代表没有过期let expired = false// 调用 onInvalidate() 函数注册一个过期回调onInvalidate(() => {// 当过期时,将 expired 设置为 trueexpired = true})// 发送网络请求const res = await fetch('/path/to/request')// 只有当该副作用函数的执行没有过期时,才会执行后续操作。if (!expired) {finalData = res}
})

在发送请求前,我们定义了 expired 标志变量,用来标识当前副作用函数的执行是否过期。
我们通过 onInvalidate 函数注册一个过期回调,当该副作用函数的执行过期时将 expired 设置为 true。
最后,只有当没有过期时才采用请求结果,避免了竞态问题导致的错误结果。
要实现 onInvalidate 的功能,可以通过以下代码:

function watch(source, cb, options = {}) {let getterif (typeof source === 'function') {getter = source} else {getter = () => traverse(source)}let oldValue, newValue// cleanup 用来存储用户注册的过期回调let cleanup// 定义 onInvalidate 函数function onInvalidate(fn) {// 将过期回调存储到 cleanup 中cleanup = fn}const job = () => {newValue = effectFn()// 在调用回调函数 cb 之前,先调用过期回调if (cleanup) {cleanup()}// 将 onInvalidate 作为回调函数的第三个参数,以便用户使用cb(newValue, oldValue, onInvalidate)oldValue = newValue}const effectFn = effect(// 执行 getter() => getter(),{lazy: true,scheduler: () => {if (options.flush === 'post') {const p = Promise.resolve()p.then(job)} else {job()}},})if (options.immediate) {job()} else {oldValue = effectFn()}
}

在这段代码中,我们定义了 cleanup 变量,用来存储用户通过 onInvalidate 函数注册的过期回调。
在每次执行回调函数 cb 之前,先检查是否存在过期回调,如果存在,则执行过期回调函数 cleanup。
最后我们把 onInvalidate 函数作为回调函数的第三个参数传递给 cb,以便用户使用。
这种方法可以避免过期的副作用函数带来的影响。
我们还是通过一个例子来进一步说明:

watch(obj, async (newValue, oldValue, onInvalidate) => {let expired = falseonInvalidate(() => {expired = true})const res = await fetch('/path/to/request')if (!expired) {finalData = res}
})// 第一次修改
obj.foo++
setTimeout(() => {// 200ms 后做第二次修改obj.foo++
}, 200)

obj 修改了两次,所以 watch 执行了两次。
watch 的回调函数第一次执行的时候,我们已经注册了一个过期回调,所以在 watch 的回调函数第二次执行之前,会优先执行之前注册的过期回调。
这会使得第一次执行的副作用函数内闭包的变量 expired 的值变为 true,即副作用函数的执行过期了。
于是等请求 A 的结果返回时,其结果会被抛弃,从而避免了过期的副作用函数带来的影响,如图所示:

image.png

总结

本章我们深入探讨了副作用函数与响应式数据的关系。我们明确了响应式数据的基本实现依赖于“读取”和“设置”操作的拦截,连接副作用函数与响应式数据。当读取数据,我们将副作用函数存储,而在设置数据时,我们将存储的副作用函数取出并执行,这是响应系统的核心实现原理。
我们构建了一个完整的响应系统,用 WeakMap 和 Map 创建新的存储结构,确保响应式数据与副作用函数之间的精确关联。此外,我们分析了 WeakMap 和 Map 的差异,其中 WeakMap 的弱引用特性不会妨碍垃圾回收。
在解决分支切换导致的冗余副作用问题时,我们清除上一次建立的响应关系,重新建立新的关系。在此过程中,我们避免了遍历 Set 数据结构导致的无限循环问题,通过创建新的 Set 结构进行遍历。
讨论嵌套的副作用函数时,我们引入了副作用函数栈来管理不同的副作用函数,解决了响应关系的混乱问题。同时,我们处理了副作用函数无限递归调用自身,导致栈溢出的问题。
我们增强了响应系统的可调度性,即可以决定副作用函数执行的时机、次数及方式。我们为 effect 函数增加了调度器选项,使用户能自行调度任务。此外,我们解释了如何利用微任务队列实现任务去重。
接着,我们讨论了计算属性(computed),它是一种懒执行的副作用函数,可以通过手动执行更新。当计算属性的依赖数据变化时,会标记属性为“脏”,使下次读取时重新计算。
此外,我们探讨了 watch 的实现,它依赖副作用函数的可调度性。我们在调度器中执行用户注册的回调函数,通过 immediate 选项实现立即执行回调,通过 flush 选项控制回调函数的执行时机。
最后,我们讨论了竞态问题,它源自过期的副作用函数。为解决此问题,Vue.js 设计了 onInvalidate 参数,让用户可以注册过期回调,在回调函数执行前标记副作用为“过期”,从而解决竞态问题。

相关文章:

《Vuejs 设计与实现》第 4 章(响应式系统)( 下 )

目录 4.6 避免无限递归循环 4.7 调度执行 4.8 计算属性 computed 与 lazy 4.9 watch 的实现原理 4.10 立即执行的 watch 与回调执行时机 4.11 过期副作用与竞态问题 总结 4.6 避免无限递归循环 在实现完善响应式系统时,需要注意避免无限递归循环。以以下代码…...

在 Windows 上为 Intel UHD Graphics 编译 OpenCL 程序

如果您使用的是 Intel UHD Graphics 集成显卡,以下是完整的 OpenCL 开发环境配置指南: 1. 准备工作 确认硬件支持 首先确认您的 Intel UHD Graphics 支持 OpenCL: 大多数第6代及以后的 Intel Core 处理器(Skylake 及更新架构)都支持 OpenCL 2.1+ 运行 clinfo 工具可以查…...

C++自学笔记 makefile

本博客参考南科大于仕琪教授的讲解视频和这位同学的学习笔记: 参考博客 感谢两位的分享。 makefile 的作用 用于组织大型项目的编译,是一个一键编译项目的脚本文件。 本博客通过四个版本的makefile逐步说明makefile的使用 使用说明 四个演示文件 …...

【PDF】使用Adobe Acrobat dc添加水印和加密

【PDF】使用Adobe Acrobat dc添加水印和加密 文章目录 [TOC](文章目录) 前言一、添加保护加密口令二、添加水印三、实验四、参考文章总结 实验工具: 1.Adobe Acrobat dc 前言 提示:以下是本篇文章正文内容,下面案例可供参考 一、添加保护加…...

客服系统重构详细计划

# 客服系统重构详细计划 ## 第一阶段:系统分析与准备工作 ### 1. 代码审查和分析 (1-2周) - 全面分析现有代码结构 - 识别代码中的问题和瓶颈 - 理解当前系统的业务逻辑 - 确定可重用的组件 - 制作系统功能清单 ### 2. 技术栈升级准备 (1周) - 升级PHP版本到7…...

基于VSCode + PlatformIO平台的ESP8266的DS1302实时时钟

基于ESP8266的DS1302实时时钟系统开发 一、项目概述 本实验通过ESP8266开发板实现: DS1302实时时钟模块的驱动系统时间同步与维护串口实时时间显示RTC模块状态监控 硬件组成: NodeMCU ESP8266开发板DS1302实时时钟模块CR2032纽扣电池(备…...

Flink 系列之十四 - Data Stream API的自定义数据类型

之前做过数据平台,对于实时数据采集,使用了Flink。现在想想,在数据开发平台中,Flink的身影几乎无处不在,由于之前是边用边学,总体有点混乱,借此空隙,整理一下Flink的内容&#xff0c…...

【数据结构】线性表

目录 1.1 线性表的概念 1.1.1 线性表的抽象数据类型 1.1.2 线性表的存储结构 1.1.3 线性表运算分类 1.2 顺序表 1.2.1 顺序表的类定义 1.2.2 顺序表的运算实现 1. 顺序表的检索 2. 顺序表的插入 3. 顺序表的删除 1.3 链表 1.3.1 单链表 1. 链表的检索 2. 链表的插…...

大疆卓驭嵌入式面经及参考答案

FreeRTOS 有哪 5 种内存管理方式? heap_1.c:这种方式简单地在编译时分配一块固定大小的内存,在整个运行期间不会进行内存的动态分配和释放。它适用于那些对内存使用需求非常明确且固定,不需要动态分配内存的场景,优点是…...

【网络】:传输层协议 —— UDP、TCP协议

目录 UDP协议 UDP协议的核心特点 UDP协议格式 UDP的缓冲区 基于UDP的应用层协议 TCP协议 TCP协议的核心特点 TCP协议格式 确认应答机制 连接管理机制 三次握手 四次挥手 流量控制 滑动窗口 拥塞控制 基于字节流 粘包和拆包 可靠性和性能保障 基于TCP的应用层…...

每日c/c++题 备战蓝桥杯(洛谷P1115 最大子段和)

洛谷P1115 最大子段和 题解 题目描述 最大子段和是一道经典的动态规划问题。题目要求:给定一个包含n个整数的序列,找出其中和最大的连续子序列,并输出该最大和。若所有数均为负数,则取最大的那个数。 输入格式: 第…...

Python与矢量网络分析仪3671E:通道插损自动化校准(Vscode)

一、背景介绍 DUT集成了多个可调衰减的射频通道,可调衰减由高精度DAC和VVA构成,使用中电思仪的3671E矢量网络分析仪测试DUT的S参数,并自动化调整VVA的控制电压,以自动化获取指定衰减值对应的控制电平。 二、前期准备 Python环境&…...

设计模式系列(1):总览与引导

目录 前言 设计模式简介 UML与设计模式 术语解释 UML工具与PlantUML 面向对象设计原则(SOLID等) 设计模式分类与典型场景 设计模式的价值 学习与实践建议 常见面试题 推荐阅读 1. 前言 本篇为设计模式系列的第一篇,定位为总览和引导,旨在为后续各专题打下基础,帮助大家…...

Day21打卡—常见降维算法

知识点回顾: LDA线性判别PCA主成分分析t-sne降维 作业: 自由作业:探索下什么时候用到降维?降维的主要应用?或者让ai给你出题,群里的同学互相学习下。可以考虑对比下在某些特定数据集上t-sne的可视化和pca可…...

什么是人工智能(Artificial Intelligence,AI)? —— 机器学习 =》 深度学习 =》 新型技术

文章目录 什么是人工智能(Artificial Intelligence,AI)? —— 关系:AI >> ML >> DL一、机器学习(Machine Learning,ML)1、历史2、类型(1)监督学习…...

iVX 平台技术解析:图形化与组件化的融合创新

一、图形化逻辑编程:用流程图替代代码的革命 iVX 的核心突破在于可视化逻辑表达—— 开发者通过拖拽 “逻辑块”(如条件判断、循环控制、数据操作等)来搭建应用逻辑,彻底摒弃传统代码的字符输入模式。这种 “所见即所得” 的开发…...

【Diffusion】在华为云ModelArts上运行MindSpore扩散模型教程

目录 一、背景与目的 二、环境搭建 三、模型原理学习 1. 类定义与初始化 2. 初始卷积层 3. 时间嵌入模块 4. 下采样模块 5. 中间模块 6. 上采样模块 7. 最终卷积层 8. 前向传播 9. 关键点总结 四、代码实现与运行 五、遇到的问题及解决方法 六、总结与展望 一、…...

跟我学c++高级篇——模板元编程之十三处理逻辑

一、元编程处理逻辑 无论在普通编程还是在元编程中,逻辑的处理,都是一个编程开始的必然经过。开发者对普通编程中的逻辑处理一般都非常清楚,不外乎条件谈判和循环处理。而条件判断常见的基本就是if语句(switch如果不考虑效率等情…...

组合模式(Composite Pattern)详解

文章目录 1. 什么是组合模式?2. 为什么需要组合模式?3. 组合模式的核心概念4. 组合模式的结构5. 组合模式的基本实现5.1 基础示例:文件系统5.2 透明组合模式 vs 安全组合模式5.2.1 透明组合模式5.2.2 安全组合模式5.3 实例:公司组织结构5.4 实例:GUI组件树6. Java中组合模…...

最长字符串 / STL+BFS

题目 代码 #include <bits/stdc.h> using namespace std;int main() {map<vector<int>, vector<string>> a;set<vector<int>> c;vector<int> initial(26, 0);c.insert(initial);ifstream infile("words.txt");string s;w…...

C++ stl中的set、multiset、map、multimap的相关函数用法

文章目录 序列式容器和关联式容器树形结构和哈希结构树形结构哈希结构 键值对setset的相关介绍set定义方式set相关成员函数multiset mapmap的相关介绍map定义方式map的相关操作1.map的插入2.map的查找3.map的删除 序列式容器和关联式容器 CSTL中包含了序列式容器和关联式容器&…...

普通IT的股票交易成长史--20250511 美元与美股强相关性

声明&#xff1a;本文章的内容非原创。参考了yt博主Andy Lee的观点&#xff0c;为了加深自己的学习印象才做的复盘&#xff0c;不构成投资建议。感谢他的无私奉献&#xff01; 送给自己的话&#xff1a; 仓位就是生命&#xff0c;绝对不能满仓&#xff01;&#xff01;&#x…...

系统架构设计(四):架构风格总结

黑板 概念 黑板体系架构是一种用于求解复杂问题的软件架构风格&#xff0c;尤其适合知识密集型、推理驱动、数据不确定性大的场景。 它模拟了人类专家协同解决问题的方式&#xff0c;通过一个共享的“黑板”协同多个模块&#xff08;专家&#xff09;逐步构建解决方案。 组…...

ElasticSearch进阶

一、文档批量操作 1.批量获取文档数据 批量获取文档数据是通过_mget的API来实现的 (1)在URL中不指定index和type 请求方式&#xff1a;GET请求地址&#xff1a;_mget功能说明 &#xff1a; 可以通过ID批量获取不同index和type的数据请求参数&#xff1a; docs : 文档数组参…...

0基础 | L298N电机驱动模块 | 使用指南

引言 在嵌入式系统开发中&#xff0c;电机驱动是一个常见且重要的功能。L298N是一款高电压、大电流电机驱动芯片&#xff0c;广泛应用于各种电机控制场景&#xff0c;如直流电机的正反转、调速&#xff0c;以及步进电机的驱动等。本文将详细介绍如何使用51单片机来控制L298N电…...

Synchronized与锁升级

一、面试题 1&#xff09;谈谈你对Synchronized的理解 2&#xff09;Sychronized的锁升级你聊聊 3&#xff09;Synchronized实现原理&#xff0c;monitor对象什么时候生成的&#xff1f;知道monitor的monitorenter和monitorexit这两个是怎么保证同步的嘛&#…...

MNIST DDP 分布式数据并行

Distributed Data Parallel 转自我的个人博客&#xff1a;https://shar-pen.github.io/2025/05/04/torch-distributed-series/3.MNIST_DDP/ The difference between DistributedDataParallel and DataParallel is: DistributedDataParallel uses multiprocessing where a proc…...

语音合成之十三 中文文本归一化在现代语音合成系统中的应用与实践

中文文本归一化在现代语音合成系统中的应用与实践 引言理解中文文本归一化&#xff08;TN&#xff09;3 主流LLM驱动的TTS系统及其对中文文本归一化的需求分析A. SparkTTS&#xff08;基于Qwen2.5&#xff09;与文本归一化B. CosyVoice&#xff08;基于Qwen&#xff09;与文本归…...

9.1.领域驱动设计

目录 一、领域驱动设计核心哲学 战略设计与战术设计的分野 • 战略设计&#xff1a;限界上下文&#xff08;Bounded Context&#xff09;与上下文映射&#xff08;Context Mapping&#xff09; • 战术设计&#xff1a;实体、值对象、聚合根、领域服务的构建原则 统一语言&am…...

如何配置光猫+路由器实现外网IP访问内部网络?

文章目录 前言一、网络拓扑理解二、准备工作三、光猫配置3.1 光猫工作模式3.2 光猫端口转发配置&#xff08;路由模式时&#xff09; 四、路由器配置4.1 路由器WAN口配置4.2 端口转发配置4.3 动态DNS配置&#xff08;可选&#xff09; 五、防火墙设置六、测试配置七、安全注意事…...

C++题题题题题题题题题踢踢踢

后缀表达式求值 #include<bits/stdc.h> #include<algorithm> using namespace std; string a[100]; string b[100]; stack<string> op; int la0,lb0; int main(){while(true){cin>>a[la];if(a[la]".") break;la;}for(int i0;i<la;i){if(…...

M. Moving Both Hands(反向图+Dijkstra)

Problem - 1725M - Codeforces 题目大意&#xff1a;给你一个有向图&#xff0c;起始点在1&#xff0c;问起始点分别与另外n-1个 点相遇的最短时间&#xff0c;无法相遇输出-1。 思路&#xff1a;反向建图&#xff0c;第一层建原图&#xff0c;第二层建反向图&#xff0c;两层…...

11、参数化三维产品设计组件 - /设计与仿真组件/parametric-3d-product-design

76个工业组件库示例汇总 参数化三维产品设计组件 (注塑模具与公差分析) 概述 这是一个交互式的 Web 组件&#xff0c;旨在演示简单的三维零件&#xff08;如带凸台的方块&#xff09;的参数化设计过程&#xff0c;并结合注塑模具设计&#xff08;如开模动画&#xff09;与公…...

智能座舱开发工程师面试题

一、基础知识类 简述智能座舱的核心组成部分及其功能 要求从硬件&#xff08;如显示屏、传感器、控制器&#xff09;和软件&#xff08;操作系统、中间件、应用程序&#xff09;层面展开&#xff0c;阐述各部分如何协同实现座舱的智能化体验。 对比 Android Automotive、QNX…...

【连载14】基础智能体的进展与挑战综述-多智能体系统设计

基础智能体的进展与挑战综述 从类脑智能到具备可进化性、协作性和安全性的系统 【翻译团队】刘军(liujunbupt.edu.cn) 钱雨欣玥 冯梓哲 李正博 李冠谕 朱宇晗 张霄天 孙大壮 黄若溪 在基于大语言模型的多智能体系统&#xff08;LLM-MAS&#xff09;中&#xff0c;合作目标和合…...

06.three官方示例+编辑器+AI快速学习webgl_animation_skinning_additive_blending

本实例主要讲解内容 这个Three.js示例展示了**骨骼动画(Skinning)和变形动画(Morphing)**的结合应用。通过加载一个机器人模型&#xff0c;演示了如何同时控制角色的肢体动作和面部表情&#xff0c;实现更加丰富的角色动画效果。 核心技术包括&#xff1a; 多动画混合与淡入…...

【Java学习日记36】:javabeen学生系统

ideal快捷键...

.Net HttpClient 使用请求数据

HttpClient 使用请求数据 0、初始化及全局设置 //初始化&#xff1a;必须先执行一次 #!import ./ini.ipynb1、使用url 传参 参数放在Url里&#xff0c;形如&#xff1a;http://www.baidu.com?namezhangsan&age18, GET、Head请求用的比较多。优点是简单、方便&#xff0…...

详解 Java 并发编程 synchronized 关键字

synchronized 关键字的作用 synchronized 是 Java 中用于实现线程同步的关键字&#xff0c;主要用于解决多线程环境下的资源竞争问题。它可以修饰方法或代码块&#xff0c;确保同一时间只有一个线程可以执行被修饰的代码&#xff0c;从而避免数据不一致的问题。 synchronized…...

《Go小技巧易错点100例》第三十二篇

本期分享&#xff1a; 1.sync.Map的原理和使用方式 2.实现有序的Map sync.Map的原理和使用方式 sync.Map的底层结构是通过读写分离和无锁读设计实现高并发安全&#xff1a; 1&#xff09;双存储结构&#xff1a; 包含原子化的 read&#xff08;只读缓存&#xff0c;无锁快…...

时序约束高级进阶使用详解四:Set_False_Path

目录 一、背景 二、Set_False_Path 2.1 Set_false_path常用场景 2.2 Set_false_path的优势 2.3 Set_false_path设置项 2.4 细节区分 三、工程示例 3.1 工程代码 3.2 时序约束如下 3.3 时序报告 3.4 常规场景 3.4.1 设计代码 3.4.2 约束场景 3.4.3 约束对象总结…...

每日定投40刀BTC(16)20250428 - 20250511

定投 坚持 《恒道》 长河九曲本微流&#xff0c;岱岳摩云起累丘。 铁杵十年销作刃&#xff0c;寒窗五鼓淬成钩。已谙蜀栈盘空险&#xff0c;更蓄湘竹带泪遒。 莫问枯荣何日证&#xff0c;星霜满鬓亦从头。...

C# 高效处理海量数据:解决嵌套并行的性能陷阱

C# 高效处理海量数据&#xff1a;解决嵌套并行的性能陷阱 问题场景 假设我们需要在 10万条ID 和 1万个目录路径 中&#xff0c;快速找到所有满足以下条件的路径&#xff1a; 路径本身包含ID字符串该路径的子目录中也包含同名ID 初始代码采用Parallel.ForEach嵌套Task.Run&am…...

【Java EE初阶 --- 多线程(初阶)】线程安全问题

乐观学习&#xff0c;乐观生活&#xff0c;才能不断前进啊&#xff01;&#xff01;&#xff01; 我的主页&#xff1a;optimistic_chen 我的专栏&#xff1a;c语言 &#xff0c;Java 欢迎大家访问~ 创作不易&#xff0c;大佬们点赞鼓励下吧~ 文章目录 线程不安全的原因根本原因…...

从InfluxDB到StarRocks:Grab实现Spark监控平台10倍性能提升

Grab 是东南亚领先的超级应用&#xff0c;业务涵盖外卖配送、出行服务和数字金融&#xff0c;覆盖东南亚八个国家的 800 多个城市&#xff0c;每天为数百万用户提供一站式服务&#xff0c;包括点餐、购物、寄送包裹、打车、在线支付等。 为了优化 Spark 监控性能&#xff0c;Gr…...

《Redis应用实例》学习笔记,第一章:缓存文本数据

前言 最近在学习《Redis应用实例》&#xff0c;这本书并没有讲任何底层&#xff0c;而是聚焦实战用法&#xff0c;梳理了 32 种 Redis 的常见用法。我的笔记在 Github 上&#xff0c;用 Jupyter 记录&#xff0c;会有更好的阅读体验&#xff0c;作者的源码在这里&#xff1a;h…...

Redis 缓存

缓存介绍 Redis 最主要三个用途&#xff1a; 1&#xff09;存储数据&#xff08;内存数据库&#xff09; 2&#xff09;消息队列 3&#xff09;缓存 对于硬件的访问速度&#xff0c;通常有以下情况&#xff1a; CPU 寄存器 > 内存 > 硬盘 > 网络 缓存的核心…...

Apache Flink 与 Flink CDC:概念、联系、区别及版本演进解析

Apache Flink 与 Flink CDC:概念、联系、区别及版本演进解析 在实时数据处理和流式计算领域,Apache Flink 已成为行业标杆。而 Flink CDC(Change Data Capture) 作为其生态中的重要组件,为数据库的实时变更捕获提供了强大的能力。 本文将从以下几个方面进行深入讲解: 什…...

缓存(4):常见缓存 概念、问题、现象 及 预防问题

常见缓存概念 缓存特征: 命中率、最大元素、清空策略 命中率&#xff1a;命中率返回正确结果数/请求缓存次数 它是衡量缓存有效性的重要指标。命中率越高&#xff0c;表明缓存的使用率越高。 最大元素&#xff08;最大空间&#xff09;&#xff1a;缓存中可以存放的最大元素的…...

实战项目6(09)

目录 任务场景一 【r1配置】 【r2配置】 【r3配置】 ​​​​​​​任务场景二 【r1配置】 【r2配置】 【r3配置】 ​​​​​​​任务场景三 【r1配置】 【r2配置】 【r3配置】 ​​​​​​​任务场景一 按照下图完成网络拓扑搭建和配置 任务要求&#xff1a;在…...