Skip to content

01|setup 的执行模型 ​

第四章概览 / 01。目标:把“组件创建”“DOM 挂载”和“资源释放”分成三个时刻,并说明代码属于哪个组件实例。

setup() 不是 mounted ​

Vue 为每个组件实例调用一次 setup(props, context)。此时 props 已可读、响应式状态可以创建、生命周期可以登记,但首次 DOM 还没有生成。setup() 没有组件 this;Composition API 返回值可以被后续 Options API 钩子访问,反方向却不成立,因为 Options 的 data、methods 还没有初始化。

一条最小时间线是:

阶段可以做不能假设
setup() 同步执行创建 ref / computed,登记 watcher 与生命周期,读取 props模板 DOM 已存在,this 指向组件
首次渲染与挂载Vue 创建并插入本组件 DOM异步子组件或 <Suspense> 内节点都已挂载
onMounted()访问模板 ref、连接浏览器 API、启动需在客户端存在的资源服务端会执行这个钩子
onUnmounted()释放手动创建的外部资源服务器渲染会经历卸载

关键规则不是“钩子必须写在同一个文件”,而是注册调用必须发生在 setup() 的同步调用链中。这样 Vue 才能把回调关联到当前活动组件实例。

为什么外部函数也知道当前实例 ​

可以把同步登记理解为一个临时上下文窗口:Vue 在调用实例 A 的 setup 前设定“当前是 A”;setup 调用 useClock(),后者登记钩子,钩子就进入 A 的生命周期列表;setup 返回后这个窗口恢复到外层。随后创建实例 B 时会重新打开 B 的窗口。

所以归属来自调用时刻,而不是函数定义在哪个文件。setTimeout 回调在窗口关闭后执行,单凭闭包里还能访问 A 的 ref,并不能使 Vue 的当前实例重新成为 A。普通资源的闭包可继续存在,Vue 的自动归属却已经结束,这正是必须保存手动停止句柄的原因。

最小可运行例子 ​

把下面文件放进一个 Vue 3 + TypeScript 项目,再在父组件中渲染两个 <SetupCounter :initial="0" />,即可观察每个实例拥有独立状态。

vue
<script lang="ts">
import { defineComponent, onMounted, onUnmounted, ref } from 'vue'

export default defineComponent({
  props: {
    initial: { type: Number, default: 0 }
  },
  setup(props, { expose }) {
    const count = ref(props.initial)
    const button = ref<HTMLButtonElement | null>(null)
    let timer: ReturnType<typeof setInterval> | undefined

    function increment() {
      count.value += 1
    }

    onMounted(() => {
      button.value?.focus()
      timer = setInterval(() => console.log('alive', count.value), 1000)
    })

    onUnmounted(() => {
      if (timer !== undefined) clearInterval(timer)
    })

    expose({ increment })
    return { button, count, increment }
  }
})
</script>

<template>
  <button ref="button" type="button" @click="increment">
    {{ count }}
  </button>
</template>

count、button、timer 都在这次 setup() 调用里创建,所以属于这一个实例。返回对象决定模板能看到什么;expose() 决定父组件通过组件 ref 能命令式调用什么,这两条公开面不是一回事。

这里把计时器放在 onMounted(),不仅因为它是浏览器例子,还因为 SSR 不会调用卸载钩子:若在 setup() 根部启动永久计时器,服务端没有对应的清理时刻。

同步注册限制与失败路径 ​

下面的代码把注册动作延迟到另一个任务。执行时当前实例已经不在调用栈上,Vue 无法可靠地把钩子归给原组件:

ts
// 错误:不是“晚一点 mounted”,而是晚一点才登记 mounted。
setTimeout(() => {
  onMounted(() => console.log('不会按预期归属组件'))
}, 0)

正确做法是同步登记钩子,把“数据尚未准备好”写进回调内部:

ts
const enabled = ref(false)

onMounted(() => {
  if (enabled.value) start()
})

同步注册也决定 watcher 的自动停止。同步创建的 watcher 会绑定当前实例,卸载时自动停止;在 setTimeout、Promise 回调或任意异步回调里新建的 watcher 没有这种保证,必须保存停止句柄并手动调用。

async setup() 的真实代价 ​

普通 setup() 应同步返回对象。把它声明为 async 会让组件成为异步依赖,只有位于 <Suspense> 后代中时才有完整的等待边界;等待时的 fallback 由这条边界提供;错误由祖先 onErrorCaptured 或全局错误处理器处理,Suspense 本身不提供错误插槽。普通 async setup() 中依赖实例的钩子应在第一次 await 前登记。

<script setup> 的顶层 await 是一个特殊编译分支:编译器会在等待后恢复活动实例上下文,但组件仍会被编译成异步 setup(),仍需考虑 <Suspense>。不要把这一特例推广到任意异步函数。

变体:初始快照还是持续响应 props ​

例子中的 ref(props.initial) 只复制初始值。父组件以后改变 initial,本地计数不会被覆盖,这适合“初值交给子组件,之后由子组件拥有”的合同。若合同是持续跟随 props,应显式派生或监听:

ts
const count = computed(() => props.initial)
// 或 watch(() => props.initial, value => { count.value = value })

两者语义不同:computed 不提供本地写入口;watch 会在父值变化时覆盖本地编辑。不要仅为“保持响应式”随意选择。

边界速查 ​

  • 每个组件实例调用一次 setup(),局部可变状态天然按实例隔离;模块级可变状态会被所有实例共享。
  • DOM 与 window / document 不在 setup() 根部直接访问;放到客户端挂载边界。
  • 生命周期和需要自动回收的 watcher 在同步 setup 调用链中创建。
  • onUnmounted 会在客户端释放计时器、监听器和连接;它不会替你中止任意手写请求,也不会在 SSR 中运行。
  • 父组件需要命令式能力时使用最小 expose / defineExpose,通常优先 props 与 emits。

练习:实现有归属的时钟 ​

创建 SetupClock.vue,合同如下:

  • prop label: string 必填;首次渲染显示 label: 0。
  • 挂载后每 1000ms 将本实例的 elapsed 加一;提供“暂停/继续”按钮。
  • 两个实例同时挂载时互不影响;卸载任一实例后,它的计时器不再触发,另一个继续。
  • 服务端渲染只输出 0,不启动计时器,也不访问浏览器对象。
  • 用可替换的 20ms 间隔做一次手动实验,记录卸载前后的日志次数。

参考答案:查看第 01 题。

自检 ​

如果你能解释“调用 composable 的函数写在外部文件,为什么钩子仍能属于当前组件”,并能指出异步创建 watcher 后谁负责停止它,就掌握了本篇的执行模型。

用心记录每一步成长