很多人迁移 Vue3 后,写的 Setup 只是 Options API 的换皮——看似整洁,实则依然无序膨胀。其实 Composition API 的核心不是 Setup 语法,而是按功能组织代码!今天从一个详情页出发,带你3步升级,真正吃透它。
从简单的详情页开始
假设我们有一个页面:
- 展示 业务详情
- 展示 用户信息
- 有一个 确认操作(confirm)
在 Vue2 里我们会这样写:
- mounted 里拉数据
- methods 里写 confirm
- data 里存 loading
迁移到 Vue.js 3 后,很多人会写成这样:
setup() {
const route = useRoute()
const detail = ref(null)
const queryDetailLoading = ref(false)
const user = ref(null)
const confirmLoading = ref(false)
async function queryDetail(id: string) {
queryDetailLoading.value = true
detail.value = await api.getDetail(id)
queryDetailLoading.value = false
}
async function queryUser(id: string) {
user.value = await api.getUser(id)
}
async function confirm(id: string) {
confirmLoading.value = true
await api.confirm(id)
confirmLoading.value = false
}
onMounted(() => {
queryDetail(route.params.id)
queryUser(route.params.id)
})
return {
detail,
queryDetailLoading,
user,
confirmLoading,
confirm
}
}
看起来没问题,甚至代码还很整洁:
- 定义外部变量
- 定义内部变量
- 定义内部方法
- 在生命周期中触发初始化操作
但是,代码的组织度好像没有变化?上面的代码好像还是很容易无序膨胀,出现几千行的 .vue 文件?应该怎么抽象?
Options 到 Composition 的优势在哪?
Vue 官网有张对比图:
很多人以为它表达的是:
代码更清晰了。
但它真正表达的是:
代码组织维度发生了变化。
Options API 是:
- data
- computed
- methods
- watch
按类型组织。
而 Composition API,可以按照:
- 详情模块
- 用户模块
- 操作模块
功能模块组织代码。
升级1:按功能模块组织代码
先把同一职责的代码写在一起。
setup() {
const route = useRoute()
// ===== 详情模块 =====
const detail = ref(null)
const queryDetailLoading = ref(false)
async function queryDetail(id: string) {
queryDetailLoading.value = true
detail.value = await api.getDetail(id)
queryDetailLoading.value = false
}
onMounted(() => {
queryDetail(route.params.id)
})
// ===== 用户模块 =====
const user = ref(null)
async function queryUser(id: string) {
user.value = await api.getUser(id)
}
onMounted(() => {
queryUser(route.params.id)
})
// ===== confirm 模块 =====
const confirmLoading = ref(false)
async function confirm(id: string) {
confirmLoading.value = true
await api.confirm(id)
confirmLoading.value = false
}
return {
detail,
queryDetailLoading,
user,
confirmLoading,
confirm
}
}
这里有个 Vue 2 很容易忽略的思维定式:
在 Vue 3 中,可以有多个生命周期钩子,可以写多个 onMounted
这一刻,setup 不再是一个“大仓库”,而是一个“功能组合器”。
生命周期不再是“一个入口”,它可以属于不同功能块。
这样的代码组织,才是官网对比图中的样子。
升级2:拆分 Setup,useXxx 的诞生
在 setup 中拆分功能块后,可以很自然地将各个功能块拆分出 setup。
比如对于详情模块,输入是 route.params.id,输出是 detail 和 queryDetailLoading
// ===== 详情模块 =====
const detail = ref(null)
const queryDetailLoading = ref(false)
async function queryDetail(id: string) {
queryDetailLoading.value = true
detail.value = await api.getDetail(id)
queryDetailLoading.value = false
}
onMounted(() => {
queryDetail(route.params.id)
})
重构为
function useDetail(id: string) {
const detail = ref(null)
const queryDetailLoading = ref(false)
async function queryDetail(id: string) {
queryDetailLoading.value = true
detail.value = await api.getDetail(id)
queryDetailLoading.value = false
}
onMounted(() => queryDetail(id))
return {
detail,
queryDetailLoading
}
}
注意不要将
const route = useRoute()抽到useDetail中,保持useDetail依据 id 获取数据的单一职责。 Composition 的抽象边界应该围绕“数据输入输出”,而不是围绕“框架能力”。
于是 setup 成为
setup() {
const route = useRoute()
// ===== 详情模块 =====
const { detail, queryDetailLoading } = useDetail(route.params.id)
// ===== 用户模块 =====
const { user } = useUser(route.params.id)
// ===== confirm 模块 =====
const { confirm, confirmLoading } = useConfirm()
return {
detail,
queryDetailLoading,
user,
confirmLoading,
confirm
}
}
通过这种方式,各个 composition 有自己的职责,setup 负责视图层数据的聚合,你再不会写出流水账式的代码。
升级3:从生命周期驱动到数据驱动
到这里,其实我们已经完成了“功能拆分”。
但还有一个更重要的转变:setup 不应该围绕生命周期组织,而应该围绕数据变化组织。
比如经常遇到的问题:如果路由参数变化要怎么做呢?
实际这里的逻辑是,当 id 变化时,重新获取 detail
function useDetail(id: MaybeRefOrGetter<string>) {
// ...
watch(() => toValue(id), () => queryDetail(toValue(id)), { immediate: true })
return {
detail,
queryDetailLoading
}
}
setup() {
const route = useRoute()
// ===== 详情模块 =====
const { detail, queryDetailLoading } = useDetail(() => route.params.id)
// ...
return {
detail,
queryDetailLoading,
// ...
}
}
如果使用了 将 props 传递给路由组件,还可以将 route 统一到组件标准的 props 操作:
setup(props) {
// ===== 详情模块 =====
const { detail, queryDetailLoading } = useDetail(() => props.id)
// ...
return {
detail,
queryDetailLoading,
// ...
}
}
回顾:Composition 的升级到底在哪?
升级路径其实很自然:
- 在 setup 内按功能组织
- 将功能抽离 setup 方便共享
- 通过 watch 将生命周期驱动转向数据驱动
Vue3 没让代码变乱。它提供了按功能组织代码的能力。
它的真正价值,不在于 setup,而在于它允许我们按“业务模型”组织代码,而不是按“框架结构”组织代码。
这可以让我们写出更内聚,更单一职责的代码。
在 Vue 2 中想达成这种能力需要通过 mixin 的方式,但 mixin 是通过在 this 上动态添加属性的方式进行的,这导致 mixin 在类型推导上极其困难,极度依赖对实现细节的了解。
升级加餐:不拆 setup,也能简单
如果你已经接受“按功能组织 + 数据驱动副作用”这个思路,那么其实可以再进一步,把这些模式固化下来。
在很多场景下,setup 中的内容没有复用的必要,单独抽到其它文件中有点大材小用。
有没有不拆分,还能保证各功能块高度内聚的写法?我写了 vue-asyncx 用于解决这个问题。
setup(props) {
// ===== 详情模块 =====
const detail = ref(null)
const queryDetailLoading = ref(false)
async function queryDetail(id: string) {
queryDetailLoading.value = true
detail.value = await api.getDetail(id)
queryDetailLoading.value = false
}
watch(() => props.id, () => queryDetail(props.id), { immediate: true })
// ===== 用户模块 =====
const user = ref(null)
async function queryUser(id: string) {
user.value = await api.getUser(id)
}
watch(() => props.id, () => queryUser(props.id), { immediate: true })
// ===== confirm 模块 =====
const confirmLoading = ref(false)
async function confirm(id: string) {
confirmLoading.value = true
await api.confirm(id)
confirmLoading.value = false
}
return {
detail,
queryDetailLoading,
user,
confirmLoading,
confirm
}
}
可以重构为
import { useAsyncData, useAsync } from 'vue-asyncx'
setup(props) {
// ===== 详情模块 =====
const {
detail,
queryDetailLoading
} = useAsyncData('detail', () => api.getDetail(props.id), {
watch: () => props.id, immediate: true
})
// ===== 用户模块 =====
const { user } = useAsyncData('user', () => api.getUser(props.id), {
watch: () => props.id, immediate: true
})
// ===== confirm 模块 =====
const { confirm, confirmLoading } = useAsync('confirm', (id: string) => api.confirm(id))
return {
detail,
queryDetailLoading,
user,
confirmLoading,
confirm
}
}
核心代码从19行减少到10行,代码量减少接近 50%,而语义化、组织性不丢失。
更多详细用法,见:早点下班:在 Vue3 中少写 40%+ 的异步代码