监视器与副作用
监视器与副作用 API 是 Qingkuai 响应性系统的一部分,允许你在更新调度器的不同阶段注册回调,以便在响应式值发生变化时执行相应逻辑。根据触发时机的不同,这些 API 分为以下几类:
watch、effect:普通注册,不能确定与更新调度器的执行顺序先后,先注册先触发;syncWatch、syncEffect:被依赖的响应式值发生变化后立即触发,优先于更新调度(异步)器执行;preWatch、preEffect:优先于更新调度器执行,适用于需要在状态变更后、更新调度前执行的逻辑;postWatch、postEffect:在更新调度完成后触发,适用于需要等待状态稳定或 DOM 更新之后的处理逻辑;
在组件文件内部,监视器与副作用 API 都是内建方法,无需从运行时包导入。编译器会按需为 API 调用生成与组件绑定的方法,使组件内注册的监视器与副作用都能正确绑定到组件实例;因此你无需关心内存泄漏问题,它们都会在组件卸载时被自动停止并释放内存,无论是否在异步逻辑中注册。
监视器
下面的示例为 name 变量注册了一个监视器,当该变量的值被修改时,回调方法将被调用。回调接受两个参数:修改前的值和当前值。由于监视器的注册时机早于模板渲染副作用,因此在回调中访问到的 DOM 仍是更新前的状态:
- js
- ts
<lang-js>
let paragraph
let name = "Javascript"
watch(
() => name,
(pre, cur) => {
console.log(pre, cur) // Javascript QingKuai
console.log(paragraph.textContent) // name is: Javascript
}
)
</lang-js>
<p &dom={paragraph}>name is: {name}</p>
<button @click={name = "QingKuai"}>Change Name</button>
<lang-ts>
let name = "Javascript"
let paragraph!: HTMLParagraphElement
watch(
() => name,
(pre, cur) => {
console.log(pre, cur) // Javascript QingKuai
console.log(paragraph.textContent) // name is: Javascript
}
)
</lang-ts>
<p &dom={paragraph}>name is: {name}</p>
<button @click={name = "QingKuai"}>Change Name</button>
前置监视器
在嵌入语言标签中通过 watch 同步注册的监视器会优先于模板渲染副作用注册,其回调在模板更新前触发。但若监视器是在异步逻辑中注册的,则无法保证这一顺序,此时可改用 preWatch 以确保回调在模板更新前触发:
- js
- ts
<lang-js>
let paragraph
let name = "Javascript"
preWatch(
() => name,
(pre, cur) => {
console.log(pre, cur) // Javascript QingKuai
console.log(paragraph.textContent) // name is: Javascript
}
)
</lang-js>
<p &dom={paragraph}>name is: {name}</p>
<button @click={name = "QingKuai"}>Change Name</button>
<lang-ts>
let name = "Javascript"
let paragraph!: HTMLParagraphElement
preWatch(
() => name,
(pre, cur) => {
console.log(pre, cur) // Javascript QingKuai
console.log(paragraph.textContent) // name is: Javascript
}
)
</lang-ts>
<p &dom={paragraph}>name is: {name}</p>
<button @click={name = "QingKuai"}>Change Name</button>
后置监视器
与前置监视器相反,后置监视器会在更新调度完成后触发,适用于需要等待状态稳定或 DOM 更新之后的处理逻辑:
- js
- ts
<lang-js>
let paragraph
let name = "Javascript"
postWatch(
() => name,
(pre, cur) => {
console.log(pre, cur) // Javascript QingKuai
console.log(paragraph.textContent) // name is: QingKuai
}
)
</lang-js>
<p &dom={paragraph}>name is: {name}</p>
<button @click={name = "QingKuai"}>Change Name</button>
<lang-ts>
let name = "Javascript"
let paragraph!: HTMLParagraphElement
postWatch(
() => name,
(pre, cur) => {
console.log(pre, cur) // Javascript QingKuai
console.log(paragraph.textContent) // name is: QingKuai
}
)
</lang-ts>
<p &dom={paragraph}>name is: {name}</p>
<button @click={name = "QingKuai"}>Change Name</button>
同步监视器
watch、 preWatch 和 postWatch 的回调均为异步触发。若需要同步触发,可以使用 syncWatch:
<lang-js>
let name = "Javascript"
function handleChangeName() {
name = "QingKuai" // logs: Javascript QingKuai
}
syncWatch(
() => name,
(pre, cur) => {
console.log(pre, cur)
}
)
</lang-js>
<p>name is: {name}</p>
<button @click={handleChangeName}>Change Name</button>
便捷注册
标准监视器注册时,第一个参数必须是返回被监听值的 getter 函数,对于简单表达式而言略显冗长。为此,编译器内建了一组与 derivedExp 作用类似的便捷注册方法:watchExp、preWatchExp、postWatchExp、syncWatchExp。这些方法的第一个参数会被编译器自动转换为 getter 函数,可以直接传入表达式:
// 普通注册
watchExp(identifier, (pre, cur) => {
console.log(pre, cur)
})
// 注册前置监视器
preWatchExp(identifier, (pre, cur) => {
console.log(pre, cur)
})
// 注册后置监视器
postWatchExp(identifier, (pre, cur) => {
console.log(pre, cur)
})
// 注册同步监视器
syncWatchExp(identifier, (pre, cur) => {
console.log(pre, cur)
})
副作用
与监视器不同,effect 只接受一个回调函数,依赖追踪与响应逻辑合二为一:回调执行时访问到的响应式值会被自动收集为依赖,任意一个依赖发生变化时该回调都会重新执行。下面的示例中,effect 的回调访问了 userId,因此每当 userId 变化时都会重新发起网络请求并更新用户信息:
- js
- ts
<lang-js>
let userId = 0
let userInfo = null
effect(async () => {
const response = await fetch(`https://example.com/user/info/${userId}`)
userInfo = await response.json()
})
</lang-js>
<qk:spread #if={userInfo}>
<p>User id: {userInfo.id}</p>
<p>User name: {userInfo.name}</p>
</qk:spread>
<lang-ts>
interface UserInfo {
id: number
name: string
}
let userId = 0
let userInfo: UserInfo | null = null
effect(async () => {
const response = await fetch(`https://example.com/user/info/${userId}`)
userInfo = await response.json()
})
</lang-ts>
<qk:spread #if={userInfo}>
<p>User id: {userInfo.id}</p>
<p>User name: {userInfo.name}</p>
</qk:spread>
effect 同样是组件文件的内置方法,无需导入即可直接调用。副作用 API 同样提供了对应不同触发时机的注册方法:
preEffect(() => {})
postEffect(() => {})
syncEffect(() => {})
从运行时包导入
在组件文件之外使用监视器与副作用 API 时,需要从 qingkuai 运行时包导入对应的方法:
import { watch, effect, preWatch, postWatch, syncWatch } from "qingkuai"
与组件文件中的内置方法不同,从运行时包导入的这些方法不会自动绑定当前组件实例。第一个参数为组件实例或 null,用于指定注册项的绑定关系,其余参数与组件内置方法一致。
这些方法的第一个参数的取值决定了注册项的清理方式:
传入组件实例:注册项会关联到该组件的销毁生命周期,无论同步还是异步注册,组件销毁时都会自动清理;
传入
null:注册项不关联任何组件,不会被自动清理,此时必须通过返回句柄的stop方法手动管理其生命周期。
需要特别提醒的是,我们极不推荐在组件内注册全局监视器和副作用,合理的设计通常是在组件外部的 js / ts 模块中注册这类全局副作用。如果确有必要在组件内注册,可以从运行时包导入相关 API,并将第一个参数传入 null,注册一个需要手动管理的监视器或副作用:
<lang-js>
import { effect as manualEffect } from "qingkuai"
const handle = manualEffect(null, () => {
// ...
})
handle.stop()
</lang-js>
manualEffect)。因为组件文件内部已经内建了同名 API,直接导入同名标识符会触发编译错误。这一限制是刻意设计的,我们希望通过这种不顺手的使用方式,让你意识到自己可能正在使用一种不被推荐的反模式。在组件文件之外,最常见的做法是传入 null 并由调用方手动管理生命周期:
import { watch, effect } from "qingkuai"
const watchHandle = watch(
null,
() => count,
(pre, cur) => {
// ...
}
)
const effectHandle = effect(null, () => {
// side effect logic ...
})
// 手动停止并释放资源
watchHandle.stop()
effectHandle.stop()
若希望注册项随组件销毁自动清理,则需要取得与组件实例的绑定关系,常见的方式有以下两种:
1. 接受组件内建的 effect / watch 方法作为参数
组件文件内部内建的 effect、watch 等方法本身就与当前组件实例绑定,可以把它们作为参数传给外部模块,由外部模块调用这些方法,从而创建与对应组件实例绑定的监视器或副作用:
<lang-js>
import { collectEffects } from "./utils"
// 把组件内建的 effect 方法作为参数传给外部模块
collectEffects(effect)
</lang-js>
外部模块中,effect 和 watch 方法的完整类型为 EffectFunc 和 WatchFunc:
- js
- ts
// 外部模块:接受组件内建的 effect 方法作为参数
export function collectEffects(effect) {
effect(() => {
// ...
})
}
import type { EffectFunc } from "qingkuai"
// 外部模块:接受组件内建的 effect 方法作为参数
export function collectEffects(effect: EffectFunc) {
effect(() => {
// ...
})
}
2. 通过 getCurrentInstance 获取当前组件实例
也可以从 qingkuai 运行时包导入 getCurrentInstance,在组件逻辑中同步获取当前组件实例并传递给监视器/副作用 API:
<lang-js>
import { getCurrentInstance, effect } from "qingkuai"
const instance = getCurrentInstance()
effect(instance, () => {
// ...
})
</lang-js>
清理监视器与副作用
自动清理
组件文件中使用内建方法创建的监视器或副作用,无论同步还是异步注册,都会随组件销毁自动清理,无需手动管理。但外部模块从 qingkuai 运行时包导入使用这些 API 时,则需要根据传入的第一个参数决定其清理方式(见上方的从运行时包导入)。
手动清理
监视器及副作用 API 的注册方法都会返回控制句柄对象,这个句柄对象的类型定义如下:
type EffectHandle = Record<"stop" | "pause" | "resume", () => void>
其中的三个方法分别用于停止、暂停和恢复监视器或副作用的触发:
const effectHandlers = effect(() => {
// effect logic ...
})
effectHandlers.stop() // 停止并清理副作用
effectHandlers.pause() // 暂停副作用
effectHandlers.resume() // 恢复被暂停的副作用
const watchHandlers = watchExp(identifier, (pre, cur) => {
// watch logic ...
})
watchHandlers.stop() // 停止并清理监视器
watchHandlers.pause() // 暂停监视器
watchHandlers.resume() // 恢复被暂停的监视器
清理函数
某些情况下,监视器或副作用在重新触发前需要执行清理逻辑。例如,若其中注册了定时器,就需要在下一次触发前将其清除,以避免内存泄漏或逻辑错误。此时可以将清理逻辑封装为函数,并在回调中通过 return 语句返回:
- js
- ts
let timer
watchExp(identifier, (pre, cur) => {
timer = setTimeout(() => {
// do something ...
}, 1000)
return () => clearTimeout(timer) // 监视器重新触发前会先执行这个清理函数
})
let timer: number
watchExp(identifier, (pre, cur) => {
timer = window.setTimeout(() => {
// do something ...
}, 1000)
return () => clearTimeout(timer) // 监视器重新触发前会先执行这个清理函数
})
被动清理
若监视器或副作用回调执行期间未收集到任何响应式依赖,运行时会发出警告并自动销毁该注册项。销毁后其占用的内存等资源都会被释放,因为它将永远不会被再次执行:
<lang-js>
effect(() => {
// 回调中没有访问任何响应式值
console.log("没有依赖,执行完后会被销毁")
})
watch(
() => "constant",
(pre, cur) => {
// getter 返回常量,未建立响应式关联
console.log("同样会被销毁")
}
)
</lang-js>
这通常意味着回调中没有读取响应式值,或读取路径被条件分支短路:
<lang-js>
let flag = true
let value = reactive("hello")
effect(() => {
// 当 flag 为 true 时仅返回常量,不读取任何响应式值
if (flag) {
console.log("no reactive deps")
return
}
console.log(value) // 这行不会被执行到
})
</lang-js>