Vue 3 组合式 API 实战:setup 语法糖的最佳实践

yipeng
yipeng 正式会员正式会员认证极客认证极客
发布于 2026-09-25 05:45 ·3 浏览 ·3 回复

学完这篇,你能把 Vue 3 的 `<script setup>` 从“能跑”写到“团队里别人也愿意看”,包括 props/emits 的正确声明、逻辑抽成 composables、副作用清理和一套固定的代码排列顺序。

第一步:先把 setup() 函数换成 `<script setup>`

如果你还在写这种老写法:

<script>
import { ref } from 'vue'
export default {
  setup() {
    const count = ref(0)
    return { count }
  }
}
</script>

改成语法糖,少写一堆样板:

<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

`<script setup>` 里的顶层变量、函数、import 进来的组件,模板里直接就能用,不需要 return,也没有 `this`。

注意:`defineProps`、`defineEmits`、`defineExpose`、`defineModel` 是编译宏,不用 import,也不能写在 `if`、`function` 里面——它们必须在顶层。

第二步:props、emits、v-model 全部显式声明

这是最容易被新手忽略的一步:不声明就收不到,也不会有类型提示。用 TypeScript 时推荐类型式声明:

<script setup lang="ts">
const props = withDefaults(
  defineProps<{ title: string; count?: number }>(),
  { count: 0 }
)

const emit = defineEmits<{
  (e: 'change', value: number): void
  (e: 'close'): void
}>()

// Vue 3.4+ 才稳定可用,替代 props + emit('update:xxx')
const keyword = defineModel<string>({ default: '' })
</script>

`defineModel` 让 `v-model="keyword"` 双向绑定变成一行搞定,父组件写法不变。

注意:不要解构 props 直接用,比如 `const { count } = defineProps(...)`,在 Vue 3.5 之前会丢掉响应式。要么 `props.count`,要么 `const { count } = toRefs(props)`。用 `reactive` 声明对象同理,整体替换对象时响应式会断,所以状态统一用 `ref` + `.value` 更省心。

第三步:把能复用的逻辑抽成 composables

组合式 API 真正的价值在这一步。约定俗成:文件名和函数名都以 `use` 开头,放 `src/composables/` 目录,返回 ref 和函数。

// src/composables/useCounter.js
import { ref, computed } from 'vue'

export function useCounter(initial = 0) {
  const count = ref(initial)
  const double = computed(() => count.value * 2)
  function inc(step = 1) {
    count.value += step
  }
  return { count, double, inc }
}

组件里一行接入:

<script setup>
import { useCounter } from '@/composables/useCounter'
const { count, double, inc } = useCounter(10)
</script>

判断标准很简单:这段逻辑换个页面还能用,就该抽出去;只服务当前页面的,留在组件里别硬抽。

第四步:生命周期和副作用,谁注册谁清理

<script setup>
import { onMounted, onUnmounted, watch } from 'vue'

function onResize() { /* ... */ }

onMounted(() => {
  window.addEventListener('resize', onResize)
})

onUnmounted(() => {
  window.removeEventListener('resize', onResize)
})

const props = defineProps({ id: String })
const controller = { current: null }

watch(() => props.id, async (id) => {
  controller.current?.abort()
  controller.current = new AbortController()
  const res = await fetch(`/api/detail/${id}`, { signal: controller.current.signal })
  // ...
}, { immediate: true })
</script>

要点:`watch` 有明确来源用 `watch`;需要自动追踪依赖、又不需要旧值时才用 `watchEffect`。请求类副作用记得用 `AbortController` 或标志位取消,避免快速切页时旧响应覆盖新数据。

注意:定时器(`setInterval`)、事件监听、WebSocket、第三方图表实例,都必须在 `onUnmounted` 里销毁。这是 Vue 3 内存泄漏的第一大来源,`<script setup>` 不会帮你自动回收。

第五步:需要给父组件用的,用 defineExpose 明说

`<script setup>` 默认是封闭的,父组件通过 ref 拿不到里面任何东西,必须显式暴露:

<script setup>
import { ref } from 'vue'
const inputRef = ref(null)
function focus() { inputRef.value?.focus() }
defineExpose({ focus })
</script>

父组件:`const formRef = ref(null)`,模板上 `ref="formRef"`,然后 `formRef.value.focus()`。

第六步:固定一套书写顺序,减少 review 争吵

在团队里推行这个排列,谁看都顺:

  1. import 语句
  2. `defineProps` / `defineEmits` / `defineModel`
  3. `ref` / `reactive` 状态
  4. `computed`
  5. `watch` / `watchEffect`
  6. 普通方法
  7. 生命周期钩子
  8. `defineExpose`

另外两个小习惯:单文件超过 300 行就该拆子组件;不要把 `computed` 写成能改的“假状态”,需要写回就用 `get`/`set` 双函数。

小结

  • `<script setup>` 顶层即模板作用域,无 `this`、无 `return`;编译宏必须写在顶层。
  • props/emits/v-model 一律显式声明并用类型标注,别解构 props 丢响应式。
  • 可复用逻辑抽成 `useXxx` composables,状态统一用 `ref` + `.value`。
  • `onMounted` 里注册的副作用,`onUnmounted` 里必须清干净。
  • 子组件能力要 `defineExpose` 才对外可见。
  • 用固定的代码排列顺序,比争论风格更有效。
本文转载自 Clara轻量论坛系统 - 轻量级 PHP 论坛系统,原文地址:https://www.leleweb.cn/thread-586.html
转载请注明出处,版权归原作者所有。

全部回复 3

最长的电影
最长的电影 正式会员正式会员 1楼 2026-09-25 05:54

第一步那套只是"能跑"的起点,

不语
不语 正式会员正式会员认证极客认证极客 #139 2楼 2026-09-25 05:57
最长的电影:第一步那套只是"能跑"的起点,

你说得对,第一步本质上只是形态统一,不是能力提升——它的价值在于团队里别同时存在 Options API、`setup()` 函数和 `<script setup>` 三种写法,审代码时脑子要来回切换,那才是真成本。语法糖本身不值得多夸。

但"起点"这个定位很准,因为后面能翻车的地方全在第一步看不见的角落。补两个帖子里没展开、但实际项目里最容易踩的:

一是副作用清理,这才是"能跑"和"能上生产"的分水岭。 定时器、`addEventListener`、`watch` 里发起的异步请求,组件卸载后回调还在跑,就会对着已经销毁的 ref 写值。异步那段尤其隐蔽,快速切路由时旧请求后返回,会把新页面的数据覆盖掉。稳妥做法是 watch 回调里用 `onCleanup` 配合 `AbortController` 中断旧请求,或在 `onUnmounted` 里统一收尾,而不是靠"感觉不会那么快切"。

二是代码排列顺序,帖子里提到但我建议固化成硬规矩: imports → 编译宏(props/emits/model)→ 本地状态 `ref` → `computed` → 方法 → `watch` → 生命周期 → `defineExpose`。同一个文件从上往下读一遍就懂,code review 基本只看 diff 逻辑本身,不用先花两分钟找状态定义在哪。

另外 composable 别抽过头:只有带状态、带生命周期的逻辑才值得抽成 `useXxx`;纯计算、纯格式化直接进 `utils/` 当普通函数,套个 `use` 前缀反而增加跳转成本。

真要说第一步的意义,大概是让新人第一周就能写出不刺眼的文件;能不能让别人愿意读,确实还得看后面几步。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP正式会员正式会员 黑卡会员黑卡会员 #140 3楼 2026-09-25 06:03
不语:你说得对,第一步本质上只是**形态统一**,不是能力提升——它的价值在于团队里别同时存在 Options API、`setup()` 函数和 `` 三种写法,审…

「起点」这个定位我同意,但你补的这两条里,第一条还差一刀:composable 内部的清理别写 `onUnmounted`,要写 `onScopeDispose`。

原因是 composable 不一定在组件里被调用——它可能被另一个 composable 调用、被提到 `effectScope` 里批量管理,或者在 SSR 下组件根本不会 mount/unmount,`onUnmounted` 直接静默失效。`onScopeDispose` 挂在当前 effect scope 上,组件里调用时等价于 `onUnmounted`,脱离组件也能正常回收。写库、写公共 composable 的时候这一条基本是硬要求。

异步那段再补个细节:`AbortController` 管得住 fetch,管不住「已经 resolve 但你还在 await 后面写逻辑」的情况,所以老一点的稳妥写法是自增 requestId 比对,或者干脆在 `onCleanup` 里翻一个 `cancelled` 标志位。`watch` / `watchEffect` 的回调第三个参数就是 `onCleanup`,比手写 `onUnmounted` 收尾更贴场景,也不会漏掉「依赖变了要取消上一次」这种事。

排列顺序我完全赞成,只加一条:composable 的调用位置单独留一档,放在 imports 之后、本地 `ref` 之前,理由是它本身就是「外部依赖」,混在本地状态里会让读者分不清哪些状态是这个文件自己造的。至于抽过头的判断,我的线是「有没有状态或生命周期,加上有没有第二个调用点」——两条都不满足就老实放 `utils/`。

真要提醒一个坑:`watch` 里发请求 + `onCleanup` 中断,如果同时用了 `{ deep: true }` 监听大对象,清理反而会被高频触发拖慢,这种场景先考虑缩小监听范围或改 `computed` 派生。