最后更新:2026-01-19

用了大半年 Vue3,回头看 Vue2 Options API,有一种”当初怎么忍下来的”感觉。

不是说 Options API 不好,只是 Composition API 在代码组织上真的更灵活。以前一个组件功能多了,data、methods、computed、watch 分散在各处,逻辑关联的代码物理上却离得很远,读起来要不停地上下翻。

现在用 Composition API,相关逻辑可以放在一起,还能轻松抽成 composable 复用。

setup 语法糖

Vue3 有两种写法,标准写法和 <script setup> 语法糖。

标准写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script>
import { ref, computed } from 'vue'

export default {
setup() {
const count = ref(0)
const double = computed(() => count.value * 2)

function increment() {
count.value++
}

// 需要手动 return 才能在模板中使用
return { count, double, increment }
}
}
</script>

<script setup> 语法糖:

1
2
3
4
5
6
7
8
9
10
11
<script setup>
import { ref, computed } from 'vue'

const count = ref(0)
const double = computed(() => count.value * 2)

function increment() {
count.value++
}
// 不需要 return,顶层变量自动暴露给模板
</script>

现在基本都用 <script setup>,代码量少,结构更清晰。后面的例子都用这种写法。

ref 还是 reactive

这是刚上手 Vue3 时最容易纠结的问题。

ref 用来创建响应式数据,基本类型必须用它,对象也可以用:

1
2
3
4
5
6
7
8
9
10
<script setup>
import { ref } from 'vue'

const count = ref(0) // 基本类型
const user = ref({ name: '' }) // 对象也可以

// 访问和修改都要 .value
count.value++
user.value.name = 'Eric'
</script>

reactive 只能用于对象,不需要 .value

1
2
3
4
5
6
7
8
9
10
11
12
<script setup>
import { reactive } from 'vue'

const state = reactive({
count: 0,
name: ''
})

// 直接访问属性
state.count++
state.name = 'Eric'
</script>

听起来 reactive 更方便,但它有几个坑:

1
2
3
4
5
// 解构会失去响应式
const { count } = state // count 不再是响应式的!

// 重新赋值整个对象会断开响应
state = { count: 1 } // ❌ 这样 reactive 失效了

我现在的习惯:基本类型用 ref,复杂对象状态也倾向于用 refref({})),保持一致性,少踩坑。如果团队规范是 reactive,注意别随意解构就行。

computed 和 watch

computed 跟 Vue2 没太大区别,声明一个依赖其他数据的计算值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script setup>
import { ref, computed } from 'vue'

const firstName = ref('Ben')
const lastName = ref('Cui')

// 只读 computed
const fullName = computed(() => `${firstName.value} ${lastName.value}`)

// 可写 computed
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (val) => {
const parts = val.split(' ')
firstName.value = parts[0]
lastName.value = parts[1]
}
})
</script>

watch 监听数据变化执行副作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script setup>
import { ref, watch, watchEffect } from 'vue'

const keyword = ref('')

// 监听单个值
watch(keyword, (newVal, oldVal) => {
console.log('搜索词变了:', newVal)
})

// 监听对象深层
const user = ref({ name: '', age: 0 })
watch(user, (newVal) => {
// deep 监听需要显式开启
}, { deep: true })

// 监听对象的某个属性,用 getter 函数
watch(
() => user.value.name,
(name) => {
console.log('名字变了:', name)
}
)

watchEffect 不用指定监听谁,自动追踪内部用到的响应式数据:

1
2
3
4
5
6
7
8
9
10
11
<script setup>
import { ref, watchEffect } from 'vue'

const id = ref(1)

// 自动追踪 id,id 变了就重新执行
watchEffect(async () => {
const data = await fetchUser(id.value)
// ...
})
</script>

watchEffect 写起来简单,但有时候追踪范围不明确,调试起来麻烦。我一般复杂情况还是用 watch 明确指定。

组合函数(Composable)

这是 Composition API 最大的亮点。把相关逻辑抽成独立函数,在组件之间复用。

比如封装一个分页逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// composables/usePagination.js
import { ref, computed } from 'vue'

export function usePagination(fetchFn, { pageSize = 10 } = {}) {
const currentPage = ref(1)
const total = ref(0)
const list = ref([])
const loading = ref(false)

const totalPages = computed(() => Math.ceil(total.value / pageSize))

async function fetchData() {
loading.value = true
try {
const res = await fetchFn({ page: currentPage.value, pageSize })
list.value = res.list
total.value = res.total
} finally {
loading.value = false
}
}

function changePage(page) {
currentPage.value = page
fetchData()
}

// 初始加载
fetchData()

return {
list,
loading,
currentPage,
total,
totalPages,
changePage,
refresh: fetchData,
}
}

在组件里使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup>
import { usePagination } from '@/composables/usePagination'
import { getUserList } from '@/api/user'

const { list, loading, currentPage, total, changePage } = usePagination(getUserList)
</script>

<template>
<div v-loading="loading">
<UserCard v-for="user in list" :key="user.id" :user="user" />
<Pagination :current="currentPage" :total="total" @change="changePage" />
</div>
</template>

这种封装方式比 Vue2 的 mixin 好在哪里:

  • 来源清晰,usePagination 返回什么一目了然,不像 mixin 会”神秘地”注入属性
  • 没有命名冲突,多个 composable 在同一组件里共存也没问题
  • 可以传参,更灵活

生命周期

Vue3 生命周期钩子改成了函数形式,在 setup 里调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup>
import { onMounted, onUnmounted, onBeforeUnmount } from 'vue'

onMounted(() => {
console.log('组件挂载了')
window.addEventListener('resize', handleResize)
})

onBeforeUnmount(() => {
// 记得清理副作用
window.removeEventListener('resize', handleResize)
})
</script>

beforeCreatecreated 在 Composition API 里不存在,setup 函数本身就相当于这两个时机。

一些踩过的坑

响应式丢失toRefs 可以解构 reactive 对象时保持响应式

1
2
3
4
5
6
import { reactive, toRefs } from 'vue'

const state = reactive({ count: 0, name: '' })

// 解构后 count 和 name 仍然是响应式的
const { count, name } = toRefs(state)

异步 setup<script setup> 里用 async/await 需要配合 Suspense 组件,否则模板会在数据准备好前渲染

1
2
3
4
5
<!-- 父组件用 Suspense 包裹 -->
<Suspense>
<AsyncChild />
<template #fallback>加载中...</template>
</Suspense>

模板 ref:获取 DOM 节点的方式变了

1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup>
import { ref, onMounted } from 'vue'

const inputRef = ref(null) // 名字要和模板里的 ref 属性一致

onMounted(() => {
inputRef.value?.focus()
})
</script>

<template>
<input ref="inputRef" />
</template>

总体来说 Vue3 迁移成本没我想象的高,新项目直接上就行。老项目的话,Vue3 兼容了 Options API,可以渐进式迁移,不用一次全改。