青少年编程与数学 02-006 前端开发框架VUE 18课题、逻辑复用
- 一、组合式函数
- 什么是“组合式函数”?
- 鼠标跟踪器示例
- 异步状态示例
- 接收响应式状态
- 约定和最佳实践
- 命名
- 输入参数
- 返回值
- 副作用
- 使用限制
- 在选项式 API 中使用组合式函数
- 与其他模式的比较
- 和 Mixin 的对比
- 和无渲染组件的对比
- 和 React Hooks 的对比
- 二、自定义指令
- 介绍
- 指令钩子
- 钩子参数
- Note
- 简化形式
- 对象字面量
- 在组件上使用
- 三、插件
- 介绍
- 编写一个插件
- 插件中的 Provide / Inject
课题摘要:本文探讨了Vue.js中的逻辑复用,重点介绍了组合式函数、自定义指令和插件。组合式函数利用Vue的组合式API封装有状态逻辑,便于在多个组件中复用。例如,useMouse组合式函数可以跨组件追踪鼠标位置。自定义指令允许重用涉及DOM操作的逻辑,如v-focus指令使元素自动聚焦。插件为Vue添加全局功能,如i18n插件提供全局翻译功能。这些工具使Vue应用开发更高效、可维护,同时保持代码的清晰和组织。
一、组合式函数
什么是“组合式函数”?
在 Vue 应用的概念中,“组合式函数”(Composables) 是一个利用 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.pageXy.value = event.pageY
}onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script><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.pageXy.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>
Mouse position is at: 0, 0
核心逻辑完全一致,我们做的只是把它移到一个外部函数中去,并返回需要暴露的状态。和在组件中一样,你也可以在组合式函数中使用所有的 组合式 API。现在, 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.pageXy.value = event.pageY})return { 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 字符串作为输入——因此它只会执行一次 fetch 并且就此结束。如果我们想要在 URL 改变时重新 fetch 呢?为了实现这一点,我们需要将响应式状态传入组合式函数,并让它基于传入的状态来创建执行操作的侦听器。
举例来说, useFetch()
应该能够接收一个 ref:
const url = ref('/initial-url')const { data, error } = useFetch(url)// 这将会重新触发 fetch
url.value = '/new-url'
或者接收一个 getter 函数:
// 当 props.id 改变时重新 fetch
const { data, error } = useFetch(() => `/posts/${props.id}`)
我们可以用 watchEffect()
和 toValue()
API 来重构我们现有的实现:
// 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 = nullerror.value = nullfetch(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。它的设计目的是将 ref 或 getter 规范化为值。如果参数是 ref,它会返回 ref 的值;如果参数是函数,它会调用函数并返回其返回值。否则,它会原样返回参数。它的工作方式类似于 unref()
,但对函数有特殊处理。
注意 toValue(url)
是在 watchEffect
回调函数的 内部 调用的。这确保了在 toValue()
规范化期间访问的任何响应式依赖项都会被侦听器跟踪。
这个版本的 useFetch()
现在能接收静态 URL 字符串、ref 和 getter,使其更加灵活。watch effect 会立即运行,并且会跟踪 toValue(url)
期间访问的任何依赖项。如果没有跟踪到依赖项 (例如 url 已经是字符串),则 effect 只会运行一次;否则,它将在跟踪到的任何依赖项更改时重新运行。
这是 更新后的 useFetch()
,为了便于演示,添加了人为延迟和随机错误。
约定和最佳实践
命名
组合式函数约定用驼峰命名法命名,并以“use”作为开头。
输入参数
即便不依赖于 ref 或 getter 的响应性,组合式函数也可以接收它们作为参数。如果你正在编写一个可能被其他开发者使用的组合式函数,最好处理一下输入参数是 ref 或 getter 而非原始值的情况。可以利用 toValue()
工具函数来实现:
import { toValue } from 'vue'function useFeature(maybeRefOrGetter) {// 如果 maybeRefOrGetter 是一个 ref 或 getter,// 将返回它的规范化值。// 否则原样返回。const value = toValue(maybeRefOrGetter)
}
如果你的组合式函数在输入参数是 ref 或 getter 的情况下创建了响应式 effect,为了让它能够被正确追踪,请确保要么使用 watch()
显式地监视 ref 或 getter,要么在 watchEffect()
中调用 toValue()
。
前面讨论过的 useFetch() 实现 提供了一个接受 ref、getter 或普通值作为输入参数的组合式函数的具体示例。
返回值
你可能已经注意到了,我们一直在组合式函数中使用 ref()
而不是 reactive()
。我们推荐的约定是组合式函数始终返回一个包含多个 ref 的普通的非响应式对象,这样该对象在组件中被解构为 ref 之后仍可以保持响应性:
// x 和 y 是两个 ref
const { x, y } = useMouse()
从组合式函数返回一个响应式对象会导致在对象解构过程中丢失与组合式函数内状态的响应性连接。与之相反,ref 则可以维持这一响应性连接。
如果你更希望以对象属性的形式来使用组合式函数中返回的状态,你可以将返回的对象用 reactive()
包装一次,这样其中的 ref 会被自动解包,例如:
const mouse = reactive(useMouse())
// mouse.x 链接到了原来的 x ref
console.log(mouse.x)
Mouse position is at: {{ mouse.x }}, {{ mouse.y }}
副作用
在组合式函数中的确可以执行副作用 (例如:添加 DOM 事件监听器或者请求数据),但请注意以下规则:
-
如果你的应用用到了 服务端渲染 (SSR),请确保在组件挂载后才调用的生命周期钩子中执行 DOM 相关的副作用,例如:
onMounted()
。这些钩子仅会在浏览器中被调用,因此可以确保能访问到 DOM。 -
确保在
onUnmounted()
时清理副作用。举例来说,如果一个组合式函数设置了一个事件监听器,它就应该在onUnmounted()
中被移除 (就像我们在useMouse()
示例中看到的一样)。当然也可以像之前的useEventListener()
示例那样,使用一个组合式函数来自动帮你做这些事。
使用限制
组合式函数只能在 <script setup>
或 setup()
钩子中被调用。在这些上下文中,它们也只能被 同步 调用。在某些情况下,你也可以在像 onMounted()
这样的生命周期钩子中调用它们。
这些限制很重要,因为这些是 Vue 用于确定当前活跃的组件实例的上下文。访问活跃的组件实例很有必要,这样才能:
-
将生命周期钩子注册到该组件实例上
-
将计算属性和监听器注册到该组件实例上,以便在该组件被卸载时停止监听,避免内存泄漏。
在选项式 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)}// ...其他选项
}
与其他模式的比较
和 Mixin 的对比
Vue 2 的用户可能会对 mixins 选项比较熟悉。它也让我们能够把组件逻辑提取到可复用的单元里。然而 mixins 有三个主要的短板:
-
不清晰的数据来源:当使用了多个 mixin 时,实例上的数据属性来自哪个 mixin 变得不清晰,这使追溯实现和理解组件行为变得困难。这也是我们推荐在组合式函数中使用 ref + 解构模式的理由:让属性的来源在消费组件时一目了然。
-
命名空间冲突:多个来自不同作者的 mixin 可能会注册相同的属性名,造成命名冲突。若使用组合式函数,你可以通过在解构变量时对变量进行重命名来避免相同的键名。
-
隐式的跨 mixin 交流:多个 mixin 需要依赖共享的属性名来进行相互作用,这使得它们隐性地耦合在一起。而一个组合式函数的返回值可以作为另一个组合式函数的参数被传入,像普通函数那样。
基于上述理由,我们不再推荐在 Vue 3 中继续使用 mixin。保留该功能只是为了项目迁移的需求和照顾熟悉它的用户。
和无渲染组件的对比
在组件插槽一章中,我们讨论过了基于作用域插槽的 无渲染组件。我们甚至用它实现了一样的鼠标追踪器示例。
组合式函数相对于无渲染组件的主要优势是:组合式函数不会产生额外的组件实例开销。当在整个应用中使用时,由无渲染组件产生的额外组件实例会带来无法忽视的性能开销。
我们推荐在纯逻辑复用时使用组合式函数,在需要同时复用逻辑和视图布局时使用无渲染组件。
和 React Hooks 的对比
如果你有 React 的开发经验,你可能注意到组合式函数和自定义 React hooks 非常相似。组合式 API 的一部分灵感正来自于 React hooks,Vue 的组合式函数也的确在逻辑组合能力上与 React hooks 相近。然而,Vue 的组合式函数是基于 Vue 细粒度的响应性系统,这和 React hooks 的执行模型有本质上的不同。这一话题在 组合式 API 的常见问题 中有更细致的讨论。
二、自定义指令
介绍
除了 Vue 内置的一系列指令(比如 v-model
或 v-show
)之外,Vue 还允许你注册自定义的指令(Custom Directives)。
我们已经介绍了两种在 Vue 中重用代码的方式:组件和组合式函数。组件是主要的构建模块,而组合式函数则侧重于有状态的逻辑。另一方面,自定义指令主要是为了重用涉及普通元素的底层 DOM 访问的逻辑。
一个自定义指令由一个包含类似组件生命周期钩子的对象来定义。钩子函数会接收到指令所绑定元素作为其参数。下面是一个自定义指令的例子,当一个 input 元素被 Vue 插入到 DOM 中后,它会被自动聚焦:
<script setup>
// 在模板中启用 v-focus
const vFocus = {mounted: (el) => el.focus()
}
</script><template><input v-focus />
</template>
const focus = {mounted: (el) => el.focus()
}export default {directives: {// 在模板中启用 v-focusfocus}
}
<input v-focus />
假设你还未点击页面中的其他地方,那么上面这个 input 元素应该会被自动聚焦。该指令比 autofocus
attribute 更有用,因为它不仅仅可以在页面加载完成后生效,还可以在 Vue 动态插入元素后生效。
在 <script setup>
中,任何以 v
开头的驼峰式命名的变量都可以被用作一个自定义指令。在上面的例子中,vFocus
即可以在模板中以 v-focus
的形式使用。
在没有使用 <script setup>
的情况下,自定义指令需要通过 directives
选项注册:
export default {setup() {/*...*/},directives: {// 在模板中启用 v-focusfocus: {/* ... */}}
}
和组件类似,自定义指令在模板中使用前必须先注册。在上面的例子中,我们使用 directives
选项完成了指令的局部注册。
将一个自定义指令全局注册到应用层级也是一种常见的做法:
const app = createApp({})// 使 v-focus 在所有组件中都可用
app.directive('focus', {/* ... */
})
只有当所需功能只能通过直接的 DOM 操作来实现时,才应该使用自定义指令。其他情况下应该尽可能地使用 v-bind
这样的内置指令来声明式地使用模板,这样更高效,也对服务端渲染更友好。
指令钩子
一个指令的定义对象可以提供几种钩子函数(都是可选的):
const myDirective = {// 在绑定元素的 attribute 前// 或事件监听器应用前调用created(el, binding, vnode) {// 下面会介绍各个参数的细节},// 在元素被插入到 DOM 前调用beforeMount(el, binding, vnode) {},// 在绑定元素的父组件// 及他自己的所有子节点都挂载完成后调用mounted(el, binding, vnode) {},// 绑定元素的父组件更新前调用beforeUpdate(el, binding, vnode, prevVnode) {},// 在绑定元素的父组件// 及他自己的所有子节点都更新后调用updated(el, binding, vnode, prevVnode) {},// 绑定元素的父组件卸载前调用beforeUnmount(el, binding, vnode) {},// 绑定元素的父组件卸载后调用unmounted(el, binding, vnode) {}
}
钩子参数
指令的钩子会传递以下几种参数:
el
:指令绑定到的元素。这可以用于直接操作 DOM。binding
:一个对象,包含以下属性。value
:传递给指令的值。例如在v-my-directive="1 + 1"
中,值是2
。oldValue
:之前的值,仅在beforeUpdate
和updated
中可用。无论值是否更改,它都可用。arg
:传递给指令的参数(如果有的话)。例如在v-my-directive:foo
中,参数是"foo"
。modifiers
:一个包含修饰符的对象(如果有的话)。例如在v-my-directive.foo.bar
中,修饰符对象是{ foo: true, bar: true }
。instance
:使用该指令的组件实例。dir
:指令的定义对象。
vnode
:代表绑定元素的底层 VNode。prevVnode
:代表之前的渲染中指令所绑定元素的 VNode。仅在beforeUpdate
和updated
钩子中可用。
举例来说,像下面这样使用指令:
<div v-example:foo.bar="baz">
binding
参数会是一个这样的对象:
{arg: 'foo',modifiers: { bar: true },value: /* `baz` 的值 */,oldValue: /* 上一次更新时 `baz` 的值 */
}
和内置指令类似,自定义指令的参数也可以是动态的。举例来说:
<div v-example:[arg]="value"></div>
这里指令的参数会基于组件的 arg
数据属性响应式地更新。
Note
除了 el
外,其他参数都是只读的,不要更改它们。若你需要在不同的钩子间共享信息,推荐通过元素的 dataset attribute 实现。
简化形式
对于自定义指令来说,一个很常见的情况是仅仅需要在 mounted
和 updated
上实现相同的行为,除此之外并不需要其他钩子。这种情况下我们可以直接用一个函数来定义指令,如下所示:
<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>
需要注意的是组件可能含有多个根节点。当应用到一个多根组件时,指令将会被忽略且抛出一个警告。和 attribute 不同,指令不能通过 v-bind="$attrs"
来传递给一个不同的元素。
三、插件
介绍
插件 (Plugins) 是一种能为 Vue 添加全局功能的工具代码。下面是如何安装一个插件的示例:
import { createApp } from 'vue'const app = createApp({})app.use(myPlugin, {/* 可选的选项 */
})
一个插件可以是一个拥有 install()
方法的对象,也可以直接是一个安装函数本身。安装函数会接收到安装它的应用实例和传递给 app.use()
的额外选项作为参数:
const myPlugin = {install(app, options) {// 配置此应用}
}
插件没有严格定义的使用范围,但是插件发挥作用的常见场景主要包括以下几种:
- 通过
app.component()
和app.directive()
注册一到多个全局组件或自定义指令。 - 通过
app.provide()
使一个资源可被注入进整个应用。 - 向
app.config.globalProperties
中添加一些全局实例属性或方法。 - 一个可能上述三种都包含了的功能库(例如 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!
了。
插件中的 Provide / Inject
在插件中,我们可以通过 provide
来为插件用户供给一些内容。举例来说,我们可以将插件接收到的 options
参数提供给整个应用,让任何组件都能使用这个翻译字典对象。
// plugins/i18n.js
export default {install: (app, options) => {app.provide('i18n', options)}
}
现在,插件用户就可以在他们的组件中以 i18n
为 key 注入并访问插件的选项对象了。
<script setup>
import { inject } from 'vue'const i18n = inject('i18n')console.log(i18n.greetings.hello)
</script>
export default {inject: ['i18n'],created() {console.log(this.i18n.greetings.hello)}
}