Rethink vue composition api

June 14, 2021

对 vue3的composition api 进行了一些反思和总结,总结了一些经验和实践,特此记录。

逻辑抽离与关注点分离

Vue2 使用 options api,根据 类型将逻辑分成了 data, computed, watch, methods 几个部分,在vue官网中对这种方式进行了反思,这种根据类型 组织代码的方式在 逻辑比较复杂的时候会变得很难理解,经常会为了一个逻辑不得不翻看大段代码。composition api 就是为了解决这么一个问题,将 一个 逻辑关注点(logic concern)组织到一个 composition api中,在一个 composition api中可以有 data, computed, watch, methods。可以参考文档

组合

composition api 归根结底还是一个函数,一个composition api 只做一件事,一个复杂的逻辑可以使用多个composition api组合而成。这样可以使得逻辑更加独立,提高复用性,体现专注点分离。

比如 vueuse 中 的 useDark 实现了 响应式的 dark mode 以及持久化

import { useDark, useToggle } from '@vueuse/core'

const isDark = useDark()
const toggleDark = useToggle(isDark)

useDark 实际上内部使用了 usePreferredDarkuseStorage ,前者是使用媒体查询来确定是否为dark模式,后者则是实现了数据的持久化。

function useDark(options: UseDarkOptions = {}) {
  const {
    selector = 'html', // css selector for target element applying to
    attribute = 'class', // HTML attribute applying the target element
    valueDark = 'dark', // value applying to the target element when isDark = true
    valueLight = '', // value applying to the target element when isDark = false
    window = defaultWindow,
    storage = defaultWindow?.localStorage, // storage object, 
    storageKey = 'vueuse-color-scheme', // key to persist the data
    listenToStorageChanges = true,
  } = options

  const preferredDark = usePreferredDark({ window })
  const store = storageKey == null
    ? ref<ColorSchemes>('auto')
    : useStorage<ColorSchemes>(storageKey, 'auto', storage, { window, listenToStorageChanges })

  const isDark = computed<boolean>({
    get() {
      return store.value === 'auto'
        ? preferredDark.value
        : store.value === 'dark'
    },
    set(v) {
      if (v === preferredDark.value)
        store.value = 'auto'
      else
        store.value = v ? 'dark' : 'light'
    },
  })

  const onChanged = options.onChanged || ((v: boolean) => {
    const el = window?.document.querySelector(selector)
    if (attribute === 'class') {
      el?.classList.toggle(valueDark, v)
      if (valueLight)
        el?.classList.toggle(valueLight, !v)
    }
    else { el?.setAttribute(attribute, v ? valueDark : valueLight) }
  })

  watch(isDark, onChanged, { flush: 'post' })

  tryOnMounted(() => onChanged(isDark.value))

  return isDark
}

可以看到,使用了 usePreferredDark 获取到 初始值(按照设计,如果在storage里读不到内容,就会使用 system preferences)。然后使用了 useStorage 来从 storage里读取指定key 的值。整个函数返回的是一个 computed value, 在修改它的时候会给target 设置属性。

接下来看下 usePreferredDark

export function usePreferredDark(options?: ConfigurableWindow) {
  return useMediaQuery('(prefers-color-scheme: dark)', options)
}

可以看到,这是是调用了 useMediaQuery

/**
 * Reactive Media Query.
 *
 * @link https://vueuse.org/useMediaQuery
 * @param query
 * @param options
 */
export function useMediaQuery(query: string, options: ConfigurableWindow = {}) {
  const { window = defaultWindow } = options
  if (!window)
    return ref(false)

  const mediaQuery = window.matchMedia(query)
  const matches = ref(mediaQuery.matches)

  const handler = (event: MediaQueryListEvent) => {
    matches.value = event.matches
  }

  if ('addEventListener' in mediaQuery) {
    mediaQuery.addEventListener('change', handler)
  }
  else {
    // @ts-expect-error - fallback for Safari < 14 and older browsers
    mediaQuery.addListener(handler)
  }

  tryOnUnmounted(() => {
    if ('removeEventListener' in mediaQuery) {
      mediaQuery.removeEventListener('change', handler)
    }
    else {
      // @ts-expect-error - fallback for Safari < 14 and older browsers
      mediaQuery.removeListener(handler)
    }
  })

  return matches
}

可以看到 useMediaQuery 的实现也很简单,是使用 window.matchMedia获取初始值,然后使用事件来处理变化实现响应式的。这也是 将 普通变量转化为响应式变量最常见的一种模式

接下来看下 useStorage

/**
 * Reactive LocalStorage/SessionStorage.
 *
 * @link https://vueuse.org/useStorage
 * @param key
 * @param defaultValue
 * @param storage
 * @param options
 */
export function useStorage<T extends(string|number|boolean|object|null)> (
  key: string,
  defaultValue: T,
  storage: StorageLike | undefined = defaultWindow?.localStorage,
  options: StorageOptions = {},
) {
  const {
    flush = 'pre',
    deep = true,
    listenToStorageChanges = true,
    window = defaultWindow,
    eventFilter,
  } = options

  const data = ref<T>(defaultValue)

  const type = defaultValue == null
    ? 'any'
    : typeof defaultValue === 'boolean'
      ? 'boolean'
      : typeof defaultValue === 'string'
        ? 'string'
        : typeof defaultValue === 'object'
          ? 'object'
          : Array.isArray(defaultValue)
            ? 'object'
            : !Number.isNaN(defaultValue)
              ? 'number'
              : 'any'

  function read() {
    if (!storage)
      return

    try {
      let rawValue = storage.getItem(key)
      if (rawValue == null && defaultValue) {
        rawValue = Serializers[type].write(defaultValue)
        storage.setItem(key, rawValue)
      }
      data.value = Serializers[type].read(rawValue, defaultValue)
    }
    catch (e) {
      console.warn(e)
    }
  }

  read()

  if (window && listenToStorageChanges)
    useEventListener(window, 'storage', read)

  watchWithFilter(
    data,
    () => {
      if (!storage) // SSR
        return

      try {
        if (data.value == null)
          storage.removeItem(key)
        else
          storage.setItem(key, Serializers[type].write(data.value))
      }
      catch (e) {
        console.warn(e)
      }
    },
    {
      flush,
      deep,
      eventFilter,
    },
  )

  return data
}

Serializers 定义了不同js类型的序列化与反序列化的方法, 这里的模式是一样的,使用 read读区默认值,使用事件监听变化。

组织

composition api 是一个函数,有输入,输出,从 options api转过来的开发者一定会有这样的一个疑问?到底要怎么样拆分呢?有一些变量需要多个逻辑块共用,这种情况应该怎么处理呢?

这里,引用 antfu在 vue conf 的一个结论: 在setup 中,我们建立输入和输出的连接。这句话要多理解。

函数的参数

Composition api 归根结底是函数,函数是可以接收参数的,有时候这个参数是不会变的,有时候是需要考虑参数的变化的。

不考虑参数变化

如果不考虑参数变化的话,一般传入的是 初始值或者一些配置化的参数(比如useBreakpoints)。

比如前面提到过的 useStorage

import { useStorage } from '@vueuse/core'

// bind object
const state = useStorage('my-store', { hello: 'hi', greeting: 'Hello' })

// bind boolean
const flag = useStorage('my-flag', true) // returns Ref<boolean>

// bind number
const count = useStorage('my-count', 0) // returns Ref<number>

// bind string with SessionStorage
const id = useStorage('my-id', 'some-string-id', sessionStorage) // returns Ref<string>

// delete data from storage
state.value = null

这种情况下一般会返回一个响应式的变量

考虑参数变化

有时候,传给composition api 的参数是变化的,这就要求composition api 内部能够处理这种变化。总结一下有以下几个场景

传递一个ref

在已经有一个ref 变量的情况下,直接将ref 变量传递给 composition api, 可以直接修改外部变。这里以 useTitle为例。useTitle 可以接收一个字符串 或者一个ref 作为参数。

/**
 * Reactive document title.
 *
 * @link https://vueuse.org/useTitle
 * @param newTitle
 * @param options
 */
export function useTitle(
  newTitle: MaybeRef<string | null | undefined> = null,
  { document = defaultDocument }: ConfigurableDocument = {},
) {
  const title = ref(newTitle ?? document?.title ?? null)

  watch(
    title,
    (t, o) => {
      if (isString(t) && t !== o && document)
        document.title = t
    },
    { immediate: true },
  )

  return title
}

可以看到 这里的参数类型是 MaybeRef<string | null | undefined>MaybeRef 的声明如下

type MybeRef<T> = T | Ref<T> | ComputedRef<T>

useTitle 中,会根据 函数参数定义一个 新的 Ref。

const title = ref(newTitle ?? document?.title ?? null)

这行代码 内容非常丰富,由于 newTitle可能为null,如果为null的情况下,就会从 document.title 上取。如果 newTitle 不是null,如果newTitle是 string 的话,就会创建一个 初始值为 newTitle 的ref,如果newTitle 是 Ref<string>的话,title 与 newTitle 是一样的。

这得益于 ref 的实现,如果传入的参数是一个 Ref 的话,就会直接返回。这一点在源码中可以看出

export function ref(value?: unknown) {
	return createRef(value)
}
function createRef(rawValue: unknown, shallow = false) {
	if (isRef(rawValue)) {
		return rawValue
	}
	return new RefImpl(rawValue, shallow)
}

传递一个函数

在一些简单的情况下,可以通过传递一个函数来实现动态参数。

const { data, error } = useFetch(() => `/api/user/${props.name}`)

实现上

function useFetch(getUrl) {
	const data = ref(null)
	const error = ref(null)
	const isPending = ref(true)

	watchEffect(() => {
		isPending.value = true
		data.value = null
		error.value = null
		fetch(getUrl())
			.then(res => res.json())
			.then(_data => {
				data.value = _data
				isPending = false
			})
			.catch(err => {
				error.value = err
				isPending = false
			})
	})

	return {
		data,
		error,
		isPending
	}
}

但是这种只适合于少量参数的情况,实际上 vueuse的 [useFetch](https://vueuse.org/core/usefetch/)还是使用的 传递ref 变量的形式。

​```typescript
const url = ref('https://my-api.com/user/1') 

const { data } = useFetch(url, { refetch: true })

函数的返回值

返回响应式变量

前文举例的大多数都是这种格式,此处不再举例。

返回创建响应式变量的方法

典型的如 useBreakpoints

使用方式如下

import { useBreakpoints } from '@vueuse/core'

const breakpoints = useBreakpoints({
  tablet: 640,
  laptop: 1024,
  desktop: 1280,
})

const laptop = breakpoints.between('laptop', 'desktop') // reactive

这里,useBreakpoints其实只是一个普通的函数,利用闭包 返回了 一些 工具方法,这些工具方法会得到 响应式变量。

/**
 * Reactively viewport breakpoints
 *
 * @link https://vueuse.org/useBreakpoints
 * @param options
 */
export function useBreakpoints<K extends string>(breakpoints: Breakpoints<K>, options: ConfigurableWindow = {}) {
  function getValue(k: K, delta?: number) {
    let v = breakpoints[k]

    if (delta != null)
      v = increaseWithUnit(v, delta)

    if (typeof v === 'number')
      v = `${v}px`

    return v
  }

  const { window = defaultWindow } = options

  function match(query: string): boolean {
    if (!window)
      return false
    return window.matchMedia(query).matches
  }

  return {
    greater(k: K) {
      return useMediaQuery(`(min-width: ${getValue(k)})`, options)
    },
    smaller(k: K) {
      return useMediaQuery(`(max-width: ${getValue(k, -0.1)})`, options)
    },
    between(a: K, b: K) {
      return useMediaQuery(`(min-width: ${getValue(a)}) and (max-width: ${getValue(b, -0.1)})`, options)
    },
    isGreater(k: K) {
      return match(`(min-width: ${getValue(k)})`)
    },
    isSmaller(k: K) {
      return match(`(max-width: ${getValue(k, -0.1)})`)
    },
    isInBetween(a: K, b: K) {
      return match(`(min-width: ${getValue(a)}) and (max-width: ${getValue(b, -0.1)})`)
    },
  }
}

即返回响应式变量,又返回工具方法

典型的比如 useFetch

请求数据不仅需要url,还要指定请求方法,还需要制定request 的content-type,这些参数如果以函数参数的形式传入,会让api变得臃肿不堪。vueuse使用了链式调用的方式来设置各个参数,看起来非常优雅。

// Request with default config
const { isFetching, error, data } = useFetch(url)

// Request will be sent with GET method and data will be parsed as JSON
const { data } = useFetch(url).get().json()

// Request will be sent with POST method and data will be parsed as text
const { data } = useFetch(url).post().text()

// Or set the method using the options

// Request will be sent with GET method and data will be parsed as blob
const { data } = useFetch(url, { method: 'GET' }, { refetch: true }).blob()

useFetch 的实现也不难

export function useFetch<T>(url: MaybeRef<string>, ...args: any[]): UseFetchReturn<T> {
  const supportsAbort = typeof AbortController === 'function'

  let fetchOptions: RequestInit = {}
  let options: UseFetchOptions = { immediate: true, refetch: false }
  // 默认配置
  const config = {
    method: 'get',
    type: 'text' as DataType,
    payload: undefined as unknown,
    payloadType: 'json' as PayloadType,
  }
  let initialized = false

  // 处理多个参数
  if (args.length > 0) {
    if (isFetchOptions(args[0]))
      options = { ...options, ...args[0] }
    else
      fetchOptions = args[0]
  }

  if (args.length > 1) {
    if (isFetchOptions(args[1]))
      options = { ...options, ...args[1] }
  }

  const {
    fetch = defaultWindow?.fetch,
  } = options

  // 定义响应式变量
  const isFinished = ref(false)
  const isFetching = ref(false)
  const aborted = ref(false)
  const statusCode = ref<number | null>(null)
  const response = shallowRef<Response | null>(null)
  const error = ref<any>(null)
  const data = shallowRef<T | null>(null)

  // 取消相关
  const canAbort = computed(() => supportsAbort && isFetching.value)

  let controller: AbortController | undefined

  const abort = () => {
    if (supportsAbort && controller)
      controller.abort()
  }

  // 发出请求的方法
  const execute = async() => {
    initialized = true
    isFetching.value = true
    isFinished.value = false
    error.value = null
    statusCode.value = null
    aborted.value = false
    controller = undefined

    if (supportsAbort) {
      controller = new AbortController()
      controller.signal.onabort = () => aborted.value = true
      fetchOptions = {
        ...fetchOptions,
        signal: controller.signal,
      }
    }

    const defaultFetchOptions: RequestInit = {
      method: config.method,
      headers: {},
    }

    if (config.payload) {
      const headers = defaultFetchOptions.headers as Record<string, string>
      if (config.payloadType === 'json') {
        defaultFetchOptions.body = JSON.stringify(config.payload)
        headers['Content-Type'] = 'application/json'
      }
      else {
        defaultFetchOptions.body = config.payload as any
        headers['Content-Type'] = config.payloadType === 'formData'
          ? 'multipart/form-data'
          : 'text/plain'
      }
    }

    let isCanceled = false
    const context: BeforeFetchContext = { url: unref(url), options: fetchOptions, cancel: () => { isCanceled = true } }

    if (options.beforeFetch)
      Object.assign(context, await options.beforeFetch(context))

    if (isCanceled || !fetch)
      return Promise.resolve()

    return new Promise((resolve) => {
      fetch(
        context.url,
        {
          ...defaultFetchOptions,
          ...context.options,
          headers: {
            ...defaultFetchOptions.headers,
            ...context.options?.headers,
          },
        },
      )
        .then(async(fetchResponse) => {
        	// 修改响应式变量
          response.value = fetchResponse
          statusCode.value = fetchResponse.status

          await fetchResponse[config.type]().then(text => data.value = text as any)

          // see: https://www.tjvantoll.com/2015/09/13/fetch-and-errors/
          if (!fetchResponse.ok)
            throw new Error(fetchResponse.statusText)

          resolve(fetchResponse)
        })
        .catch((fetchError) => {
          error.value = fetchError.message || fetchError.name
        })
        .finally(() => {
          isFinished.value = true
          isFetching.value = false
        })
    })
  }

  // 处理函数参数ref的变化
  watch(
    () => [
      unref(url),
      unref(options.refetch),
    ],
    () => unref(options.refetch) && execute(),
    { deep: true },
  )

  const base: UseFetchReturnBase<T> = {
    isFinished,
    statusCode,
    response,
    error,
    data,
    isFetching,
    canAbort,
    aborted,
    abort,
    execute,
  }

  // 将数据和链式调用的方法一并放回
  const shell: UseFetchReturn<T> = {
    ...base,

    get: setMethod('get'),
    put: setMethod('put'),
    post: setMethod('post'),
    delete: setMethod('delete'),

    json: setType('json'),
    text: setType('text'),
    blob: setType('blob'),
    arrayBuffer: setType('arrayBuffer'),
    formData: setType('formData'),
  }

  function setMethod(method: string) {
    return (payload?: unknown, payloadType?: PayloadType) => {
      if (!initialized) {
        config.method = method
        config.payload = payload
        config.payloadType = payloadType || typeof payload === 'string' ? 'text' : 'json'
        return shell as any
      }
      return undefined
    }
  }

  function setType(type: DataType) {
    return () => {
      if (!initialized) {
        config.type = type
        return base as any
      }
      return undefined
    }
  }

  // 将请求放入宏任务队列,比链式调用后执行
  if (options.immediate)
    setTimeout(execute, 0)

  return shell
}

返回cleanup 函数

典型地如 useEventListener 就返回了解除绑定的函数, 一般用在 使用事件绑定的函数中。

onClickOutside为例

export function useEventListener(...args: any[]) {
  let target: MaybeRef<EventTarget> | undefined
  let event: string
  let listener: any
  let options: any

  if (isString(args[0])) {
    [event, listener, options] = args
    target = defaultWindow
  }
  else {
    [target, event, listener, options] = args
  }

  if (!target)
    return noop

  let cleanup = noop

  const stopWatch = watch(
    () => unref(target),
    (el) => {
      cleanup()
      if (!el)
        return

      el.addEventListener(event, listener, options)

      cleanup = () => {
        el.removeEventListener(event, listener, options)
        cleanup = noop
      }
    },
    { immediate: true, flush: 'post' },
  )

  const stop = () => {
    stopWatch()
    cleanup()
  }

  tryOnUnmounted(stop)

  return stop
}

本文从函数的结构上分析了一下 composition api, 当然composition api还有一些固定的模式可以学习,可以查看下一篇文章。


Profile picture

Written by Colgin who lives and works in China, focus on web development. You can comment on github