组合式函数不只是把代码搬进另一个文件。它把有状态的逻辑及其生命周期放在一起:组件负责输入和显示,函数负责请求、状态变化与资源释放。一个搜索框很适合练习这种边界。
把状态与请求放进 useSearch
下面的函数接收一个字符串 ref,返回结果、加载状态和错误信息。示例假设后端提供 /api/search?q=关键词,并返回 [{ "id": 1, "title": "文章标题" }] 这样的数组;实际使用时应按接口约定处理数据。
// src/composables/useSearch.js
import { ref, watch } from 'vue'
export function useSearch(query) {
const items = ref([])
const loading = ref(false)
const error = ref('')
watch(query, (value, _oldValue, onCleanup) => {
const controller = new AbortController()
let active = true
onCleanup(() => {
active = false
controller.abort()
})
items.value = []
error.value = ''
const term = value.trim()
loading.value = Boolean(term)
if (!term) return
void (async () => {
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(term)}`,
{ signal: controller.signal }
)
if (!response.ok) throw new Error(`请求失败:${response.status}`)
const data = await response.json()
if (!Array.isArray(data)) throw new Error('搜索结果格式不正确')
if (active) items.value = data
} catch (cause) {
if (active) {
error.value = cause instanceof Error ? cause.message : '请求失败'
}
} finally {
if (active) loading.value = false
}
})()
}, { immediate: true, flush: 'sync' })
return { items, loading, error }
}每次关键词变化,上一轮的清理函数会先执行。abort() 尽可能中止网络工作,active 则明确禁止已失效任务写回状态,包含错误和 finally 中的加载状态。否则旧请求结束时,也可能把新请求的“加载中”提前关掉。
这里对单个字符串使用同步监听,让输入变化时立即使旧任务失效。同步监听不会批处理,不适合频繁同步修改的大数组。真实搜索还可以增加防抖,但防抖只控制发起频率,不能替代过期结果保护。
在组件初始化时调用
组件只需要声明关键词并绑定返回的 ref。这个函数应在 <script setup> 中同步调用,让 Vue 将内部监听器关联到当前组件:
状态声明放在函数内部,每次调用都会得到独立的搜索结果。返回普通对象中的多个 ref,可以安全解构;如果把状态移到模块顶层,就会在调用者之间共享,需要明确这是否符合业务预期。
<script setup>
import { ref } from 'vue'
import { useSearch } from './composables/useSearch.js'
const query = ref('')
const { items, loading, error } = useSearch(query)
</script>
<template>
<label>搜索文章 <input v-model="query" type="search" /></label>
<p v-if="loading" role="status">正在搜索…</p>
<p v-else-if="error" role="alert">{{ error }}</p>
<ul v-else>
<li v-for="item in items" :key="item.id">{{ item.title }}</li>
</ul>
</template>在组件作用域中同步创建的监听器会随组件卸载而停止,已注册的清理也会执行。不应在一个延迟回调里才创建监听器,然后假设框架仍会自动收尾;这类情况需要保存并调用 watch() 返回的停止函数。
其他副作用也要有对称的清理
事件监听、计时器和订阅同样有生命周期。下面的函数在挂载后监听窗口尺寸,并在卸载时移除同一个处理函数;服务端渲染期间不会访问 window:
import { onMounted, onUnmounted, ref } from 'vue'
export function useWindowWidth() {
const width = ref(null)
const update = () => { width.value = window.innerWidth }
onMounted(() => {
update()
window.addEventListener('resize', update)
})
onUnmounted(() => window.removeEventListener('resize', update))
return { width }
}验证时,先快速输入不同关键词,再在请求进行中离开页面。最终结果应对应最后一次输入,离开后不应继续更新组件状态。良好的复用既包含“如何开始”,也包含“什么时候结束”。
读到这里,不妨动手试一试。
把一个概念变成能运行的小实验。