跳转到内容

可组合

提示

本节假设您已具备Composition API的基本知识。如果您只使用Options API学习Vue,可以将API首选项设置为Composition API(使用左侧侧边栏顶部的切换按钮),并重新阅读响应式基础生命周期钩子章节。

什么是“可组合”?

在Vue应用中,“可组合”是一个利用Vue的Composition API来封装和重用有状态逻辑的函数。

在构建前端应用时,我们经常需要重用常见任务的逻辑。例如,我们可能需要在多个地方格式化日期,所以我们提取一个可重用的函数。这个格式化函数封装了无状态逻辑:它接受一些输入并立即返回预期的输出。有许多库可以用于重用无状态逻辑 - 例如lodashdate-fns,您可能已经听说过。

相比之下,有状态逻辑涉及管理随时间变化的州。一个简单的例子是跟踪页面上鼠标的当前位置。在实际场景中,它也可能是更复杂的逻辑,如触摸手势或与数据库的连接状态。

鼠标跟踪示例

如果我们直接在组件内部使用Composition API实现鼠标跟踪功能,它将看起来像这样

vue
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const x = ref(0)
const y = ref(0)

function update(event) {
  x.value = event.pageX
  y.value = event.pageY
}

onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>

<template>Mouse position is at: {{ x }}, {{ y }}</template>

但如果我们想在多个组件中重用相同的逻辑呢?我们可以将逻辑提取到一个外部文件中,作为一个可组合函数

js
// mouse.js
import { ref, onMounted, onUnmounted } from 'vue'

// by convention, composable function names start with "use"
export function useMouse() {
  // state encapsulated and managed by the composable
  const x = ref(0)
  const y = ref(0)

  // a composable can update its managed state over time.
  function update(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  // a composable can also hook into its owner component's
  // lifecycle to setup and teardown side effects.
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  // expose managed state as return value
  return { x, y }
}

然后在组件中使用它这样

vue
<script setup>
import { useMouse } from './mouse.js'

const { x, y } = useMouse()
</script>

<template>Mouse position is at: {{ x }}, {{ y }}</template>
鼠标位置在:0, 0

在沙盒中尝试

正如我们所见,核心逻辑保持不变 - 我们所做的只是将其移动到外部函数中,并返回应该公开的状态。就像在组件内部一样,您可以在可组合函数中使用Composition API函数的全套功能。现在,相同的useMouse()功能可以在任何组件中使用。

尽管可组合组件有一个很好的特性,那就是你也可以嵌套它们:一个可组合函数可以调用一个或多个其他可组合函数。这使得我们能够使用小的、独立的单元来组合复杂的逻辑,就像我们使用组件来组合整个应用程序一样。事实上,这就是我们决定将使这种模式成为可能的API集合称为Composition API的原因。

例如,我们可以将添加和删除DOM事件监听器的逻辑提取到自己的可组合组件中

js
// event.js
import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, callback) {
  // if you want, you can also make this
  // support selector strings as target
  onMounted(() => target.addEventListener(event, callback))
  onUnmounted(() => target.removeEventListener(event, callback))
}

现在我们的useMouse()可组合组件可以简化为

js
// mouse.js
import { ref } from 'vue'
import { useEventListener } from './event'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  useEventListener(window, 'mousemove', (event) => {
    x.value = event.pageX
    y.value = event.pageY
  })

  return { x, y }
}

提示

每个调用useMouse()的组件实例将创建自己的xy状态副本,这样它们就不会相互干扰。如果你想在组件之间管理共享状态,请阅读状态管理章节。

异步状态示例

useMouse()可组合函数不接收任何参数,那么让我们看看另一个使用它的例子。在进行异步数据获取时,我们经常需要处理不同的状态:加载、成功和错误

vue
<script setup>
import { ref } from 'vue'

const data = ref(null)
const error = ref(null)

fetch('...')
  .then((res) => res.json())
  .then((json) => (data.value = json))
  .catch((err) => (error.value = err))
</script>

<template>
  <div v-if="error">Oops! Error encountered: {{ error.message }}</div>
  <div v-else-if="data">
    Data loaded:
    <pre>{{ data }}</pre>
  </div>
  <div v-else>Loading...</div>
</template>

在需要获取数据的每个组件中重复此模式会很麻烦。让我们将其提取到一个可组合组件中

js
// fetch.js
import { ref } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)

  fetch(url)
    .then((res) => res.json())
    .then((json) => (data.value = json))
    .catch((err) => (error.value = err))

  return { data, error }
}

现在在我们的组件中,我们只需要做

vue
<script setup>
import { useFetch } from './fetch.js'

const { data, error } = useFetch('...')
</script>

接受响应式状态

useFetch()接收一个静态URL字符串作为输入 - 因此它只执行一次获取,然后完成。如果我们想让它每次URL变化时都重新获取呢?为了实现这一点,我们需要将响应式状态传递给可组合函数,并让可组合函数创建使用传递状态的执行动作的监视器。

例如,useFetch()应该能够接受一个ref

js
const url = ref('/initial-url')

const { data, error } = useFetch(url)

// this should trigger a re-fetch
url.value = '/new-url'

或者,接受一个获取函数

js
// re-fetch when props.id changes
const { data, error } = useFetch(() => `/posts/${props.id}`)

我们可以使用watchEffect()toValue() API重构我们现有的实现

js
// fetch.js
import { ref, watchEffect, toValue } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)

  const fetchData = () => {
    // reset state before fetching..
    data.value = null
    error.value = null

    fetch(toValue(url))
      .then((res) => res.json())
      .then((json) => (data.value = json))
      .catch((err) => (error.value = err))
  }

  watchEffect(() => {
    fetchData()
  })

  return { data, error }
}

toValue()是在3.3版本中添加的API。它旨在将refs或getters标准化为值。如果参数是一个ref,它返回ref的值;如果参数是一个函数,它将调用该函数并返回其返回值。否则,它返回参数本身。它的工作方式与unref()类似,但针对函数有特殊处理。

请注意,在watchEffect回调中调用toValue(url)。这确保了在toValue()标准化过程中访问的任何响应式依赖项都被监视器跟踪。

这个版本的useFetch()现在接受静态URL字符串、refs和getters,使其更加灵活。监视效果将立即运行,并跟踪在toValue(url)期间访问的任何依赖项。如果没有跟踪任何依赖项(例如,url已经是一个字符串),效果只运行一次;否则,它将在跟踪的依赖项更改时重新运行。

以下是useFetch()的更新版本,用于演示目的,包含人工延迟和随机错误。

约定和最佳实践

命名

约定以camelCase命名以"use"开头的组合函数。

输入参数

即使组合函数不依赖于它们进行响应性,组合函数也可以接受ref或getter参数。如果您正在编写可能被其他开发者使用的组合函数,处理输入参数为refs或getters的情况是一个好主意。在这种情况下,toValue()实用函数会很有用。

js
import { toValue } from 'vue'

function useFeature(maybeRefOrGetter) {
  // If maybeRefOrGetter is a ref or a getter,
  // its normalized value will be returned.
  // Otherwise, it is returned as-is.
  const value = toValue(maybeRefOrGetter)
}

如果您的组合函数在输入为ref或getter时创建响应式效果,请确保使用watch()显式地监视ref/getter,或者在一个watchEffect()调用中调用toValue(),以确保它得到适当的跟踪。

前面讨论的useFetch()实现提供了接受refs、getters和普通值作为输入参数的组合函数的具体示例。

返回值

您可能已经注意到,我们在组合函数中一直使用ref()而不是reactive()。建议的组合函数约定是始终返回一个包含多个refs的普通、非响应式对象。这使得它可以被组件解构,同时保持响应性。

js
// x and y are refs
const { x, y } = useMouse()

从组合函数返回响应式对象将导致这些解构失去与组合函数内部状态的响应性连接,而refs将保持这种连接。

如果您更喜欢将组合函数返回的状态作为对象属性使用,可以使用reactive()包装返回的对象,以便取消refs的包装。例如

js
const mouse = reactive(useMouse())
// mouse.x is linked to original ref
console.log(mouse.x)
模板
Mouse position is at: {{ mouse.x }}, {{ mouse.y }}

副作用

在组合函数中执行副作用(例如添加DOM事件监听器或获取数据)是可以的,但请注意以下规则。

  • 如果您正在开发一个使用服务器端渲染(SSR)的应用程序,请确保在生命周期钩子中执行DOM特定的副作用,例如onMounted()。这些钩子仅在浏览器中调用,因此您可以确定它们内部的代码可以访问DOM。

  • 请记住在 onUnmounted() 中清理副作用。例如,如果组合式组件设置了 DOM 事件监听器,它应该在 onUnmounted() 中移除该监听器,正如我们在 useMouse() 示例中所见。使用自动为您完成此操作的组合式,如 useEventListener() 示例,可能是一个好主意。

使用限制

组合式只能在 <script setup>setup() 钩子中调用。它们也应该在这些上下文中以 同步 的方式调用。在某些情况下,您还可以在生命周期钩子如 onMounted() 中调用它们。

这些限制很重要,因为这些是 Vue 能够确定当前活动组件实例的上下文。访问活动组件实例是必要的,以便

  1. 将生命周期钩子注册到它。

  2. 计算属性和侦听器可以与之关联,这样它们就可以在实例卸载时被销毁,以防止内存泄漏。

提示

<script setup> 是唯一可以调用组合式并在使用 await 后进行调用的地方。编译器会在异步操作后自动为您恢复活动实例上下文。

提取组合式以进行代码组织

组合式不仅可以用于复用,还可以用于代码组织。随着组件复杂性的增加,您可能会得到难以导航和推理的组件。组合式 API 可以让您完全灵活地将组件代码组织成基于逻辑关注点的小函数

vue
<script setup>
import { useFeatureA } from './featureA.js'
import { useFeatureB } from './featureB.js'
import { useFeatureC } from './featureC.js'

const { foo, bar } = useFeatureA()
const { baz } = useFeatureB(foo)
const { qux } = useFeatureC(baz)
</script>

在某种程度上,您可以把这些提取的组合式看作是组件范围内的服务,它们可以相互通信。

在 Options API 中使用组合式

如果您正在使用 Options API,组合式必须在 setup() 中调用,并且必须从 setup() 中返回返回的绑定,以便它们暴露给 this 和模板

js
import { useMouse } from './mouse.js'
import { useFetch } from './fetch.js'

export default {
  setup() {
    const { x, y } = useMouse()
    const { data, error } = useFetch('...')
    return { x, y, data, error }
  },
  mounted() {
    // setup() exposed properties can be accessed on `this`
    console.log(this.x)
  }
  // ...other options
}

与其他技术的比较

与 Mixins 的比较

来自 Vue 2 的用户可能熟悉 mixins 选项,它也允许我们将组件逻辑提取成可重用的单元。mixins 有三个主要的缺点

  1. 属性来源不明确:当使用许多 mixins 时,变得不清楚哪个实例属性是由哪个 mixin 注入的,这使得跟踪实现和理解组件的行为变得困难。这也是我们为什么建议使用 refs + destructure 模式来创建组合式:它在消费组件中使属性来源更清晰。

  2. 命名空间冲突:来自不同作者的多 mixins 可能会注册相同的属性键,导致命名空间冲突。使用组合式时,如果不同组合式有冲突的键,可以重命名解构变量。

  3. 隐式跨 mixin 通信:需要相互交互的多个 mixins 必须依赖于共享的属性键,这使得它们隐式耦合。使用组合式时,可以从一个组合式传递值到另一个作为参数,就像正常函数一样。

出于上述原因,我们不再建议在 Vue 3 中使用 mixins。该功能仅保留用于迁移和熟悉性原因。

与无状态组件相比

在组件槽位章节中,我们讨论了基于作用域槽位的 无状态组件 模式。我们还使用无状态组件实现了相同的鼠标跟踪演示。

可组合组件相较于无状态组件的主要优势是,可组合组件不会产生额外的组件实例开销。当在整个应用程序中使用时,无状态组件模式创建的额外组件实例数量可能会成为明显的性能开销。

建议在重用纯逻辑时使用可组合组件,在重用逻辑和视觉布局时使用组件。

与 React Hooks 相比

如果您有 React 的经验,您可能会注意到这看起来非常类似于自定义 React Hooks。组合 API 在一定程度上受到了 React Hooks 的启发,Vue 的可组合组件确实在逻辑组合能力方面与 React Hooks 类似。然而,Vue 的可组合组件是基于 Vue 的细粒度响应式系统,这与 React Hooks 的执行模型在本质上不同。这将在组合 API FAQ中更详细地讨论。

进一步阅读

  • 深入响应式:了解 Vue 的响应式系统是如何工作的。
  • 状态管理:了解管理多个组件共享状态的模式。
  • 测试可组合组件:单元测试可组合组件的技巧。
  • VueUse:一个不断增长的 Vue 可组合组件集合。源代码也是一个很好的学习资源。
已加载可组合组件