监视器与副作用

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

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

监视器

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

  • js
  • ts
qk
<lang-js>
    import { watch } from "qingkuai"

    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>
    import { watch } from "qingkuai"

    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>
    import { preWatch } from "qingkuai"

    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>
    import { preWatch } from "qingkuai"

    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>
    import { postWatch } from "qingkuai"

    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>
    import { postWatch } from "qingkuai"

    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>
    import { syncWatch } from "qingkuai"

    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>
    import { effect } from "qingkuai"

    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>
    import { effect } from "qingkuai"

    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>

副作用 API 同样提供了对应不同触发时机的注册方法:

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

清理监视器与副作用

手动清理

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

ts
type EffectHandlers = 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) // 监视器重新触发前会先执行这个清理函数
})

自动清理

在组件中同步注册的监视器与副作用会自动关联到当前组件的销毁生命周期。当组件被销毁时,这些注册项会被框架自动清理,无需手动调用 stop()

js
import { effect, watch } from "qingkuai"

// 组件内同步注册,组件销毁时会自动清理
effect(() => {
    // ...
})

watchExp(someValue, (pre, cur) => {
    // ...
})

若监视器或副作用是在异步逻辑中注册的,则不再受组件销毁生命周期的管理,需要在 onBeforeDestroyonAfterDestroy 中调用返回句柄的 stop() 来手动清理:

js
import { effect, watch, onBeforeDestroy } from "qingkuai"

let handle

setTimeout(() => {
    // 异步注册,不在组件销毁生命周期内
    handle = effect(() => {
        // ...
    })
}, 1000)

// 在组件销毁前手动停止
onBeforeDestroy(() => {
    handle?.stop()
})

被动清理

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

js
import { effect, watch } from "qingkuai"

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

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

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

js
import { effect } from "qingkuai"

let flag = true
let value = reactive("hello")

effect(() => {
    // 当 flag 为 true 时仅返回常量,不读取任何响应式值
    if (flag) {
        console.log("no reactive deps")
        return
    }
    console.log(value) // 这行不会被执行到
})

在github上编辑此页面