Vue 可组合项是开发 Vue 应用时非常有用的工具。它们让开发者能够轻松地在应用中复用逻辑。除了允许“无状态”逻辑(例如格式化或常规计算)之外,可组合项还使我们能够在整个应用中复用有状态逻辑。

为什么要使用可组合项?

可组合函数允许您在应用中复用有状态逻辑。当某个逻辑在两个以上的地方使用时,我们通常希望将其合并到其自己的函数中。大多数情况下,该逻辑被认为是“无状态的”,这意味着它接受输入并返回输出。文档中提到了日期格式,但这也可能包括货币计算或字符串验证之类的功能。

在现代 Web 应用中,经常会有一些逻辑需要随时间推移管理状态。在典型的组件内部,我们能够根据组件内不同变量的“状态”来调整应用程序。有时,这些逻辑,或者至少是其中的一部分,会在整个应用中重复使用

例如,在电商应用中,你可能有一些逻辑来增加或减少用户添加到购物车中的商品数量。这种逻辑既可以在产品页面上使用,也可以在购物车内部使用。

这两个地方的外观和感觉会有所不同,因此复用整个组件毫无意义——但我们仍然希望将逻辑集中化,以便于代码维护。这就是可组合组件 (Composables) 的用武之地。

简单的可组合示例

我们来看一个简单的计数器示例。这是一个非常简单的Counter组件的代码。

<script setup lang="ts">
  import { ref } from 'vue'
  import type { Ref } from 'vue'

  const count: Ref<number> = ref(0)
  const increment = () => {
    count.value++
  }
  const decrement = () => {
    count.value--
  }
</script>

<template>
  <div class="bg-teal-100 border-2 border-gray-800 rounded-xl p-4 w-64">
    <div class="text-center mb-4">
      <span class="text-lg font-medium text-gray-800">Count: {{ count }}</span>
    </div>

    <div class="flex gap-2 justify-center">
      <button
        @click="decrement"
        class="bg-red-100 border-2 border-gray-800 rounded px-4 py-0 text-gray-800 font-medium hover:bg-red-500 transition-colors"
      >
        -
      </button>

      <button
        @click="count = 0"
        class="bg-gray-100 border-2 border-gray-800 rounded px-4 py-0 text-gray-800 font-medium hover:bg-gray-300 transition-colors"
      >
        Reset
      </button>

      <button
        @click="increment"
        class="bg-green-100 border-2 border-gray-800 rounded px-4 py-0 text-gray-800 font-medium hover:bg-green-500 transition-colors"
      >
        +
      </button>
    </div>
  </div>
</template>

该组件的输出如下所示:

这很好用,但如果我们最终需要在另一个外观和感觉完全不同的组件中使用相同的计数器逻辑,那么我们最终会重复这个逻辑。我们可以将逻辑提取到可组合组件中,并在任何需要的地方访问相同的状态逻辑

// counter.ts
import { ref } from 'vue'
import type { Ref } from 'vue'

export default function useCounter(): Readonly<{
  count: Ref<number>
  increment: () => void
  decrement: () => void
}> {
  const count: Ref<number> = ref(0)
  const increment = () => {
    count.value++
  }
  const decrement = () => {
    count.value--
  }
  return { count, increment, decrement }
}

然后我们更新组件中的脚本标签以使用可组合项:

<script setup>
import { useCounter } from '@/counter.ts'

const { count, increment, decrement } = useCounter()
</script>

<template>
  ...
</template>

现在我们可以在整个应用程序的多个组件中使用此逻辑。

您会注意到,只有逻辑被复制,每个组件仍然拥有各自的count状态副本。使用可组合组件并不意味着状态会在组件之间共享,而只是有状态逻辑会被共享。

复杂可组合示例

Vue 文档中给出了一个使用可组合项处理异步数据获取的示例。

Util 函数fetch

在开始使用可组合函数之前,我们需要为 API 设置一个实用函数fetch。这是因为我们希望确保每个请求在失败时都会抛出错误。fetch如果请求返回错误状态,API 不会抛出错误。我们必须检查response.ok以验证状态,然后在必要时抛出错误

// utils.ts
export async function handleFetch(url: string, options: RequestInit = {}): Promise<Response> {
  const res = await fetch(url, options)
  if (!res.ok) {
    const err = await res.text()
    throw new Error(err)
  }
  return res
}

 

useAsyncState 可组合

当使用异步状态时,请求可以处于几种不同的状态:

  • 待办的
  • 已解决
  • 被拒绝

除了这些状态之外,我们还想跟踪从请求返回的数据或错误。

// useAsyncState.ts
import { shallowRef } from 'vue'
import type { Ref } from 'vue'

// Specify a type for the response
export type AsyncState<T> = {
  data: Ref<T | null>
  error: Ref<Error | null>
  isPending: Ref<boolean>
  isResolved: Ref<boolean>
  isRejected: Ref<boolean>
}

export default function useAsyncState<T>(promise: Promise<T>): AsyncState<T> {
  // I used shallowRef instead of ref to avoid deep reactivity
  // I only care about the top-level properties being reactive
  const data = shallowRef<T | null>(null)
  const error = shallowRef<Error | null>(null)
  const isPending = shallowRef(false)
  const isResolved = shallowRef(false)
  const isRejected = shallowRef(false)

  data.value = null
  error.value = null
  isPending.value = true
  isRejected.value = false
  isResolved.value = false

  promise.then((result) => {
    data.value = result
    isPending.value = false
    isResolved.value = true
  }).catch(err => {
    error.value = err
    isPending.value = false
    isRejected.value = true
  })

  return { data, error, isPending, isResolved, isRejected }
}

这为不同的状态提供了一些更明确的属性,而不是依赖于data和中的值error。您还会注意到,这个可组合项接受的是 Promise,而不是像文档中显示的那样接受 URL 字符串。不同的端点会有不同的响应类型,我希望能够处理这个可组合项之外的响应类型。

在组件中的使用

我设置了一个端点,它会等待随机秒数,然后才返回成功或错误。我的组件正在使用可组合项调用此端点,并使用可组合项中的数据来更新模板。

 

错误状态显示如下:

 

 

为了使其更容易解释和理解,我将<script>标签和<template>组件的各个部分分开。

脚本

<script lang="ts" setup>
import { ref, unref } from 'vue'
import type { Ref } from 'vue'
import { useAsyncState } from '@/composables'
import type { AsyncState } from '@/composables'
import { handleFetch } from '@/utils'

interface RandomResponse {
  msg: string
}

async function getRandomResponse(): Promise<RandomResponse> {
  const response = await handleFetch('https://briancbarrow.com/api/random')
  const text = await response.text()
  return { msg: text }
}

const randomResponseData: Ref<AsyncState<RandomResponse> | null> = ref(null)

const handleMakeRequest = async () => {
    const data = getRandomResponse()
    randomResponseData.value = useAsyncState(data)
}
</script>

这里我们有一个方法,getRandomResponse它调用一个端点并返回一个 Promise。这个 Promise 随后被传入useAsyncStatewhenhandleMakeRequest函数。这将完整的返回值放入randomResponseDataref 中,然后我们可以在模板中使用它。

我不会展示完整的模板,而只展示其中的几个部分。

这里你可以看到两个按钮根据不同的状态被使用。我使用了一个单独的按钮元素来指示“加载”状态,但实际上你可以使用可组合属性来设置disabled按钮的属性并更改文本。

<button
          v-if="
            !randomResponseData?.isPending &&
            !randomResponseData?.error &&
            !randomResponseData?.data
          "
          class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
          @click="handleMakeRequest"
        >
          Make Request
        </button>

        <!-- Loading State Button -->
        <button
          v-if="randomResponseData?.isPending"
          disabled
          class="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg opacity-75 cursor-not-allowed flex items-center mx-auto"
        >
          <svg
            class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
            xmlns="http://www.w3.org/2000/svg"
            fill="none"
            viewBox="0 0 24 24"
          >
            <circle
              class="opacity-25"
              cx="12"
              cy="12"
              r="10"
              stroke="currentColor"
              stroke-width="4"
            ></circle>
            <path
              class="opacity-75"
              fill="currentColor"
              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
            ></path>
          </svg>
          Loading...
        </button>

以下是表中的几行:

<tr class="divide-x divide-gray-200">
  <td class="py-4 pr-4 pl-4 text-sm font-medium whitespace-nowrap text-gray-900 sm:pl-0">
    isPending
  </td>
  <td
    class="p-4 text-sm whitespace-nowrap text-gray-500"
    :class="randomResponseData?.isPending ? 'bg-blue-500' : 'bg-gray-300'"
  ></td>
  <td class="p-4 text-sm whitespace-nowrap text-gray-500">
    {{ randomResponseData?.isPending }}
  </td>
</tr>

<tr class="divide-x divide-gray-200">
  <td class="py-4 pr-4 pl-4 text-sm font-medium whitespace-nowrap text-gray-900 sm:pl-0">
    data
  </td>
  <td
    class="p-4 text-sm whitespace-nowrap text-gray-500"
    :class="randomResponseData?.data ? 'bg-green-500' : 'bg-gray-300'"
  ></td>
  <td class="p-4 text-sm whitespace-nowrap text-gray-500">
    {{ unref(randomResponseData?.data)?.msg }}
  </td>
</tr>

在这些tr标签中,您可以看到模板根据来自可组合项的状态渲染不同的内容。

结论
可组合项是 Vue 3 中非常有用的工具。随着项目规模和范围的增长,了解如何以及何时使用可组合项可以长期提高项目的可维护性。

关键是确定何时需要跨组件重用状态逻辑,然后将其提取到结构良好的可组合中,以正确处理边缘情况。

如需更完整地查看代码,可下载附件。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。