Vue 基础体系 · 第 5/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue Composition API:setup、生命周期、作用域和逻辑组织
Composition API 是 Vue 3 中一组用于组织组件逻辑的 API。它不是单一函数,而是由响应式状态、计算值、侦听器、生命周期钩子、依赖注入以及可组合函数(Composable)共同构成的编程模型。
这篇文章集中回答四个相互关联的问题:
setup()在组件创建过程中处于什么位置,它能访问什么,返回值如何进入模板?- 生命周期钩子何时执行,为什么必须在
setup()的同步执行阶段注册? - “作用域”分别指什么:组件实例作用域、JavaScript 词法作用域、响应式副作用作用域,以及模块作用域?
- 如何把状态、派生值、副作用和事件处理组织成可复用、可测试的逻辑?
示例基于 Vue 3、TypeScript 和 Vite。
一、先建立基础:一个组件同时拥有几种“作用域”
在 Composition API 中,至少要区分以下四种作用域。
1. JavaScript 词法作用域
这是语言本身的作用域规则。变量是否可访问,由它所在的代码块、函数和模块决定:
function createCounter() {
const count = ref(0)
function increment() {
count.value++
}
return {
count,
increment,
}
}
increment() 可以访问外层的 count,这是闭包,不是 Vue 特有机制。
2. 组件实例作用域
每次组件被创建,Vue 都会执行一次该组件的 setup()。因此,写在 setup() 内部的状态默认属于当前组件实例:
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script>
如果页面上渲染两个相同组件,会得到两个不同的 count。状态不会因为代码文本相同而自动共享。
3. 响应式副作用作用域
watch()、watchEffect()、计算属性内部的响应式追踪等,都会创建或使用响应式副作用。它们需要在适当的时机停止,否则可能继续执行回调、保留资源或访问已经销毁的对象。
组件内创建的副作用通常会自动绑定到组件实例,并在组件卸载时停止。effectScope() 则允许我们手动把多个响应式副作用放进一个可统一停止的作用域。
4. JavaScript 模块作用域
模块顶层代码只执行一次,通常在模块首次被导入时执行:
// counterStore.ts
import { ref } from 'vue'
export const count = ref(0)
这里的 count 是模块级单例。所有导入该模块的组件共享它,而不是每个组件拥有一份。
这几种作用域的差异是理解 Composition API 的前提:
| 代码位置 | 通常的生命周期 | 是否按组件实例隔离 |
|---|---|---|
setup() 内部 |
随组件创建、卸载 | 是 |
setup() 内部调用的普通函数闭包 |
随外层 setup() |
是 |
effectScope() 内部 |
随 scope 停止 | 取决于 scope |
| 模块顶层 | 随模块加载 | 否,通常是共享单例 |
一个常见错误是把模块级响应式变量误认为组件私有状态,或者把 Composable 内部状态误认为所有组件共享状态。
二、setup() 是组件实例的逻辑入口
2.1 setup() 的基本形态
使用普通 defineComponent 时,组件可以这样写:
import { defineComponent, ref, computed } from 'vue'
export default defineComponent({
name: 'CounterPanel',
props: {
initial: {
type: Number,
default: 0,
},
},
emits: ['change'],
setup(props, context) {
const count = ref(props.initial)
const doubled = computed(() => count.value * 2)
function increment() {
count.value++
context.emit('change', count.value)
}
return {
count,
doubled,
increment,
}
},
})
setup() 的两个参数分别是:
setup(props, context)
props:父组件传入的、经过 Vue 管理的只读响应式对象。context:组件上下文,包含attrs、slots和emit。
setup() 返回的对象会暴露给模板:
<template>
<button @click="increment">
{{ count }} / {{ doubled }}
</button>
</template>
模板中可以直接使用 count,但在普通 JavaScript 或 TypeScript 中,ref 仍然需要通过 .value 访问。
2.2 setup() 的执行时机
组件创建过程可以简化为以下顺序:
sequenceDiagram
participant V as Vue
participant C as 组件实例
participant S as setup()
participant T as 模板渲染
participant D as DOM
V->>C: 创建组件实例
V->>C: 解析 props、slots、attrs
V->>S: 执行 setup(props, context)
S-->>V: 返回渲染上下文
V->>T: 执行渲染函数或模板渲染
T->>D: 创建并插入 DOM
V->>C: 执行 onMounted()
这里有几个重要边界:
setup()执行时,组件的 DOM 还没有挂载。setup()可以创建状态、计算值、侦听器和事件函数。- 依赖真实 DOM 的逻辑不能直接在
setup()主体中执行。 - 需要 DOM 的逻辑应放到
onMounted(),需要更新后 DOM 的逻辑应考虑onUpdated()或nextTick()。 setup()返回值会成为模板渲染上下文的一部分。
因此,下面的代码是不可靠的:
setup() {
const button = document.querySelector('button')
button?.focus()
}
此时当前组件自己的 <button> 可能尚未渲染,查询结果可能是 null,也可能意外选中了页面上其他组件的按钮。
应使用模板引用:
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const buttonRef = ref<HTMLButtonElement | null>(null)
onMounted(() => {
buttonRef.value?.focus()
})
</script>
<template>
<button ref="buttonRef">自动获得焦点</button>
</template>
这里 buttonRef 初始为 null 是正常状态,因为挂载前 DOM 元素尚不存在。
2.3 setup() 中没有组件实例 this
Composition API 的 setup() 不使用 Options API 风格的 this:
setup() {
// 错误思路:this.count、this.$emit
}
原因不是 TypeScript 类型没有声明,而是 setup() 的设计不依赖一个动态代理对象。逻辑直接通过变量和闭包组织:
setup(props, { emit }) {
const count = ref(0)
function increment() {
count.value++
emit('change', count.value)
}
return {
count,
increment,
}
}
这种方式的收益是依赖关系更显式:函数需要什么状态,就直接捕获或接收什么状态,而不是从一个隐式的 this 对象上查找。
三、<script setup> 到底做了什么
在 Vue 单文件组件中,推荐使用 <script setup>:
<script setup lang="ts">
import { ref, computed } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
function increment() {
count.value++
}
</script>
<template>
<button @click="increment">
{{ count }} / {{ doubled }}
</button>
</template>
<script setup> 是编译器语法。编译器会把顶层代码放入组件的 setup() 中,并自动把顶层绑定暴露给模板。它不是浏览器原生语法,必须经过 Vue SFC 编译工具处理;Vite 的 Vue 插件会负责这一步。
概念上可以近似理解为:
export default defineComponent({
setup() {
const count = ref(0)
function increment() {
count.value++
}
return {
count,
increment,
}
},
})
这是帮助理解的等价模型,不代表编译器输出一定与此逐字相同。
3.1 顶层代码按实例执行
<script setup lang="ts">
import { ref } from 'vue'
console.log('setup 执行')
const count = ref(0)
</script>
每创建一个组件实例,console.log 和 ref(0) 都会执行一次。也就是说,<script setup> 顶层并不等同于普通 .ts 模块的模块顶层。
对比:
// shared.ts
export const sharedCount = ref(0)
<script setup lang="ts">
import { sharedCount } from './shared'
import { ref } from 'vue'
const localCount = ref(0)
</script>
localCount:每个组件实例一份。sharedCount:所有导入该模块的组件共享一份。
四、setup() 的输入:props、emit、attrs 和 slots
4.1 Props 是只读的响应式输入
props 会随着父组件更新而变化,但子组件不应直接修改它:
setup(props) {
// 可以读取
console.log(props.title)
// 不应修改
// props.title = 'new title'
}
如果子组件需要修改某个值,应通过事件通知父组件:
<!-- 子组件 -->
<script setup lang="ts">
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function updateValue(event: Event) {
const input = event.target as HTMLInputElement
emit('update:modelValue', input.value)
}
</script>
<template>
<input :value="props.modelValue" @input="updateValue" />
</template>
父组件:
<script setup lang="ts">
import { ref } from 'vue'
import TextInput from './TextInput.vue'
const name = ref('')
</script>
<template>
<TextInput v-model="name" />
<p>{{ name }}</p>
</template>
数据流是:
父组件状态
↓ props
子组件显示
↓ emit update:modelValue
父组件更新状态
这比子组件直接修改输入值更容易追踪,因为状态的所有权仍然在父组件。
4.2 解构 props 的响应性边界
在 setup(props) 中直接解构:
setup(props) {
const { title } = props
return { title }
}
在 Vue 3 的常规语义下,title 会变成一个普通值,后续父组件更新 props.title 时,它不会自动保持响应式引用。
需要保留响应性时,可以使用 toRef 或 toRefs:
import { toRef, toRefs } from 'vue'
setup(props) {
const title = toRef(props, 'title')
// 或:
// const { title } = toRefs(props)
return { title }
}
toRef(props, 'title') 创建的是一个指向 props.title 的 ref,而不是复制当前值。
在较新的 Vue 版本和特定编译配置中,<script setup> 对 props 解构提供了编译器层面的响应式解构支持。这个能力属于版本敏感的编译语义;如果代码需要兼容较老的 Vue 3 版本,使用 toRefs 或直接访问 props.title 更明确。
4.3 emit 是显式的输出通道
TypeScript 项目中可以为事件声明参数:
const emit = defineEmits<{
save: [id: string]
cancel: []
}>()
emit('save', 'article-1')
emit('cancel')
这会在编译期检查事件名和参数类型,但它不会在运行时自动验证业务语义。例如,id: string 并不保证字符串一定对应数据库中存在的记录。
emit 的职责是通知父组件,不是直接修改父组件状态。父组件是否监听、如何处理,仍由父组件决定。
4.4 attrs 和 slots 的边界
使用普通 setup 时:
setup(props, { attrs, slots, emit }) {
console.log(attrs.class)
const content = slots.default?.()
return {}
}
attrs:没有声明为 props 或 emits 的属性和事件监听器。slots:父组件传入的插槽函数。emit:触发组件声明的自定义事件。
attrs 会随着父组件更新而变化,但它不是供 watch() 追踪的常规响应式对象。不要把它当成一个可以稳定侦听的状态源:
// 不应依赖这种写法来监听 attrs 变化
watch(() => attrs.someValue, () => {
// attrs 本身不提供普通响应式追踪语义
})
如果一个输入需要被可靠地追踪,应将它声明为 prop:
const props = defineProps<{
someValue: string
}>()
watch(() => props.someValue, () => {
// 明确、可追踪
})
五、生命周期:状态存在的时间边界
生命周期描述组件实例从创建到销毁的阶段。Composition API 通过 onXxx() 函数注册钩子。
常用钩子包括:
onBeforeMountonMountedonBeforeUpdateonUpdatedonBeforeUnmountonUnmountedonErrorCapturedonActivatedonDeactivated
5.1 挂载阶段
import { ref, onBeforeMount, onMounted } from 'vue'
const root = ref<HTMLElement | null>(null)
onBeforeMount(() => {
// 即将执行首次渲染
// 当前组件 DOM 还未插入
})
onMounted(() => {
// 当前组件 DOM 已创建并插入
root.value?.classList.add('ready')
})
典型因果关系是:
setup()
↓
创建响应式状态和渲染上下文
↓
执行首次渲染
↓
插入 DOM
↓
onMounted()
onMounted() 适合:
- 读取当前组件 DOM;
- 初始化依赖 DOM 的第三方库;
- 注册必须等元素存在后才能完成的监听器;
- 执行只需要在浏览器端发生的初始化。
但“子组件全部已经 mounted”不能简单理解为“所有相关异步任务都完成”。异步请求、图片加载和第三方库内部异步初始化,都可能在 onMounted() 之后继续执行。
5.2 更新阶段与 nextTick
响应式状态变化后,Vue 通常会批量调度 DOM 更新,而不是在每一次赋值后立刻修改 DOM:
const count = ref(0)
const button = ref<HTMLButtonElement | null>(null)
async function increment() {
count.value++
// 此时 DOM 可能还显示旧的 count
await nextTick()
// 此时本次更新对应的 DOM 已完成刷新
button.value?.focus()
}
nextTick() 等待的是 Vue 已排队的 DOM 更新完成,不是等待任意异步任务完成,也不是等待网络请求。
onUpdated() 适合观察组件更新后的阶段,但不应在其中无条件修改会触发再次更新的状态:
onUpdated(() => {
// 如果这里无条件 count.value++,
// 可能形成持续更新或更新循环
})
如果逻辑只依赖某个具体状态,通常应直接侦听那个状态,并在必要时使用 nextTick(),而不是让所有更新都触发处理。
5.3 卸载阶段与资源清理
组件卸载时,外部资源必须释放:
import { onMounted, onUnmounted } from 'vue'
function handleResize() {
console.log(window.innerWidth)
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
如果忘记移除监听器,旧组件实例虽然从 DOM 中消失,window 仍然持有 handleResize 的引用,导致:
- 事件继续触发;
- 回调访问旧状态;
- 闭包保留更多对象;
- 页面频繁创建和销毁组件时形成泄漏或重复执行。
同样需要清理的资源包括:
setInterval()、setTimeout();- WebSocket;
MutationObserver、ResizeObserver;- 第三方库实例;
- 未完成的请求;
- 手动创建的响应式 effect scope。
Vue 会自动停止组件作用域内创建的 watch() 和 watchEffect(),但它不会替你清理所有外部 API 资源。
5.4 生命周期钩子必须在正确的作用域中注册
以下写法是可靠的:
setup() {
onMounted(() => {
console.log('mounted')
})
}
以下写法存在问题:
setup() {
setTimeout(() => {
onMounted(() => {
console.log('too late')
})
}, 0)
}
生命周期注册函数需要在组件当前的 setup() 执行上下文中同步调用。异步回调执行时,Vue 不再处于原来的组件实例注册上下文中,因此无法可靠地把该钩子绑定到当前组件。
这条规则也适用于 Composable:
function useWindowWidth() {
const width = ref(window.innerWidth)
function update() {
width.value = window.innerWidth
}
onMounted(() => {
window.addEventListener('resize', update)
})
onUnmounted(() => {
window.removeEventListener('resize', update)
})
return { width }
}
useWindowWidth() 必须在 setup() 或另一个同步执行的 Composable 中调用,而不是在按钮点击、定时器回调或请求完成回调中临时调用。
5.5 SSR 边界
服务端渲染时没有浏览器 DOM,因此:
onMounted()、onUpdated()、onUnmounted()等依赖客户端组件生命周期的逻辑不会在服务端按浏览器方式运行;window、document不能在服务端初始化阶段直接访问;- 需要浏览器对象的代码应放在客户端生命周期中,或先判断运行环境。
例如:
import { ref, onMounted } from 'vue'
const width = ref(0)
onMounted(() => {
width.value = window.innerWidth
})
如果在 setup() 顶层直接执行 window.innerWidth,SSR 构建或服务端渲染可能抛出 ReferenceError: window is not defined。
六、响应式副作用与作用域
生命周期解决“组件什么时候存在”,作用域解决“副作用归谁管理”。
6.1 watch() 和 watchEffect() 的归属
const count = ref(0)
watch(count, value => {
console.log('count changed:', value)
})
如果这段代码在组件的 setup() 中同步执行,Vue 会把这个 watcher 绑定到当前组件。组件卸载时,watcher 会自动停止。
这并不意味着所有异步创建的 watcher 都自动安全:
let stop: (() => void) | undefined
setTimeout(() => {
stop = watch(count, value => {
console.log(value)
})
}, 1000)
这个 watcher 创建时已经脱离了原来的同步 setup() 上下文,不能依赖组件自动管理。应手动停止:
onUnmounted(() => {
stop?.()
})
更好的做法通常是提前创建 watcher,用条件控制是否处理,而不是延迟创建副作用。
6.2 effectScope():把多个 effect 作为一个单元管理
当一个 Composable 或独立模块内部创建多个响应式副作用时,可以用 effectScope() 统一停止:
import {
effectScope,
ref,
watch,
computed,
type EffectScope,
} from 'vue'
export function createScopedCounter() {
const scope: EffectScope = effectScope()
let count!: ReturnType<typeof ref<number>>
let doubled!: ReturnType<typeof computed<number>>
let stopLogging!: () => void
scope.run(() => {
count = ref(0)
doubled = computed(() => count.value * 2)
stopLogging = watch(count, value => {
console.log('count:', value)
})
})
return {
count,
doubled,
stop() {
scope.stop()
},
stopLogging,
}
}
调用:
const counter = createScopedCounter()
counter.count.value++
counter.stop()
counter.stop() 会停止该 scope 内的响应式副作用。
需要区分:
scope.stop():停止 scope 内的 effect 和清理回调;stopLogging():只停止一个 watcher;- 组件自动卸载:只自动管理绑定在组件作用域中的副作用。
effectScope() 更适合构建底层状态模块、可独立启动和停止的服务式逻辑,而不是普通组件逻辑的默认写法。
6.3 getCurrentScope() 和 onScopeDispose()
Composable 可以通过 onScopeDispose() 注册作用域销毁时的清理逻辑:
import {
getCurrentScope,
onScopeDispose,
ref,
} from 'vue'
export function useInterval(ms: number) {
const ticks = ref(0)
const timer = window.setInterval(() => {
ticks.value++
}, ms)
if (getCurrentScope()) {
onScopeDispose(() => {
window.clearInterval(timer)
})
}
return { ticks }
}
如果在组件 setup() 中调用,当前组件作用域销毁时会执行清理。
这里的 getCurrentScope() 判断很重要:如果这个函数也允许在组件外部调用,那么它不能无条件假设存在当前 Vue 作用域。没有作用域时,调用者应负责处理返回对象的停止方法,或者函数应该明确规定只能在 setup() 中使用。
在 Vue 3.5 及相关版本中,onScopeDispose() 支持静默处理缺少当前作用域的场景;具体参数行为属于版本敏感 API,跨版本库代码应查看目标 Vue 版本的 API 文档。
七、可组合函数:用闭包封装一组有边界的逻辑
可组合函数是一个按照约定组织 Vue Composition API 的函数。它通常:
- 创建或接收响应式状态;
- 注册计算值、watcher 或生命周期钩子;
- 提供命令式操作;
- 返回调用者需要的状态和方法;
- 负责自己创建的副作用清理。
例如,一个监听窗口宽度的 Composable:
// composables/useWindowWidth.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useWindowWidth() {
const width = ref(0)
function updateWidth() {
width.value = window.innerWidth
}
onMounted(() => {
updateWidth()
window.addEventListener('resize', updateWidth)
})
onUnmounted(() => {
window.removeEventListener('resize', updateWidth)
})
return {
width,
}
}
组件中使用:
<script setup lang="ts">
import { computed } from 'vue'
import { useWindowWidth } from '@/composables/useWindowWidth'
const { width } = useWindowWidth()
const isMobile = computed(() => width.value < 768)
</script>
<template>
<p>宽度:{{ width }}</p>
<p>{{ isMobile ? '移动端布局' : '桌面端布局' }}</p>
</template>
每个组件实例调用一次 useWindowWidth(),就会拥有自己的 width 和自己的事件监听器。Composable 函数本身可以被多个组件调用,但函数内部创建的状态不是共享的。
八、一个完整的异步 Composable:状态、并发、取消和错误
异步请求比简单计数器更能体现逻辑组织问题。一个可靠的请求 Composable 至少需要考虑:
- 初始状态;
- 加载状态;
- 成功结果;
- 错误;
- 组件卸载;
- 连续请求的竞态;
- 请求取消;
- 响应数据的类型。
下面实现一个通用的 useFetchJson:
// composables/useFetchJson.ts
import {
ref,
shallowRef,
onScopeDispose,
getCurrentScope,
type Ref,
} from 'vue'
export interface UseFetchJsonOptions {
immediate?: boolean
}
export function useFetchJson<T>(
url: Ref<string> | string,
options: UseFetchJsonOptions = {},
) {
const data = shallowRef<T | null>(null)
const error = shallowRef<unknown>(null)
const loading = ref(false)
let controller: AbortController | null = null
let requestId = 0
function getUrl() {
return typeof url === 'string' ? url : url.value
}
async function execute() {
const currentId = ++requestId
controller?.abort()
controller = new AbortController()
loading.value = true
error.value = null
try {
const response = await fetch(getUrl(), {
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const result = (await response.json()) as T
// 只允许最新请求写入状态
if (currentId === requestId) {
data.value = result
}
} catch (cause) {
// 主动取消不一定是业务错误
if (cause instanceof DOMException && cause.name === 'AbortError') {
return
}
if (currentId === requestId) {
error.value = cause
}
} finally {
if (currentId === requestId) {
loading.value = false
}
}
}
function cancel() {
controller?.abort()
controller = null
}
if (options.immediate) {
void execute()
}
if (getCurrentScope()) {
onScopeDispose(() => {
requestId++
cancel()
})
}
return {
data,
error,
loading,
execute,
cancel,
}
}
组件使用:
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useFetchJson } from '@/composables/useFetchJson'
interface User {
id: number
name: string
email: string
}
const userId = ref(1)
const url = computed(() => `/api/users/${userId.value}`)
const {
data: user,
error,
loading,
execute,
} = useFetchJson<User>(url, {
immediate: true,
})
</script>
<template>
<section>
<button :disabled="loading" @click="execute">
{{ loading ? '加载中…' : '重新加载' }}
</button>
<p v-if="error">请求失败:{{ String(error) }}</p>
<pre v-else-if="user">{{ user }}</pre>
<p v-else>暂无数据</p>
</section>
</template>
8.1 为什么需要 requestId
假设先后发起两个请求:
t0: 请求 A 开始
t1: 请求 B 开始
t2: 请求 B 返回
t3: 请求 A 返回
如果不做保护,A 在 t3 写入旧数据,页面最终显示的可能不是最新请求结果。
requestId 的逻辑是:
每次 execute:
requestId += 1
当前请求保存自己的 id
请求返回:
只有 currentId === requestId 才能写入状态
于是:
- A 的 id 为 1;
- B 的 id 为 2;
- B 返回时
2 === 2,允许写入; - A 返回时
1 !== 2,丢弃结果。
AbortController 负责尽早取消旧请求,requestId 负责即使取消不及时,也不让旧结果覆盖新结果。两者解决的是不同问题,不能只依赖其中一个。
8.2 为什么使用 shallowRef
接口返回对象通常由整体替换驱动:
data.value = result
shallowRef 只追踪 .value 的替换,不会递归把返回对象的所有嵌套属性转换成深层响应式对象。对于只展示或整体替换的 API 响应,这可以减少不必要的代理层级。
如果业务需要修改并追踪:
user.value.profile.name = 'new name'
则应明确考虑是否需要 ref 配合深层对象,或者使用 reactive 管理可变表单状态。shallowRef 不是“更快就应该总是使用”的替代品,它改变了嵌套属性的响应式语义。
九、逻辑组织:按“状态—派生—副作用—动作”分层
一个组件可以按因果关系组织,而不是按生命周期名称堆代码。
const query = ref('')
const page = ref(1)
const trimmedQuery = computed(() => query.value.trim())
const canSearch = computed(() => {
return trimmedQuery.value.length >= 2
})
watch(trimmedQuery, () => {
page.value = 1
})
async function search() {
if (!canSearch.value) return
// 执行请求
}
这里有四类逻辑:
1. 状态
用户或外部系统可以改变的事实:
const query = ref('')
const page = ref(1)
2. 派生状态
由其他状态计算得到,不应重复存储:
const trimmedQuery = computed(() => query.value.trim())
const canSearch = computed(() => trimmedQuery.value.length >= 2)
如果同时维护 query、trimmedQuery 和 canSearch 三份可变状态,就必须保证它们始终同步,状态空间会增加,错误路径也会增加。
3. 副作用
会影响组件外部系统或执行异步行为:
watch(trimmedQuery, () => {
page.value = 1
})
网络请求、日志、浏览器 API、缓存写入都属于副作用。
4. 动作
由用户或外部事件显式触发:
async function search() {
// 校验输入并开始请求
}
这种划分可以帮助判断逻辑应该放在哪里:
| 需求 | 合适位置 |
|---|---|
| 保存当前值 | ref / reactive |
| 根据状态计算结果 | computed |
| 响应状态变化并产生外部影响 | watch |
| 用户点击后执行 | 普通函数 |
| DOM 已存在后初始化 | onMounted |
| 组件离开后清理 | onUnmounted / onScopeDispose |
十、watch、watchEffect 与生命周期的关系
10.1 watch 追踪明确的源
watch(
() => props.userId,
async (newId, oldId) => {
console.log(oldId, '→', newId)
await loadUser(newId)
},
)
watch 只追踪第一个参数中明确读取的响应式源。它适合:
- 需要比较新旧值;
- 只关心某个具体字段;
- 需要明确控制触发条件;
- 需要配置
immediate、deep、flush。
10.2 watchEffect 自动收集同步读取的依赖
watchEffect(() => {
document.title = `用户 ${props.userId}`
})
它会执行一次,并追踪回调同步执行期间读取的响应式值。之后这些值变化时重新执行。
异步回调需要特别注意:
watchEffect(async () => {
const id = props.userId
await loadUser(id)
})
只有 await 之前同步读取的响应式依赖会被自动追踪。await 之后新读取的响应式值不属于这次自动收集范围。需要明确控制异步请求时,通常使用 watch(() => props.userId, ...) 更容易表达意图。
10.3 清理上一次副作用
侦听器回调中启动异步任务时,必须处理旧任务:
watch(
() => props.userId,
async (id, _oldId, onCleanup) => {
const controller = new AbortController()
onCleanup(() => {
controller.abort()
})
const response = await fetch(`/api/users/${id}`, {
signal: controller.signal,
})
// 处理结果
},
)
当源再次变化或 watcher 停止时,Vue 会调用清理函数。这个清理函数解决的是“当前 watcher 的上一次执行不再有效”,而 onUnmounted() 解决的是“组件整体离开”。
在 Vue 3.5 及之后还存在独立的 onWatcherCleanup() API,但它有同步注册等版本相关限制。为了兼容更广版本,回调参数中的 onCleanup 仍是明确的常用方式。
十一、Composable 的参数契约:值、ref 和 getter 不要混用
一个 Composable 如果只接受普通值:
function usePageTitle(title: string) {
const fullTitle = computed(() => `${title} - WR BLOG`)
return { fullTitle }
}
调用者传入 ref 时,得到的是 ref 对象本身,而不是其当前值:
const title = ref('Vue')
usePageTitle(title) // 类型和语义都不匹配
如果需要支持普通值、ref 和 getter,可以定义输入类型:
import {
computed,
toValue,
type MaybeRefOrGetter,
} from 'vue'
export function usePageTitle(
title: MaybeRefOrGetter<string>,
) {
const fullTitle = computed(() => {
return `${toValue(title)} - WR BLOG`
})
return { fullTitle }
}
调用方式:
const title = ref('Vue')
usePageTitle(title)
usePageTitle('Vue')
usePageTitle(() => title.value)
toValue() 会把普通值、ref 或 getter 统一解析为当前值。关键不是“参数越灵活越好”,而是契约必须明确:调用者知道传入的是快照,还是会随响应式源变化。
十二、依赖注入也是作用域设计
当祖先组件需要向后代组件提供状态时,可以使用 provide 和 inject,避免多层 props 透传:
// keys.ts
import type { InjectionKey, Ref } from 'vue'
export interface ThemeContext {
theme: Ref<'light' | 'dark'>
toggle: () => void
}
export const themeKey: InjectionKey<ThemeContext> = Symbol('theme')
提供方:
import { provide, ref } from 'vue'
import { themeKey, type ThemeContext } from './keys'
const theme = ref<'light' | 'dark'>('light')
function toggle() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
const context: ThemeContext = {
theme,
toggle,
}
provide(themeKey, context)
使用方:
import { inject } from 'vue'
import { themeKey } from './keys'
const themeContext = inject(themeKey)
if (!themeContext) {
throw new Error('ThemeContext is not provided')
}
themeContext.toggle()
provide/inject 的作用域是组件树,而不是 JavaScript 模块。它的共享范围由祖先—后代关系决定:
Provider 组件实例
↓
后代组件实例 A
后代组件实例 B
如果直接导出一个模块级 reactive 对象,所有使用该模块的组件都会共享它,即使它们不在同一棵组件子树中。这两种共享语义不能混为一谈。
十三、错误处理:错误发生在哪里,决定处理方式
13.1 请求错误应进入状态模型
网络请求失败不是异常系统可以完全替代的业务状态。组件通常需要显式表达:
const loading = ref(false)
const data = ref<Result | null>(null)
const error = ref<unknown>(null)
模板根据状态渲染:
<template>
<p v-if="loading">加载中</p>
<p v-else-if="error">加载失败</p>
<pre v-else-if="data">{{ data }}</pre>
<p v-else>暂无数据</p>
</template>
如果只在控制台打印错误,用户无法得到可见反馈,重试策略也无处实现。
13.2 生命周期回调中的错误
组件级错误边界可以使用:
import { onErrorCaptured } from 'vue'
onErrorCaptured((error, instance, info) => {
console.error('子树错误:', error, info)
// 返回 false 会阻止错误继续向上传播
return true
})
实际使用时应谨慎返回 false。它会停止错误继续传播,可能让全局监控系统无法收到错误。通常应记录上下文后让错误继续传播,除非当前边界确实有能力处理它。
应用级兜底可以配置:
const app = createApp(App)
app.config.errorHandler = (error, instance, info) => {
console.error('Vue error:', error, info)
}
app.mount('#app')
组件级 onErrorCaptured() 适合局部子树,app.config.errorHandler 适合应用级监控和兜底。它们都不能代替请求状态管理,也不能捕获所有浏览器原生事件回调中的错误。
十四、常见错误及诊断方法
错误一:把组件状态放在模块顶层
// 错误风险:所有组件共享
const count = ref(0)
export function useCounter() {
return { count }
}
如果两个组件都调用 useCounter(),它们会修改同一个 count。
如果需要每个组件独立状态:
export function useCounter() {
const count = ref(0)
function increment() {
count.value++
}
return { count, increment }
}
如果确实需要全局共享,模块级状态并非绝对错误,但应明确把它设计成 store,并处理 SSR 请求隔离问题。服务端进程中模块单例可能让不同用户请求共享数据,这是严重的状态串请求风险。
错误二:在 setup() 中直接访问 DOM
失败表现通常是:
- ref 为
null; - 初始化第三方库时抛错;
- SSR 中出现
window is not defined; - 查询到错误组件的同名元素。
诊断方法是检查逻辑是否依赖真实 DOM。如果依赖,应使用模板 ref 和 onMounted();如果只需要在状态变化后读取 DOM,使用 nextTick()。
错误三:异步回调中注册生命周期
await loadConfig()
onMounted(() => {
// 绑定关系不可靠
})
正确做法是先注册,再在钩子内部等待:
onMounted(async () => {
const config = await loadConfig()
applyConfig(config)
})
生命周期注册本身同步完成,回调内部可以异步执行。
错误四:请求返回顺序导致旧数据覆盖新数据
失败表现:
用户快速切换条件 A → B
页面最后却显示 A 的结果
应使用请求取消、请求序号或两者结合。仅仅设置 loading = false 不能解决数据覆盖问题,因为加载状态和结果有效性是两个不同维度。
错误五:忘记清理外部监听器
失败表现:
- 同一事件每次触发多次;
- 页面离开后仍有网络或定时器活动;
- 内存占用持续增长;
- 回调中出现已经卸载的组件状态。
诊断时可以在注册和清理处分别打印实例标识:
const id = Math.random().toString(36)
onMounted(() => {
console.log('add listener', id)
})
onUnmounted(() => {
console.log('remove listener', id)
})
生产代码应使用稳定的业务标识或开发工具,而不是依赖随机数定位问题。
十五、测试时如何利用 Composition API 的作用域
Composition API 的逻辑如果依赖明确输入和返回值,通常比依赖 this 的组件逻辑更容易测试。
例如:
import { describe, expect, it } from 'vitest'
import { nextTick, ref } from 'vue'
import { usePageTitle } from './usePageTitle'
describe('usePageTitle', () => {
it('supports reactive title', async () => {
const title = ref('Vue')
const result = usePageTitle(title)
expect(result.fullTitle.value).toBe('Vue - WR BLOG')
title.value = 'TypeScript'
await nextTick()
expect(result.fullTitle.value).toBe('TypeScript - WR BLOG')
})
})
这里测试的是明确契约:
- 输入可以是 ref;
- 返回值是 computed ref;
- 输入变化后派生值更新。
涉及定时器或事件监听时,测试还需要验证清理:
const { ticks, stop } = useSomeTimer()
stop()
如果 Composable 只能在组件 setup() 中调用,则测试工具需要挂载一个最小组件,或者使用 Vue Test Utils 提供的组件作用域。不要为了方便测试而破坏真实的生命周期约束。
十六、如何判断逻辑应该留在组件还是提取出去
可以按以下因果边界判断:
留在组件中
适合:
- 只服务于一个组件;
- 强依赖该组件模板 ref;
- 逻辑很短,提取后反而降低可读性;
- 没有独立的生命周期和资源管理需求。
提取为 Composable
适合:
- 同一组状态和副作用会在多个组件出现;
- 逻辑有明确输入输出;
- 需要统一处理清理、竞态、错误或缓存;
- 可以脱离具体模板测试。
提取为普通 TypeScript 函数
适合:
- 逻辑不需要响应式追踪;
- 不需要生命周期;
- 纯数据转换、校验、格式化或算法。
例如:
export function normalizeSearchQuery(input: string): string {
return input.trim().replace(/\s+/g, ' ')
}
没有必要把所有函数都包装成 Composable。Composables 的价值在于管理响应式状态和副作用,而不是给普通函数换一个命名方式。
十七、从 Options API 迁移时不要机械一一对应
Options API 常按 data、computed、methods、watch 和生命周期分区。Composition API 更适合按一个业务功能组织完整逻辑:
// 搜索逻辑放在一起
const query = ref('')
const results = ref<Result[]>([])
const loading = ref(false)
const canSearch = computed(() => query.value.trim().length > 0)
async function search() {
// ...
}
watch(query, () => {
// ...
})
这并不表示生命周期不重要,而是生命周期成为业务逻辑的一部分:
onMounted(loadInitialData)
onUnmounted(cancelPendingRequest)
如果一个组件中有多个互不相关的业务功能,继续按功能分组通常更清晰;如果所有逻辑都混在一个很长的 setup() 中,Composition API 只改变了语法,没有解决组织问题。
十八、核心边界总结
理解 setup、生命周期、作用域和逻辑组织,可以归纳为以下几条机制:
-
setup()是组件实例逻辑的同步初始化入口。
它创建状态、派生值、watcher、事件处理函数,并返回模板需要的绑定。 -
<script setup>是编译器语法。
其顶层代码按组件实例执行,不等同于普通模块顶层。 -
DOM 存在时间决定生命周期钩子的选择。
setup()阶段不能假设 DOM 存在;onMounted()之后才可安全访问当前组件 DOM。 -
生命周期注册必须发生在同步的 setup 上下文中。
异步回调中临时注册onMounted()等钩子无法可靠绑定到原组件。 -
组件内同步创建的 Vue 副作用通常随组件自动停止。
脱离当前作用域创建的 watcher、定时器、事件监听器和请求仍需要显式清理。 -
模块作用域和组件作用域决定状态是否共享。
setup()内部通常是实例隔离;模块顶层通常是全局共享。 -
Composable 应同时封装状态、动作、副作用和清理。
只返回一个响应式变量而不处理它创建的资源,会把生命周期责任泄露给调用者。 -
异步逻辑必须显式处理取消和并发。
请求取消减少无效工作,序号或其他有效性判断防止旧结果覆盖新结果。 -
状态、派生值、副作用和动作应区分。
这样可以减少重复状态,明确数据流,并让测试针对稳定契约进行。
Composition API 的核心并不是把代码从 data() 搬到 setup(),而是让每个状态和副作用拥有明确的创建位置、作用域、生命周期和所有权。只有这些边界清楚,组件逻辑才能从“能运行”进一步变成可复用、可测试且不会在卸载和并发场景中失控的代码。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 响应式原理:ref、reactive、computed、watch 与依赖追踪
- 下一篇:Vue 组件契约:Props、Emits、v-model、透传属性与边界
- 延伸:Vue Composable 设计:复用状态、清理副作用、参数契约和测试
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论