别再写换皮 Options 了!Vue3 Setup 真正的用法的是这3步升级

0 阅读5分钟

很多人迁移 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 官网有张对比图:

image.png

很多人以为它表达的是:

代码更清晰了。

但它真正表达的是:

代码组织维度发生了变化。

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,输出是 detailqueryDetailLoading

  // ===== 详情模块 =====
  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 的升级到底在哪?

升级路径其实很自然:

  1. 在 setup 内按功能组织
  2. 将功能抽离 setup 方便共享
  3. 通过 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%+ 的异步代码