Luna Tech

Tutorials For Dummies.

Vue: Composables

2022-03-29


0. Intro

Composables | Vue.js (vuejs.org)

Composable 是什么?

在 Vue app 里面,“composable”是一个 function,它 leverages Vue Composition API to encapsulate and reuse stateful logic.

当我们在写前端 app 的时候,经常需要复用常见任务的逻辑。比如我们可能要在多个地方 format date,所以我们会写一个专门的 function 来做这件事。

这个 formatter function 包含的是 stateless logic,简单地把 input 转化成 output,有很多 library 就是做 stateless logic reuse 的,比如 lodashdate-fns

Stateful logic vs stateless logic

stateful logic 包含对于变化的 state 的管理,比如 track the current position of the mouse 或者 touch gesture or connection status to a db.


1. Mouse Tracker Example

单一组件 - 无复用

如果我们想用 Composition API 直接在一个 component 里面实现 mouse tracking 功能,那么代码是这样的:

<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>

多组件可复用

但是假如我们想要在多个组件里面复用这套逻辑,我们就需要把逻辑单独提取出来放在一个文件里,作为 composable function:

// 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 }
}

在组件中使用

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

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

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

区别

我们可以看到,逻辑其实是一样的,唯一的区别就是一个把逻辑和组件放在一起,另一个是把逻辑单独写成一个 function。

The core logic remains exactly the same - all we had to do was move it into an external function and return the state that should be exposed.

Conposable 可以使用全部的 Composition API function

Same as inside a component, you can use the full range of Composition API functions in composables.


2. Nest Composables

一个 composable function 可以调用一个或多个其他的 composable functions. 这样我们就可以用 small, isolated unites 来写 complex logic,就和写 component 一个逻辑。

In fact, this is why we decided to call the collection of APIs that make this pattern possible Composition API.

Example - 进一步分解 useMouse 为更小的 composables

We can extract the logic of adding and cleaning up a DOM event listener into its own composable.

// 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() function 可以被简化为:

// 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 }
}

Each component instance calling useMouse() will create its own copies of x and y state so they won’t interfere with one another. If you want to manage shared state between components, read the State Management chapter.


3. Async State Example

我们之前的例子(useMouse())没有用到 argument,当我们在做 async data fetching 的时候,经常需要 handle 不同的 states,比如:loading, success, error:

<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>

我们可以把这个 function extract into a composable,增加复用性。

// 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 }
}

在我们的 component 里面可以直接使用 composable:

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

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

这个 useFetch() 会接收一个 static URL string 作为 input,然后 fetch data。

假如我们想要 re-fetch data 怎么办?

我们可以接收 refs as an argument。

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

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

  function doFetch() {
    // reset state before fetching..
    data.value = null
    error.value = null
    // unref() unwraps potential refs
    fetch(unref(url))
      .then((res) => res.json())
      .then((json) => (data.value = json))
      .catch((err) => (error.value = err))
  }

  if (isRef(url)) {
    // setup reactive re-fetch if input URL is a ref
    watchEffect(doFetch)
  } else {
    // otherwise, just fetch once
    // and avoid the overhead of a watcher
    doFetch()
  }

  return { data, error }
}

这个版本的 useFetch() 现在会接收两个 argument,一个是 static URL string,一个是 refs of URL strings. 当它发现 URL 是 dynamic ref 时(通过isRef()来 check),它就可以用 watchEffect() 来 set up a reactive effect。

这个 effect 会马上运行,然后 tracking the URL ref as a dependency in the process.

每当 URL ref 改变时,我们就会 reset and re-fetch data。


4. Conventions and Best Practices

Naming

一般都用 camelCase,以use 开始。e.g., useFetch()

Input Arguments

Composable 可以接收 ref arguments,即便它不依赖于 ref 来实现 reactivity。

假如你写的 composable 是给别的 developer 用的,那最好 handle input argument 是 refs 的情况。

helper functions

unref() utility function 可以帮助我们实现 ref handling,unwrap input ref and get the value.

import { unref } from 'vue'

function useFeature(maybeRef) {
  // if maybeRef is indeed a ref, its .value will be returned
  // otherwise, maybeRef is returned as-is
  const value = unref(maybeRef)
}

假如 input 是 ref 时,你的 composable 会创建 reactive effects,那么确保你要么 explicitly watch the ref with watch(), or call unref() inside a watchEffect() so that it is properly tracked.

Return Values

我们在 composable 里面只用 ref(),不用 reactive(),这是因为推荐的惯例是 always return an object of refs from composables, so that it can be destructured in components while retaining reactivity.

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

假如 return reactive object 的话会导致 destructures to lose the reactivity connection to the state inside the composable, while the refs will retain that connection.

想 return state 怎么办?

If you prefer to use returned state from composables as object properties, you can wrap the returned object with reactive() so that the refs are unwrapped.

const mouse = reactive(useMouse())
// mouse.x is linked to original ref
console.log(mouse.x)

usage in template:

Mouse position is at: {{ mouse.x }}, {{ mouse.y }}

Side Effects

在 composable 里面可以 perform side effects (e.g. adding DOM event listeners or fetching data),但是要遵守以下规则(跟生命周期相关):

Usage Restrictions

Composable 只能在 <script setup> or the setup() hook 里面被同步调用(be called synchronously),有些时候你也可以在 lifecycle hooks(比如 onMounted())里面调用它们。

<script setup> is the only place where you can call composables after usage of await. The compiler automatically restores the active instance context after the async operation for you.

Why? - 需要 context

These are the contexts where Vue is able to determine the current active component instance. Access to an active component instance is necessary so that:

  1. Lifecycle hooks can be registered to it.
  2. Computed properties and watchers can be linked to it for disposal on component unmount.

5. Extracting Composables for Code Organization

除了 reuse 之外,composables 也可以被用于 code organization。当你的组件复杂度变高时,你可能会发现有些组件太大了,这时候就可以用 Composition API 来组织代码。

Composition API gives you the full flexibility to organize your component code into smaller functions based on logical concerns.

To some extent, you can think of these extracted composables as component-scoped services that can talk to one another.

<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>

6. Using Composables in Options API

在 Options API 里面用 Composable 是可以的,但是只能在 setup() 里面调用。

返回的 binding 也必须从 setup() 里面返回,这样才能 be exposed to this and the template.

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
}

7. Comparisons with Other Techniques

vs. Mixins

Vue 2 里面有 mixins option,可以用来 extract component logic into reusable units,但是 mixin 有三个缺点,所以 vue 3 弃用了 mixin:

  1. Unclear source of properties: when using many mixins, it becomes unclear which instance property is injected by which mixin, making it difficult to trace the implementation and understand the component’s behavior. This is also why we recommend using the refs + destructure pattern for composables: it makes the property source clear in consuming components.
  2. Namespace collisions: multiple mixins from different authors can potentially register the same property keys, causing namespace collisions. With composables, you can rename the destructured variables if there are conflicting keys from different composables.
  3. Implicit cross-mixin communication: multiple mixins that need to interact with one another have to rely on shared property keys, making them implicitly coupled. With composables, values returned from one composable can be passed into another as arguments, just like normal functions.

vs. Renderless Components

在 slot 那章讨论过 renderless components。

composable 比 renderless component 好的地方在于不需要创建 component instance, overhead 少,建议在需要复用逻辑的时候用 composable,需要用逻辑+View的时候用 components。

composables do not incur the extra component instance overhead. When used across an entire application, the amount of extra component instances created by the renderless component pattern can become a noticeable performance overhead.

The recommendation is to use composables when reusing pure logic, and use components when reusing both logic and visual layout.

vs. React Hooks

Composable 的灵感源于 React hooks,不过 Vue composable 是基于 Vue 的响应式系统,跟 React hook 的 execution model 不同。

This is discussed in more details in the Composition API FAQ .


8. Further Reading