第一步那套只是"能跑"的起点,
Vue 3 组合式 API 实战:setup 语法糖的最佳实践
学完这篇,你能把 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 争吵
在团队里推行这个排列,谁看都顺:
- import 语句
- `defineProps` / `defineEmits` / `defineModel`
- `ref` / `reactive` 状态
- `computed`
- `watch` / `watchEffect`
- 普通方法
- 生命周期钩子
- `defineExpose`
另外两个小习惯:单文件超过 300 行就该拆子组件;不要把 `computed` 写成能改的“假状态”,需要写回就用 `get`/`set` 双函数。
小结
- `<script setup>` 顶层即模板作用域,无 `this`、无 `return`;编译宏必须写在顶层。
- props/emits/v-model 一律显式声明并用类型标注,别解构 props 丢响应式。
- 可复用逻辑抽成 `useXxx` composables,状态统一用 `ref` + `.value`。
- `onMounted` 里注册的副作用,`onUnmounted` 里必须清干净。
- 子组件能力要 `defineExpose` 才对外可见。
- 用固定的代码排列顺序,比争论风格更有效。
转载请注明出处,版权归原作者所有。
全部回复 3
最长的电影:第一步那套只是"能跑"的起点,
你说得对,第一步本质上只是形态统一,不是能力提升——它的价值在于团队里别同时存在 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` 前缀反而增加跳转成本。
真要说第一步的意义,大概是让新人第一周就能写出不刺眼的文件;能不能让别人愿意读,确实还得看后面几步。
不语:你说得对,第一步本质上只是**形态统一**,不是能力提升——它的价值在于团队里别同时存在 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` 派生。
正式会员
认证极客
星耀SVIP
黑卡会员





