Vue 基础体系 · 第 5/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue Composition API:setup、生命周期、作用域和逻辑组织

Composition API 是 Vue 3 中一组用于组织组件逻辑的 API。它不是单一函数,而是由响应式状态、计算值、侦听器、生命周期钩子、依赖注入以及可组合函数(Composable)共同构成的编程模型。

这篇文章集中回答四个相互关联的问题:

  1. setup() 在组件创建过程中处于什么位置,它能访问什么,返回值如何进入模板?
  2. 生命周期钩子何时执行,为什么必须在 setup() 的同步执行阶段注册?
  3. “作用域”分别指什么:组件实例作用域、JavaScript 词法作用域、响应式副作用作用域,以及模块作用域?
  4. 如何把状态、派生值、副作用和事件处理组织成可复用、可测试的逻辑?

示例基于 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:组件上下文,包含 attrsslotsemit

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()

这里有几个重要边界:

  1. setup() 执行时,组件的 DOM 还没有挂载。
  2. setup() 可以创建状态、计算值、侦听器和事件函数。
  3. 依赖真实 DOM 的逻辑不能直接在 setup() 主体中执行。
  4. 需要 DOM 的逻辑应放到 onMounted(),需要更新后 DOM 的逻辑应考虑 onUpdated()nextTick()
  5. 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.logref(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 时,它不会自动保持响应式引用。

需要保留响应性时,可以使用 toReftoRefs

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 attrsslots 的边界

使用普通 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() 函数注册钩子。

常用钩子包括:

  • onBeforeMount
  • onMounted
  • onBeforeUpdate
  • onUpdated
  • onBeforeUnmount
  • onUnmounted
  • onErrorCaptured
  • onActivated
  • onDeactivated

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 的引用,导致:

  1. 事件继续触发;
  2. 回调访问旧状态;
  3. 闭包保留更多对象;
  4. 页面频繁创建和销毁组件时形成泄漏或重复执行。

同样需要清理的资源包括:

  • setInterval()setTimeout()
  • WebSocket;
  • MutationObserverResizeObserver
  • 第三方库实例;
  • 未完成的请求;
  • 手动创建的响应式 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() 等依赖客户端组件生命周期的逻辑不会在服务端按浏览器方式运行;
  • windowdocument 不能在服务端初始化阶段直接访问;
  • 需要浏览器对象的代码应放在客户端生命周期中,或先判断运行环境。

例如:

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 的函数。它通常:

  1. 创建或接收响应式状态;
  2. 注册计算值、watcher 或生命周期钩子;
  3. 提供命令式操作;
  4. 返回调用者需要的状态和方法;
  5. 负责自己创建的副作用清理。

例如,一个监听窗口宽度的 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)

如果同时维护 querytrimmedQuerycanSearch 三份可变状态,就必须保证它们始终同步,状态空间会增加,错误路径也会增加。

3. 副作用

会影响组件外部系统或执行异步行为:

watch(trimmedQuery, () => {
  page.value = 1
})

网络请求、日志、浏览器 API、缓存写入都属于副作用。

4. 动作

由用户或外部事件显式触发:

async function search() {
  // 校验输入并开始请求
}

这种划分可以帮助判断逻辑应该放在哪里:

需求 合适位置
保存当前值 ref / reactive
根据状态计算结果 computed
响应状态变化并产生外部影响 watch
用户点击后执行 普通函数
DOM 已存在后初始化 onMounted
组件离开后清理 onUnmounted / onScopeDispose

十、watchwatchEffect 与生命周期的关系

10.1 watch 追踪明确的源

watch(
  () => props.userId,
  async (newId, oldId) => {
    console.log(oldId, '→', newId)
    await loadUser(newId)
  },
)

watch 只追踪第一个参数中明确读取的响应式源。它适合:

  • 需要比较新旧值;
  • 只关心某个具体字段;
  • 需要明确控制触发条件;
  • 需要配置 immediatedeepflush

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 统一解析为当前值。关键不是“参数越灵活越好”,而是契约必须明确:调用者知道传入的是快照,还是会随响应式源变化。


十二、依赖注入也是作用域设计

当祖先组件需要向后代组件提供状态时,可以使用 provideinject,避免多层 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')
  })
})

这里测试的是明确契约:

  1. 输入可以是 ref;
  2. 返回值是 computed ref;
  3. 输入变化后派生值更新。

涉及定时器或事件监听时,测试还需要验证清理:

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 常按 datacomputedmethodswatch 和生命周期分区。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、生命周期、作用域和逻辑组织,可以归纳为以下几条机制:

  1. setup() 是组件实例逻辑的同步初始化入口。
    它创建状态、派生值、watcher、事件处理函数,并返回模板需要的绑定。

  2. <script setup> 是编译器语法。
    其顶层代码按组件实例执行,不等同于普通模块顶层。

  3. DOM 存在时间决定生命周期钩子的选择。
    setup() 阶段不能假设 DOM 存在;onMounted() 之后才可安全访问当前组件 DOM。

  4. 生命周期注册必须发生在同步的 setup 上下文中。
    异步回调中临时注册 onMounted() 等钩子无法可靠绑定到原组件。

  5. 组件内同步创建的 Vue 副作用通常随组件自动停止。
    脱离当前作用域创建的 watcher、定时器、事件监听器和请求仍需要显式清理。

  6. 模块作用域和组件作用域决定状态是否共享。
    setup() 内部通常是实例隔离;模块顶层通常是全局共享。

  7. Composable 应同时封装状态、动作、副作用和清理。
    只返回一个响应式变量而不处理它创建的资源,会把生命周期责任泄露给调用者。

  8. 异步逻辑必须显式处理取消和并发。
    请求取消减少无效工作,序号或其他有效性判断防止旧结果覆盖新结果。

  9. 状态、派生值、副作用和动作应区分。
    这样可以减少重复状态,明确数据流,并让测试针对稳定契约进行。

Composition API 的核心并不是把代码从 data() 搬到 setup(),而是让每个状态和副作用拥有明确的创建位置、作用域、生命周期和所有权。只有这些边界清楚,组件逻辑才能从“能运行”进一步变成可复用、可测试且不会在卸载和并发场景中失控的代码。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。