逻辑复用

156 阅读5分钟

一首歌的时间music.163.com/#/song?id=1…

逻辑复用

1. 组合式函数

什么是“组合式函数”?

"组合式函数":是一个利用vue的组合式API来封装和复用有状态逻辑的函数。

在构建前端应用时,我们常常需要复用公告任务的逻辑。

在不同地方格式化时间,我们可能会抽取可复用的时间格式函数。这个函数封装了无状态的逻辑:它在接收一些输入后立刻返回所期望的输出。复用无状态逻辑的库有很多,比如你可能已经用过lodash和date-fns。相比一下,有状态逻辑负责管理会随时间而变化的状态。跟踪当前鼠标在页面中的位置。实际应用中,触摸手势或与数据库的链接状态这样的再复杂的逻辑。

鼠标跟踪器

如果我们要直接在组件中使用组合式API实现鼠标跟踪功能,它会是这样的:

<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))
</scrip个t>

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

但是如果我们在多个组件中复用这个相同的逻辑呢?我们可以把这个逻辑以一个组合式函数的形式提取到外部文件中:

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

// 按照惯例,组合式函数名以“use”开头
export function useMouse() {
  // 被组合式函数封装和管理的状态
  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))

  // 通过返回值暴露所管理的状态
  return { x, y }
}

他们在组建中使用的方式:

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

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

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

useMouse()的功能可以在任何组织中轻易复用。 更酷的是,可以嵌套多个组合式函数:一个组合式函数可以调用一个或多个其他的组合式函数。这使得我们可以像使用多个组件组合成整个应用一样,用多个较小切且逻辑独立的单元来组合形成复杂的逻辑。实际上,这正是为什么我们决定将实现了这一涉及模式的API集合命名为组合式API。举例来说我们可以将添加和清楚DOM事件监听器的逻辑也封装进一个组合式函数中。

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

export function useEventListener(target, event, callback) {
  // 如果你想的话,
  // 也可以用字符串形式的 CSS 选择器来寻找目标 DOM 元素
  onMounted(() => target.addEventListener(event, callback))
  onUnmounted(() => target.removeEventListener(event, callback))
}

有了它,之前的useMouse()组合式函数可以被简化为:

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

tip: 每一个调用useMouse()的组件实例会创建其独有的x,y状态拷贝,因此他们不会互相影响。你想要在组件之间共享状态,查看状态管理。

异步状态

useMouse() 组合式函数没有接收任何参数,因此让我们再来看一个需要接收一个参数的组合式函数示例。在做异步数据请求时,我们常常需要处理不同的状态:加载中、加载成功和加载失败。

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

如果在每个需要获取数据的组件中都要重复这种模式,太繁琐了,让我们把他抽取成一个组合式函数。

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

现在我们在组件里只需要:

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

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

useFetch()接收一个静态的url字符串作为输入,所有它只执行一次请求,然后就完成了,但如果我们想让它在每次url变化时都重新请求呢?那我们可以让它同时允许接收ref作为参数:

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

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

  function doFetch() {
    // 在请求之前重设状态...
    data.value = null
    error.value = null
    // unref() 解包可能为 ref 的值
    fetch(unref(url))
      .then((res) => res.json())
      .then((json) => (data.value = json))
      .catch((err) => (error.value = err))
  }

  if (isRef(url)) {
    // 若输入的 URL 是一个 ref,那么启动一个响应式的请求
    watchEffect(doFetch)
  } else {
    // 否则只请求一次
    // 避免监听器的额外开销
    doFetch()
  }

  return { data, error }
}

这个版本的uesFeth()现在同时可以接收静态url字符串和url字符串的ref.当通过isRef()检测到url是一个动态ref时,它会使用watchEffect()启动一个响应式的effect。该effct会立刻执行一次,并再次过程中将url的ref作为依赖进行跟踪。当url的ref发生变化时,数据就会被重置,并重新请求。

约定和最佳实践

命名:组合式函数约定用驼峰命名法,并以“use”作为开头。

输入参数:尽管其响应式不依赖ref,组合式函数仍可接收ref参数。如果编写的组合式函数会被其他开发者使用,最好在处理输入参数兼容ref而不只是原始的值。unref()工具函数会对此非常有帮助

import { unref } from 'vue'

function useFeature(maybeRef) {
  // 若 maybeRef 确实是一个 ref,它的 .value 会被返回
  // 否则,maybeRef 会被原样返回
  const value = unref(maybeRef)
}

如果你的组合式函数在接收 ref 为参数时会产生响应式 effect,请确保使用 watch() 显式地监听此 ref,或者在 watchEffect() 中调用 unref() 来进行正确的追踪。

返回值

我们在组合式函数中使用的是ref()而不是reactive().我们推荐的约定是组合式函数始终返回一个包含多个ref的函数ref的普通的非响应式对象。这样对该对象在组建中被结构为ref之后仍然保持着响应式:

// x 和 y 是两个 ref
const { x, y } = useMouse()

如果你更希望以对象属性的形式来使用组合式函数中返回的状态,你可以将返回的对象用 reactive() 包装一次,这样其中的 ref 会被自动解包,例如:

const mouse = reactive(useMouse())
// mouse.x 链接到了原来的 x ref
console.log(mouse.x)

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

副作用

在组合式函数中的确可以执行副作用:

如果你的应用用到了服务端渲染(ssr),请确保在组件挂载后才调用的声明周期钩子中执行DOM相关的副作用

确保在onUnmounted()时清除副作用。

使用限制

组合式函数在 <script setup> 或 setup() 钩子中,应始终被同步地调用。在某些场景下,你也可以在像 onMounted() 这样的生命周期钩子中使用他们。 这个限制是为了让 Vue 能够确定当前正在被执行的到底是哪个组件实例,只有能确认当前组件实例,才能够:

  1. 将生命周期钩子注册到该组件实例上

  2. 将计算属性和监听器注册到该组件实例上,以便在该组件被卸载时停止监听,避免内存泄漏。

TIP:

<script setup> 是唯一在调用 await 之后仍可调用组合式函数的地方。编译器会在异步操作之后自动为你恢复当前的组件实例。

这个限制是为了让 Vue 能够确定当前正在被执行的到底是哪个组件实例,只有能确认当前组件实例,才能够:

  1. 将生命周期钩子注册到该组件实例上
  2. 将计算属性和监听器注册到该组件实例上,以便在该组件被卸载时停止监听,避免内存泄漏。

通过抽取组合式函数改善代码结构

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

在选项式Api中使用组合式函数

如果你正在使用选项式 API,组合式函数必须在 setup() 中调用。且其返回的绑定必须在 setup() 中返回,以便暴露给 this 及其模板:

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() 暴露的属性可以在通过 `this` 访问到
    console.log(this.x)
  }
  // ...其他选项
}

2. 自定义指令 (Custom Directives)

介绍

自定义指令主要是为了重用涉及普通元素的底层DOM访问逻辑。

<script setup>
// 在模板中启用 v-focus
const vFocus = {
  mounted: (el) => el.focus()
}
</script>

<template>
  <input v-focus />
</template>

<script setup>中,以V开头的驼峰命名的变量都可以被用作一个自定义指令。

在没有使用<script setup>的情况下,自定义指令需要通过directives选项注册:

  setup() {
    /*...*/
  },
  directives: {
    // 在模板中启用 v-focus
    focus: {
      /* ... */
    }
  }
}

    

将一个自定义指令全局注册到应用层级也是一种常见的做法:

const app = createApp({})

// 使 v-focus 在所有组件中都可用
app.directive('focus', {
  /* ... */
})

TIP: 只有当所需功能只能通过直接的DOM操作来实现时,才应该使用自定义指令。其他情况下应该尽可能地使用v-bind这样的内置指令声明地使用模板,也对服务端渲染更友好。

指令钩子

一般指令的定义对象可以提供集中钩子函数(都是可选的):

const myDirective = {
  // 在绑定元素的 attribute 前
  // 或事件监听器应用前调用
  created(el, binding, vnode, prevVnode) {
    // 下面会介绍各个参数的细节
  },
  // 在元素被插入到 DOM 前调用
  beforeMount(el, binding, vnode, prevVnode) {},
  // 在绑定元素的父组件
  // 及他自己的所有子节点都挂载完成后调用
  mounted(el, binding, vnode, prevVnode) {},
  // 绑定元素的父组件更新前调用
  beforeUpdate(el, binding, vnode, prevVnode) {},
  // 在绑定元素的父组件
  // 及他自己的所有子节点都更新后调用
  updated(el, binding, vnode, prevVnode) {},
  // 绑定元素的父组件卸载前调用
  beforeUnmount(el, binding, vnode, prevVnode) {},
  // 绑定元素的父组件卸载后调用
  unmounted(el, binding, vnode, prevVnode) {}
}

钩子参数

1.el:指令绑定到的元素。这可以用于直接操作DOM。

2.binding:一个对象,包含一下属性。

2.1:alue:传递给指令的值。如在v-my-directive="1+1"中,值是2.

2.2:oldValue:之前的值,仅在beforeUpdate和update中可用。无论值是都更改,可用。

2.3:arg:传递给指令的参数(如果有的话)。例如在v-my-directive:foo中参数是"foo".

2.4:modifies:一个包含修饰符的对象(如果有的话),在v-my-directive.fpp.bar中,修饰符对象是{foo:true,bar:true}。

2.5:instance:使用该指令的组件实例。

2.6:dir:指令的定义对象。

3.vnode:代表绑定元素的底层VNode.

4.prevNode:之前的渲染中代表指令所绑定的元素的VNode.仅在beforeUpdate和update钩子中可用。

<div v-example:foo.bar="baz">
//binding参数会是一个这样的对象:
{
  arg: 'foo',
  modifiers: { bar: true },
  value: /* `baz` 的值 */,
  oldValue: /* 上一次更新时 `baz` 的值 */
}

//和内置指令类似,自定义指令的参数也可以是动态的:
<div v-example:[arg]="value"></div>

简化形式

对于自定义指令来说,一个很常见的情况是仅仅需要在mounted和update上实现相同的行为,除此之外并不需要其他钩子。这种情况下,我们可以直接用一个函数来定义指令。

<div v-color="color"></div>
app.directive('color', (el, binding) => {
  // 这会在 `mounted` 和 `updated` 时都调用
  el.style.color = binding.value
})

对象字面量

//如果你的指令需要多个值,你可以向它传递一个javascript对象字面量。别忘了,指令也可以接收任何合法的javascript表达式。

<div v-demo="{ color: 'white', text: 'hello!' }"></div>

app.directive('demo', (el, binding) => {
 console.log(binding.value.color) // => "white"
 console.log(binding.value.text) // => "hello!"
})

在组件上使用

//当在组件上使用自定义指令时,它会始终应用于组件的根节点,和透传attributes类似
<MyComponent v-demo="test" />
<!-- MyComponent 的模板 -->

<div> <!-- v-demo 指令会被应用在此处 -->
  <span>My component content</span>
</div>

3. 插件

介绍 插件(plugins)一种能为vue添加全局功能的工具代码:

import { createApp } from 'vue'

const app = createApp({})

app.use(myPlugin, {
  /* 可选的选项 */
})

一个插件可以是一个拥有 install() 方法的对象,也可以直接是一个安装函数本身。安装函数会接收到安装它的应用实例和和传递给 app.use() 的额外选项作为参数:

const myPlugin = {
  install(app, options) {
    // 配置此应用
  }
}

插件没有严格定义的使用范围,但是插件发挥作用的常见场景主要包括以下几种:

  1. 通过 app.component() 和 app.directive() 注册一到多个全局组件或自定义指令。
  2. 通过 app.provide() 使一个资源可被注入进整个应用。
  3. 向 app.config.globalProperties 中添加一些全局实例属性或方法
  4. 一个可能上述三种都包含了的功能库 (例如 vue-router)。

编写一个插件 为了更好地理解如何构建 Vue.js 插件,我们可以试着写一个简单的 i18n (国际化 (Internationalization) 的缩写) 插件

// plugins/i18n.js
export default {
  install: (app, options) => {
    // 在这里编写插件代码
  }
}

我们希望有一个翻译函数,这个函数接收一个以 . 作为分隔符的 key 字符串,用来在用户提供的翻译字典中查找对应语言的文本。期望的使用方式如下:

<h1>{{ $translate('greetings.hello') }}</h1>

这个函数应当能够在任意模板中被全局调用。这一点可以通过在插件中将它添加到 app.config.globalProperties 上来实现:

// plugins/i18n.js
export default {
  install: (app, options) => {
    // 注入一个全局可用的 $translate() 方法
    app.config.globalProperties.$translate = (key) => {
      // 获取 `options` 对象的深层属性
      // 使用 `key` 作为索引
      return key.split('.').reduce((o, i) => {
        if (o) return o[i]
      }, options)
    }
  }
}

我们的 $translate 函数会接收一个例如 greetings.hello 的字符串,在用户提供的翻译字典中查找,并返回翻译得到的值。

用于查找的翻译字典对象则应当在插件被安装时作为 app.use() 的额外参数被传入:

import i18nPlugin from './plugins/i18n'

app.use(i18nPlugin, {
  greetings: {
    hello: 'Bonjour!'
  }
})

这样,我们一开始的表达式 $translate('greetings.hello') 就会在运行时被替换为 Bonjour! 了。

TIP: 请谨慎使用全局属性,如果在整个应用中使用不同插件注入的太多全局属性,很容易让应用变得难以理解和维护。

插件中的Provide/inject

在插件中,我们可以通过 provide 来为插件用户供给一些内容。举例来说,我们可以将插件接收到的 options 参数提供给整个应用,让任何组件都能使用这个翻译字典对象。

// plugins/i18n.js
export default {
  install: (app, options) => {
    app.config.globalProperties.$translate = (key) => {
      return key.split('.').reduce((o, i) => {
        if (o) return o[i]
      }, options)
    }

    app.provide('i18n', options)
  }
}

现在,插件用户就可以在他们的组件中以 i18n 为 key 注入并访问插件的选项对象了。

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

const i18n = inject('i18n')

console.log(i18n.greetings.hello)
</script>