监视器与副作用

监视器与副作用 API 是 Qingkuai 响应性系统的一部分,允许你在更新调度器的不同阶段注册回调,以便在响应式值发生变化时执行相应逻辑。根据触发时机的不同,这些 API 分为以下几类:

组件文件内部,监视器与副作用 API 都是内建方法,无需从运行时包导入。编译器会按需为 API 调用生成与组件绑定的方法,使组件内注册的监视器与副作用都能正确绑定到组件实例;因此你无需关心内存泄漏问题,它们都会在组件卸载时被自动停止并释放内存,无论是否在异步逻辑中注册。

监视器与副作用 API 主要面向熟悉 Vue 等框架的开发者,作为迁移阶段的过渡工具,用于降低上手门槛。我们不建议在正式项目中大范围使用这类 API。原因在于,副作用通常以回调形式注册,其触发位置不会直接体现在调用栈中,调用链不够直观、跟踪成本也更高;同时,这种模式也不利于借助 IDE 的跳转、查找引用等语言服务进行高效的代码审查与维护。若项目对可维护性和可读性要求较高,建议优先采用显式数据流与函数组合来组织响应逻辑。

监视器

下面的示例为 name 变量注册了一个监视器,当该变量的值被修改时,回调方法将被调用。回调接受两个参数:修改前的值和当前值。由于监视器的注册时机早于模板渲染副作用,因此在回调中访问到的 DOM 仍是更新前的状态:

  • js
  • ts
qk
<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>
qk
<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
qk
<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>
qk
<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
qk
<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>
qk
<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>

同步监视器

watchpreWatchpostWatch 的回调均为异步触发。若需要同步触发,可以使用 syncWatch

qk
<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 作用类似的便捷注册方法:watchExppreWatchExppostWatchExpsyncWatchExp。这些方法的第一个参数会被编译器自动转换为 getter 函数,可以直接传入表达式:

js
// 普通注册
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
qk
<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>
qk
<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 同样提供了对应不同触发时机的注册方法:

js
preEffect(() => {})
postEffect(() => {})
syncEffect(() => {})

从运行时包导入

组件文件之外使用监视器与副作用 API 时,需要从 qingkuai 运行时包导入对应的方法:

js
import { watch, effect, preWatch, postWatch, syncWatch } from "qingkuai"

与组件文件中的内置方法不同,从运行时包导入的这些方法不会自动绑定当前组件实例。第一个参数为组件实例null,用于指定注册项的绑定关系,其余参数与组件内置方法一致。

这些方法的第一个参数的取值决定了注册项的清理方式:

需要特别提醒的是,我们极不推荐在组件内注册全局监视器和副作用,合理的设计通常是在组件外部的 js / ts 模块中注册这类全局副作用。如果确有必要在组件内注册,可以从运行时包导入相关 API,并将第一个参数传入 null,注册一个需要手动管理的监视器或副作用:

qk
<lang-js>
    import { effect as manualEffect } from "qingkuai"

    const handle = manualEffect(null, () => {
        // ...
    })

    handle.stop()
</lang-js>
这里必须为导入的 API 取别名(如 manualEffect)。因为组件文件内部已经内建了同名 API,直接导入同名标识符会触发编译错误。这一限制是刻意设计的,我们希望通过这种不顺手的使用方式,让你意识到自己可能正在使用一种不被推荐的反模式。

在组件文件之外,最常见的做法是传入 null 并由调用方手动管理生命周期:

js
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 方法作为参数

组件文件内部内建的 effectwatch 等方法本身就与当前组件实例绑定,可以把它们作为参数传给外部模块,由外部模块调用这些方法,从而创建与对应组件实例绑定的监视器或副作用:

qk
<lang-js>
    import { collectEffects } from "./utils"

    // 把组件内建的 effect 方法作为参数传给外部模块
    collectEffects(effect)
</lang-js>

外部模块中,effectwatch 方法的完整类型为 EffectFuncWatchFunc

  • js
  • ts
js
// 外部模块:接受组件内建的 effect 方法作为参数
export function collectEffects(effect) {
    effect(() => {
        // ...
    })
}
ts
import type { EffectFunc } from "qingkuai"

// 外部模块:接受组件内建的 effect 方法作为参数
export function collectEffects(effect: EffectFunc) {
    effect(() => {
        // ...
    })
}

2. 通过 getCurrentInstance 获取当前组件实例

也可以从 qingkuai 运行时包导入 getCurrentInstance,在组件逻辑中同步获取当前组件实例并传递给监视器/副作用 API:

qk
<lang-js>
    import { getCurrentInstance, effect } from "qingkuai"

    const instance = getCurrentInstance()
    effect(instance, () => {
        // ...
    })
</lang-js>

清理监视器与副作用

自动清理

组件文件中使用内建方法创建的监视器或副作用,无论同步还是异步注册,都会随组件销毁自动清理,无需手动管理。但外部模块从 qingkuai 运行时包导入使用这些 API 时,则需要根据传入的第一个参数决定其清理方式(见上方的从运行时包导入)。

手动清理

监视器及副作用 API 的注册方法都会返回控制句柄对象,这个句柄对象的类型定义如下:

ts
type EffectHandle = Record<"stop" | "pause" | "resume", () => void>

其中的三个方法分别用于停止、暂停和恢复监视器或副作用的触发:

js
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
js
let timer

watchExp(identifier, (pre, cur) => {
    timer = setTimeout(() => {
        // do something ...
    }, 1000)

    return () => clearTimeout(timer) // 监视器重新触发前会先执行这个清理函数
})
ts
let timer: number

watchExp(identifier, (pre, cur) => {
    timer = window.setTimeout(() => {
        // do something ...
    }, 1000)

    return () => clearTimeout(timer) // 监视器重新触发前会先执行这个清理函数
})

被动清理

若监视器或副作用回调执行期间未收集到任何响应式依赖,运行时会发出警告并自动销毁该注册项。销毁后其占用的内存等资源都会被释放,因为它将永远不会被再次执行:

qk
<lang-js>
    effect(() => {
        // 回调中没有访问任何响应式值
        console.log("没有依赖,执行完后会被销毁")
    })

    watch(
        () => "constant",
        (pre, cur) => {
            // getter 返回常量,未建立响应式关联
            console.log("同样会被销毁")
        }
    )
</lang-js>

这通常意味着回调中没有读取响应式值,或读取路径被条件分支短路:

qk
<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>

在github上编辑此页面