必不可少的UI组件一——组件的基础知识

作者: jie 分类: Vue 发布时间: 2023-11-17 10:31

主要是基于组件库开发的场景,介绍了 Vue 组件开发的基础知识与优秀实践。

前言

很多同学随着自己前端技能的增长,不满足于常规的业务开发,开始想着做一些自己的技术积累。例如学会了 Vue 框架的使用以及如何封装组件之后,想要自己试着开发一套组件库引入到项目中,甚至共享给更多的人使用。但是在这个过程中,往往会遇到许多的问题:

  • 组件库工程需要的基础设施该如何搭建,如何实现组件库的构建、提交门禁、测试、文档、发布?
  • 对于复杂一些的组件,我在实现的过程中感觉逻辑越来越混乱,代码越来越难以维护,最终难以持续迭代下去。
  • 有些组件交互复杂,甚至由多个子组件构成(例如 Form 和 FormItem),它们之间的通信和状态共享如何处理?感觉缺少思路,无从下手。

磨刀不误砍柴工,对于正处于经验积累阶段的前端同学,或许需要先重温一些基础知识,夯实内功,才能更好地实践。

想要实践 Vue 组件库的搭建,我们需要掌握一些前置知识:

  • 一方面是前端工程化相关内容,它们是组件库的地基、脚手架般重要的存在,是整个组件库工程的基础;
  • 另一方面是Vue组件开发的技巧与优秀实践,它们在实现组件库主体部分时发挥作用,决定了我们的能否实现、能否做好每一个组件。

本章分享的内容侧重于后者,我们将基于组件库开发的场景,介绍一些高频使用的 Vue 框架基础知识与实战技巧,主要内容如下:

  • 组件的基本概念;
  • 官方主推的组件开发范式:单文件组件与组合式 API;
  • 深入组合式 API:响应式 API;
  • 深入组合式 API:组件的生命周期;
  • 深入组合式 API:组件之间的通信方式;
  • 组件开发的优秀实践介绍。

我们在举例时,会尽量贴近当下环境中较新的实践——使用 Vue 的最新版本与 TypeScript。如果读者在阅读过程中对代码示例中的内容感到困惑,可以前往以下文档补充学习:

  • Vue 官方文档
  • TypeScript 官方文档
  • TypeScript 学习指南

组件的基本概念

对于组件库而言,组件的概念是用户界面 UI 中独立的、可重用的部分,用户倾向于多次复用组件,像搭积木一样,将多个组件组合为完整的用户界面。

不过,站在 Vue 框架层面来看,我们先前提到的“组件”的概念其实是 Vue 框架中“组件”概念的子集。对于 Vue 框架而言,万物都是组件——无论是大的用户界面,还是小的功能模块,任何一个 Vue 应用都可以看做是以 App.vue(入口组件可以叫其他名称) 为根节点的组件树。

既然我们的目标是编写组件库,那么下文将要讲解的基础知识将围绕着以下三个问题展开:

  • 应该采用什么样的范式编写组件?
  • 如何编写组件的内部运行逻辑?
  • 如何定义组件的外部交互接口?即处理组件之间的通信问题。

单文件组件与组合式API

目前,Vue 官方主推的组件实现范式是 单文件组件 与 组合式 API 的结合。下面给出一个典型案例:

<script lang="ts" setup>
	import { ref, onMounted } from 'vue'

	// 响应式状态
	const count = ref(0)

	// 更改状态、触发更新的函数
	function increment() {
		count.value++
	}

	// 生命周期钩子
	onMounted(() => {
		console.log(`计数器初始值为 ${count.value}。`)
	})
</script>

<template>
	<button class="btn" @click="increment">点击了:{{ count }} 次</button>
</template>

<style>
	.btn {
		background-color: #c7000b;
	}
</style>

如你所见,Vue 的单文件组件是网页开发中 HTML、CSS 和 JavaScript 三种语言经典组合的自然延伸。<template><script> 和 <style> 三个块在同一个文件中封装、组合了组件的视图、逻辑和样式。

关于单文件组件的优势与选型理由,Vue 官网给出了非常充分清晰的理由:为什么要使用 SFC。

而 组合式 API 则体现在单文件组件的逻辑部分(<script></script>),它使我们使用函数语句而不是声明选项的方式书写 Vue 组件的逻辑部分。在 Vue 3 中,组合式 API 基本上都会配合 <script setup> 语法在单文件组件中使用。Vue 官网对于组合式 API 的优势也有着充分的说明:为什么要有组合式 API?

组合式 API 的相比选项式 API 的一大优势,在于可以将相同逻辑关注点的代码聚合为一组,而不用为了同一个逻辑关注点在不同的选项之间来回切换。

我们分享中的演示案例都将采用 <script setup> 单文件模板 的形式,也推荐大家编写自己的组件库时采纳这种实践。这主要基于以下理由:

  • 单文件模板和组合式 API 各自的优势。(参考官方文档中的描述)
  • Vue 官方已经针对这样的范式此做了足够的优化,目前足以满足绝大多数应用场景。
  • 作为官方主推的一种实践方案,未来也将得到社区最大力度的支持。

组合式 API 和单文件组件并不能天然被浏览器所支持,需要提供额外的编译支持,因此必须搭配构建工具使用。 我们可以参考 Vite 搭建第一个 Vite 项目,基于 Vite,通过简单的命令快速出这样的模板。

npm create vite@latest

其中的 src/components/HelloWorld.vue 就是符合 单文件组件和组合式 API 实践的典型组件,我们可以参考它并尝试编写我们自己的组件。

响应式API

明确了我们编写组件的范式之后,下一步我们需要掌握如何编写组件的内部运行逻辑。这就需要我们对组合式 API 涵盖的内容——响应式 API、生命周期钩子、依赖注入进行了解,这里我们先来看响应式 API。

我建议大家仔细阅读官方文档中的 深入响应式系统,它有助于我们更好地理解和运用响应式 API。

本文由于篇幅限制,不倾向于花篇幅分析响应式 API 的原理,这里给出一个简单的说明:响应式 API 用于创建响应式变量,响应式变量的改变可以触发 <template> 模板渲染内容的改变,或者触发一些关联的事件。下面的例子对刚才的说明进行了解释:

<script setup lang="ts">
	import { ref, watch } from 'vue'

	const a = ref('Hello');

	// 响应式变量 a 发生修改,关联事件(alert) 会被触发
	watch(a, () => {
		alert(a.value)
	})


	// 5 秒后,修改响应式变量 a
	setTimeout(() => {
		a.value = 'Hello World!'
	}, 5000)
</script>

<template>
	<div>
		<!-- 响应式变量 a 发生修改,模板渲染内容也会及时跟进 -->
		<p>{{ a }}</p>
	</div>
</template>

接下来,我们将简单回顾在开发过程中最常用的响应式 API:

ref 和 reactive

ref 和 reactive 是响应式 API 的基础,它们能够将普通 JavaScript 变量变成响应式变量:

  • reactive 方法接收一个对象,将其变成响应式。
  • ref 可以让基本类型(字符串、数字、布尔)变量也能够变成响应式。
  • ref 创建的响应式数据需要通过 .value 属性进行访问和修改;而 reactive 创建的响应式对象可以直接访问和修改其属性。
  • 从表面上看 ref 更适合于处理基本类型,而 reactive 更适合于处理对象。(不过这不代表 ref 不可以处理对象,许多实践中推荐尽可能使用 ref 代替 reactive参考:VueUse Guidelines)。
<script setup lang="ts">
	import { ref, reactive } from 'vue'

	const refState = ref(0);
	console.log(refState) // Ref 对象
	console.log(refState.value) // 0

	const reactiveState = reactive({ state: 0 })
	console.log(reactiveState.state) // 0

	function clickHandler() {
		// ref 对象的设置也需要 .value
		refState.value++;
		reactiveState.state++;
	}
</script>

<template>
	<div>
		<!-- 注意在模板中访问 ref 变量不需要 value  -->
		<p>{{ refState }}</p>
		<p>{{ reactiveState.state }}</p>
		<button @click="clickHandler">+1</button>
	</div>
</template>

computed

computed 用于创建一个响应式的计算属性。

<script setup lang="ts">
	import { ref, reactive, computed } from 'vue'

	const a = ref(1);
	const b = reactive({ count: 2 })

	// 函数内部无论是 a 还是 b 发生变化,都会自动触发响应式变量 sum 的重新计算,永远保持 sum = a + b.count
	const sum = computed(() => a.value + b.count)

	setTimeout(() => {
		a.value = 2;
		b.count = 3;
		// 注意访问 computed 创建的响应式变量时也要加上 .value
		console.log(sum.value) // 5
	}, 5000)
</script>

<template>
	<div>
		<!-- 注意在模板中访问 ref 变量不需要 value  -->
		<p>a = {{ a }}</p>
		<p>b.count = {{ b.count }}</p>
		<p>sum = a + b.count = {{ sum }}</p>
	</div>
</template>

watch

watch 用于观察一个或多个响应式对象,并在观察对象发生变化时,执行与其相关联的方法。

import {
	ref,
	reactive,
	watch
} from 'vue'

const count = ref(0)
const data = reactive({
	count: 0
})

watch(count, (newVal, oldVal) => {
	// count changed from 0 to 1
	console.log(`count changed from ${oldVal} to ${newVal}`)
})

watch(data, (newVal, oldVal) => {
	// { count: 2 }
	console.log(oldVal)

	// { count: 2 }
	console.log(newVal)
})

// 检测 reactive 对象内部属性时,需要写成函数返回的形式
watch(() => data.count, (newVal, oldVal) => {
	// data.count changed from 0 to 2
	console.log(`data.count changed from ${oldVal} to ${newVal}`)
})

// 观测多个 响应式对象/属性 的变化
watch([
	count,
	() => data.count
], ([newCount, newDataCount], [oldCount, oldDataCount]) => {
	// count changed from 0 to 1
	// data.count changed from 0 to 2
	console.log(`count changed from ${oldCount} to ${newCount}`)
	console.log(`data.count changed from ${oldDataCount} to ${newDataCount}`)
})

setTimeout(() => {
	count.value = 1
	data.count = 2
}, 5000)

上述提到的响应式 API 具有最强的泛用性,涵盖了 90% 甚至更多的应用场景。需要更加深入的了解响应式 API,可以进一步参考官方文档:

  • 响应式 API:核心
  • 响应式 API:工具函数
  • 响应式 API:进阶

组件的生命周期

每个 Vue 组件实例在创建时都需要经历一系列的初始化步骤,比如设置好数据侦听,编译模板,挂载实例到 DOM,以及在数据改变时更新 DOM。在此过程中,它也会运行被称为生命周期钩子的函数,让开发者有机会在特定阶段运行自己的代码。

  • onBeforeMount(): 在组件被挂载之前调用,此时组件已经完成了响应式状态的设置,但还没有创建 DOM 节点。
  • onMounted(): 在组件被挂载之后调用,此时组件已经创建了 DOM 节点,并插入了父容器中。可以在这个钩子中访问或操作 DOM 元素
  • onBeforeUpdate(): 在组件即将因为响应式状态变更而更新其 DOM 树之前调用,可以在这个钩子中访问更新前的 DOM 状态。
  • onUpdated(): 在组件因为响应式状态变更而更新其 DOM 树之后调用,可以在这个钩子中访问更新后的 DOM 状态。
  • onBeforeUnmount(): 在组件实例被卸载之前调用,此时组件实例依然还保有全部的功能。
  • onUnmounted(): 在组件实例被卸载之后调用,此时组件实例已经失去了全部的功能。可以在这个钩子中清理一些副作用,如计时器、事件监听器等
  • onErrorCaptured(): 在捕获了后代组件传递的错误时调用,可以在这个钩子中处理错误或阻止错误继续向上传递。
  • onRenderTracked(): 在组件渲染过程中追踪到响应式依赖时调用,仅在开发模式下可用,用于调试响应式系统。

在实际的开发过程中,我们最常用到的声明周期钩子是 onMountedonBeforeUnmount / onUnmounted——它们具有最强的泛用性,在实际开发过程中占据了 90% 的出场率。

<script setup lang="ts">
	import { ref, onMounted, onBeforeUnmount } from 'vue'

	const el = ref<HTMLDivElement>()
	console.log(el.value) // undefined
	onMounted(() => {
		// 通常在 onMounted 中获取 DOM
		console.log(el.value) // HTMLDivElement
	})

	const timer = setTimeout(function () {
		// 定时器任务
	}, 5000)
	onBeforeUnmount(() => {
		// 在 onBeforeUnmount 中注销定时器、绑定事件等
		clearTimeout(timer)
	})
</script>

<template>
	<div ref="el"></div>
</template>

曾经的选项式 API 中,全局只有一个 mounted 钩子,所有的 DOM 初始化相关逻辑都要写进去。不同的是,组合式 API 中的生命周期钩子是可以多次调用的,这一特点使得组合式 API 更加擅于“按逻辑关系划分组织代码”。

<script setup lang="ts">
	import { ref, onMounted, onBeforeUnmount } from 'vue'

	const a = ref<HTMLDivElement>()
	onMounted(() => {
		console.log(a.value) // HTMLDivElement
		console.log(a.value.innerText) // aaa
	})

	const b = ref<HTMLDivElement>()
	onMounted(() => {
		console.log(b.value) // HTMLDivElement
		console.log(b.value.innerText) // bbb
	})

	const c = ref<HTMLDivElement>()
	onMounted(() => {
		console.log(c.value) // HTMLDivElement
		console.log(c.value.innerText) // ccc
	})
</script>

<template>
	<div>
		<div ref="a">aaa</div>
		<div ref="b">bbb</div>
		<div ref="c">ccc</div>
	</div>
</template>

组件之间的通信方式

接下来还有一个问题,就是组件如何与外部进行交互,即如何与其他组件进行通信?对于组件库的开发而言,我们推荐使用以下通信机制,对于每种通信机制都给出了实例代码,大家可以在自己创建的示例工程中,或者在 Vue SFC Playground 尝试运行示例,查看效果。

props / v-bind

参考:Vue 官方文档:Props

  • 这是 Vue 中父子组件最基础的通信方式。子组件声明自身的属性 props,父组件调用子组件时通过 v-bind 绑定属性。
  • 结合使用 withDefaults 和 defineProps,可以完整地设置组件属性的类型与默认值。
  • 组件的属性可以是任何类型,包括复杂对象、函数等。
  • 组件原则上不允许修改 props,因此 props 是一种从父到子的单向通信机制。但是子组件可以利用函数类型的 props,将内部的状态通过函数参数告知父组件实现反向通信。
<!-- 子组件 child.vue -->
<script setup lang="ts">
	import { reactive } from 'vue'

	const props = withDefaults(defineProps<{
		// props 的类型
		text ?: string;
		data ?: Record<string, any>;
		clickCallback ?: (data : Record<string, any>) => void
	}>(), {
		// props 的默认值
		text: 'Button',
		data: () => ({}),
		clickCallback: () => { }
	})

	const childData = reactive({
		...props.data,
		count: 0,
	})

	function clickHandler() {
		childData.count++
		props.clickCallback(childData)
	}
</script>

<template>
	<button @click="clickHandler">
		{{ text }}
	</button>
</template>
<!-- 父组件中使用子组件 child.vue -->
<script setup lang="ts">
	import Child from './child.vue'

	function clickHandler(data : Record<string, any>) {
		console.log('子组件的数据对象:', data); // 子组件的数据对象:{ message: 'parent', count: 1 }
	}
</script>

<template>
	<Child text="Hello World" :data="{ message: 'parent' }" :clickCallback="clickHandler" />
</template>

本案例的代码演示:props / v-bind(https://b.d4t.cn/rRtQ3e)

emit / v-on

参考:Vue 官方文档:事件

  • 组件之间从子到父的单向通信机制。组件通过 defineEmits 声明事件。
  • 父组件通过 v-on 监听子组件事件,当子组件内部调用 emit() 触发事件时,会执行 v-on 绑定的方法。
  • 因为 emit() 可以携带参数,因此子组件可以向父组件传递自身的状态。
<!-- 子组件 child.vue -->
<script setup lang="ts">
	import { reactive } from 'vue'

	const emit = defineEmits<{
		(event : 'add', val : string, list : string[]) : void;
	}>();

	const list : string[] = reactive([])

	function clickHandler() {
		const value = `第${String(list.length + 1)}项`
		list.push(value)
		emit('add', value, list);
	}
</script>

<template>
	<div>
		<ul>
			<li v-for="(item, index) in list" :key="index">{{ item }}</li>
		</ul>
		<button @click="clickHandler">Add</button>
	</div>
</template>
<!-- 父组件中使用子组件 child.vue -->
<script setup lang="ts">
	import Child from './child.vue'

	function addHandler(value : string, list : string[]) {
		console.log('向子组件列表添加项:', value)
		console.log('子组件当前列表:', list)
	}
</script>

<template>
	<Child @add="addHandler" />
</template>

本案例的代码演示:emit / v-on(https://b.d4t.cn/yg9kN8)

v-model

参考:Vue 官方文档:组件 v-model

  • v-model 机制是 vue 提供的一个语法糖,它能够使一个响应式变量在父子组件之间始终保持同步,实现双向绑定。
  • 实现组件的 v-model 机制需要综合使用上述的 props 和 emit。子组件通过 emit() 方法触发一个携带了新值的 update:xxx 自定义事件,就能使父组件绑定到子组件 props 上的 xxx 属性同步为对应的新值。
  • 下面的例子以一个 input 输入框组件为例子,通过 watch 方法实现 v-model 机制。无论父组件从外部修改,还是子组件在内部修改,v-model 绑定的 value 属性始终双向同步。
<!-- 子组件 child.vue -->
<script setup lang="ts">
	import { ref, watch } from 'vue'

	const props = withDefaults(defineProps<{
		value ?: string;
	}>(), {
		value: ''
	})

	const emit = defineEmits<{
		(event : 'update:value', val : string) : void;
	}>();

	const valueModel = ref(props.value);

	watch(() => props.value, (val) => {
		valueModel.value = val
	})

	watch(valueModel, (val) => {
		emit('update:value', val)
	})

	function inputHandler(event : Event) {
		const { value } = event.target as HTMLInputElement
		valueModel.value = value
	}

	function clickHandler() {
		valueModel.value += 'Hello World!'
	}
</script>

<template>
	<div>
		<input :value="valueModel" @input="inputHandler" />
		<button @click="clickHandler">子组件内部修改 value</button>
	</div>
</template>
<!-- 父组件中使用子组件 child.vue -->
<script setup lang="ts">
	import Child from './child.vue'
	import { ref, watch } from 'vue'

	const msg = ref('')
</script>

<template>
	<div>
		<Child v-model:value="msg" />
		<p>{{ msg }}</p>
	</div>
</template>

本案例的代码演示:v-model(https://b.d4t.cn/pcBCCV)

defineExpose / ref

参考:Vue 官方文档:模板引用

  • 子组件使用 defineExpose 向外暴露自身的属性与方法。
  • 父组件通过 ref 获取子组件的实例对象,访问与调用子组件暴露的属性与方法。
<!-- 子组件 child.vue -->
<script setup lang="ts">
	import { ref } from 'vue'

	const isVisible = ref(false);

	function open() {
		isVisible.value = true;
	}

	function close() {
		isVisible.value = false;
	}

	defineExpose({
		isVisible,
		open,
		close
	})
</script>

<template>
	<div v-if="isVisible">Child</div>
</template>
<!-- 父组件中使用子组件 child.vue -->
<script setup lang="ts">
	import Child from './child.vue'
	import { ref, computed } from 'vue'

	const childInstance = ref<InstanceType<typeof Child>>()

	const showState = computed(() => `${childInstance.value?.isVisible ? '显示' : '隐藏'}`)

	function showHandler() {
		childInstance.value?.open()
		console.log('当前组件的状态:', showState.value)
	}

	function hideHandler() {
		childInstance.value?.close();
		console.log('当前组件的状态:', showState.value)
	}
</script>

<template>
	<div>
		<button @click="showHandler">显示 Child</button>
		<button @click="hideHandler">隐藏 Child</button>
		<p>当前组件的状态:{{ showState }}</p>
		<Child ref="childInstance" />
	</div>
</template>

本案例的代码演示:defineExpose / ref(http://985.so/2npjv)

provide / inject

参考:Vue 官方文档:依赖注入

provide / inject 是 vue 中的依赖注入 API,可用于在组件树中传值。凡是在上层组件中通过 provide 注册的值,都可以在下层组件中使用 inject 获取。

我们通过 单选框组 RadioGroup 的场景来演示 provide / inject 的典型使用,radio-group 组件可以将包括选中值在内的自身状态包装为上下文对象,通过 provide 向下传递,内部的 radio 组件中通过 inject 方法获取上下文对象,从而可以根据自身属性更新 select 组件的状态。

这样的传值方式,使得子组件之间只要处在同一个父组件之下,也得以共享对象,实现同级组件之间的通信。

<!-- radio-group.vue -->
<script setup lang="ts">
	import { ref, watch, provide, Ref } from 'vue'

	const props = withDefaults(defineProps<{
		modelValue ?: any;
	}>(), {
		modelValue: ''
	})

	const emit = defineEmits<{
		(event : 'update:modelValue', val : any) : void;
	}>();

	// 实现选中项 v-model 双向绑定
	const model = ref(props.modelValue)
	watch(() => props.modelValue, (val) => { model.value = val })
	watch(model, (val) => { emit('update:modelValue', val) })

	// 将组件的上下文对象向下传递
	const context = {
		radioGroupSelected: model,
		selections: <Ref<boolean>[]>[]
	};

	export type RadioGroupContext = typeof context

	provide('radio-group', context)
</script>

<template>
	<ul class="radio-group">
		<slot />
	</ul>
</template>
<!-- radio.vue -->
<script setup lang="ts">
	import { ref, watch, inject, Ref } from 'vue'
	import type { RadioGroupContext } from './radio-group.vue'

	const props = withDefaults(defineProps<{
		/** 单个 radio 的选中状态 */
		modelValue ?: boolean;

		/** radio 的绑定值 */
		value ?: any;
	}>(), {
		modelValue: false,
		value: ''
	})

	const emit = defineEmits<{
		(event : 'update:modelValue', val : boolean) : void;
	}>();

	// 获取 radio-group 组件的上下文对象
	const radioGroupContext = inject<RadioGroupContext>('radio-group')

	// 实现选中状态 v-model 双向绑定
	const model = ref(props.modelValue)
	watch(() => props.modelValue, (val) => { model.value = val })
	watch(model, (val) => { emit('update:modelValue', val) })

	if (radioGroupContext) {
		// 若检测到父级 radio-group 组件,将自身状态推入上下文对象
		radioGroupContext.selections.push(model);
	}

	function changeHandler(event : Event) {
		const { checked } = event.target as HTMLInputElement
		model.value = checked

		if (checked && radioGroupContext) {
			// 子组件被选中时,根据子组件绑定的 value,控制父组件的 v-model 绑定值
			radioGroupContext.radioGroupSelected.value = props.value

			// 取消其他同级 radio 的选中状态
			radioGroupContext.selections.forEach((selection) => {
				if (selection !== model) {
					selection.value = false
				}
			})
		}
	}
</script>

<template>
	<li class="radio">
		<input type="radio" :checked="model" @change="changeHandler" />
		<slot />
	</li>
</template>
<!-- 父组件中使用子组件 radio-group.vue -->
<script setup lang="ts">
	import { ref } from 'vue'
	import RadioGroup from './radio-group.vue'
	import Radio from './radio.vue'

	const value = ref('')
</script>

<template>
	<div>
		<RadioGroup v-model="value">
			<Radio value="11111">选项 1</Radio>
			<Radio value="22222">选项 2</Radio>
			<Radio value="33333">选项 3</Radio>
		</RadioGroup>
		<p>当前选中的值:{{ value }}</p>
	</div>
</template>

本案例的代码演示:provide / inject(http://x4ey.ym11.cn/a0)

插槽 slot

参考:Vue 官方文档:插槽

  • 插槽功能允许我们将自定义模板内容渲染到组件的特定位置,也算作一种父组件向子组件通信的方式。
  • 通过 作用域插槽 功能,组件可以向一个插槽的出口上传递属性,而父组件使用插槽时通过 v-slot 指令就能接收到子组件所传递的内容。
<!-- 子组件 child.vue -->
<script setup lang="ts">
	import { reactive } from 'vue'

	const data = reactive({
		default: 0,
		special: 0
	})
</script>

<template>
	<div>
		<p>defaultCount:{{ data.default }}</p>
		<slot :data="data" />
		<p>specialCount:{{ data.special }}</p>
		<slot name="special" :data="data" />
	</div>
</template>
<!-- 父组件中使用子组件 child.vue -->
<script setup lang="ts">
	import Child from './child.vue'
</script>

<template>
	<Child>
		<template #default="{ data }">
			<button @click="data.default++">Click</button>
		</template>
		<template #special="{ data }">
			<button @click="data.special++">Click</button>
		</template>
	</Child>
</template>

本案例的代码演示:插槽 slot(http://x4en.ym11.cn/a2)

封装组件的优秀实践

了解了 Vue 框架的基础知识和组件开发技巧后,我们给大家分享一些优秀的实践,可以改善编码体验,更好地组织组件的逻辑模块,促进代码质量的提升。

安装并设置配套的 IDE 插件

许多小伙伴还在使用 Vetur 作为 Vue 开发的辅助插件,虽然 Vetur 的下载量压倒性得高,但它代表的是 Vue2 时代的历史,目前已经不再得到持续维护

我们应该卸载 Vetur,改为安装 Volar 和 TypeScript Vue Plugin。前者支持 Vue3 的语法特性,后者提供了对 .vue 单文件模板的 TypeScript 支持。

如果想要更进一步加强 TypeScript 支持,我们应当参照 Vue 官方文档:Volar Takeover 模式 对编辑器进行配置,使得 TypeScript Vue Plugin 也能接管普通的 .ts 文件,进而支持对 Vue 组件实例类型的推断。

单组件的文件结构

我们推荐大家在开发单个组件时,尝试用以下文件结构来组织代码:

📦comp
 ┣ 📜comp.vue
 ┣ 📜composables.ts
 ┣ 📜index.ts
 ┗ 📜props.ts

概述和介绍

  • props.ts – 集中定义组件的属性 props、事件 emits 相关的接口。
  • composables.ts – 使用组合式 API,按照逻辑关注点的不同,将组件逻辑封装为多个组合式函数。
  • comp.vue – 组件的单文件模板。
  • index.ts – 组件的出口,导出其他文件中的内容,参考内容如下:
// index.ts
import Comp from './comp.vue';

export { Comp }
export * from './composables';
export * from './props';

规范组件的定义

我们推荐在 props.ts 中集中定义组件的属性 props、事件 emits 相关的接口,供组件的逻辑实现 composables.ts 以及单文件模板 .vue 文件使用。这里以 input 输入框组件的属性定义为例子,在 props.ts 中应当定义以下内容:

  • 组件的属性 props 接口以及默认值。
  • 组件的事件 emits 接口。
  • 组件的实例类型。
// props.ts
import { InferVueDefaults } from '@/utils';
import type Input from './Input.vue';

export interface InputProps {
	/** 原生 input 类型 */
	type ?: string;

	/** 绑定值 */
	modelValue ?: string;

	/** 输入框占位文本 */
	placeholder ?: string;

	/** 是否显示清楚按钮 */
	clearable ?: boolean;
}

export type RequiredInputProps = Required<InputProps>

export function defaultInputProps() : Required<InferVueDefaults<InputProps>> {
	return {
		type: 'text',
		modelValue: '',
		placeholder: '',
		clearable: false
	};
}

export interface InputEmits {
	(event : 'update:modelValue', val : string) : void;
	(event : 'input', val : string) : void;
	(event : 'clear') : void;
	(event : 'focus') : void;
	(event : 'blur') : void;
}

export type InputInstance = InstanceType<typeof Input>

关于 InferVueDefaults,这个是 Vue 中推断默认 props 类型的类型工具,我们可以自己实现它:

type NativeType = null | number | string | boolean | symbol | Function;
type InferDefault<P, T> = ((props: P) => T & {}) | (T extends NativeType ? T : never);

/** 推断出 props 默认值的类型 */
export type InferVueDefaults<T> = {
	[K in keyof T]?: InferDefault<T, T[K]>;
};

在组件的单文件模板实现 input.vue 中,我们可以引入 props.ts 中的接口与类型,使用 Vue 官方文档:编译器宏 规范清晰地定义组件。

<!-- Input.vue -->
<script setup lang="ts">
	import {
		defaultInputProps,
		InputProps,
		InputEmits
	} from './props';

	// 声明自定义选项,如组件名称 name
	defineOptions({
		// ...
	})

	// 定义属性 props
	const props = withDefaults(
		defineProps<InputProps>(),
		defaultInputProps(),
	);

	// 定义事件 emits
	const emit = defineEmits<InputEmits>();

	// 组件实现逻辑


	// 向外暴露属性与方法
	defineExpose({
		// ...
	})
</script>

<template>
	<!-- ... -->
</template>

除了使组件本身的实现代码更具条理性,在 props.ts 中规范声明的类型与接口带来的另一大好处是:当用户希望对组件进行使用和拓展时,可以得到强大、完善、贴心的类型支持。

  • 通过 ref 获取组件实例时,用户可以直接使用 Instance 类型,无需自己实现类型推断。
<script setup lang="ts">
	import { InputInstance } from './input'
	import { ref } from 'vue'

	const input = ref<InputInstance>()
</script>

<template>
	<Input ref="input" />
</template>
  • 在对组件进行二次封装时,用户可以引入 PropsEmits 接口,通过继承在原组件的基础上继续拓展属性与事件的定义。
<!-- MyInput.vue -->
<script setup lang="ts">
	import {
		defaultInputProps,
		InputProps,
		InputEmits
	} from './input';

	interface MyInputProps extends InputProps {
		// ...
	}

	const props = withDefaults(
		defineProps<MyInputProps>(),
		{
			...defaultInputProps(),
			// 更多属性的默认值
		},
	);

	interface MyInputEmits extends InputEmits {
		// ...
	}

	const emit = defineEmits<MyInputEmits>();
</script>

封装组合式函数

参考:Vue 官方文档:组合式函数

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>

遵循官方的建议,我们建议大家在 composables.ts 中,将组件的功能拆分为多个逻辑关注点,将每一个逻辑关注点封装为一个组合式函数。

继续以之前的 input 输入框为例子,我们可以简单划分出三个逻辑点:

  • 输入框内容的双向绑定,通过 useInputModelValue 实现。
  • 输入框的聚焦、失焦等各种事件的处理,通过 useInputEvents 实现。
  • 输入框的清空逻辑,通过 useInputClearable 实现。
// composables.ts
import {ref, watch, onMounted, onBeforeUnmount} from "vue";
import {RequiredInputProps, InputEmits} from "./props";

export function useInputModelValue(props: RequiredInputProps, emit: InputEmits) {
	const model = ref(props.modelValue);

	watch(
		() => props.modelValue,
		(val) => {
			model.value = val;
		}
	);

	watch(model, (val) => {
		emit("update:modelValue", val);
	});

	return {model};
}

export function useInputEvents(emit: InputEmits, modelValueContext: ReturnType<typeof useInputModelValue>) {
	const inputEl = ref<HTMLInputElement>();
	const {model} = modelValueContext;

	function focus() {
		inputEl.value?.focus();
	}

	function blur() {
		inputEl.value?.blur();
	}

	function focusHandler() {
		emit("focus");
	}

	function blurHandler() {
		emit("blur");
	}

	function inputHandler(e: Event) {
		const {value} = e.target as HTMLInputElement;
		model.value = value;
		emit("input", value);
	}

	// 组件挂载后获取 dom
	onMounted(() => {
		inputEl.value?.addEventListener("focus", focusHandler);
		inputEl.value?.addEventListener("blur", blurHandler);
		inputEl.value?.addEventListener("input", inputHandler);
	});

	// 组件注销前及时解绑事件
	onBeforeUnmount(() => {
		inputEl.value?.removeEventListener("focus", focusHandler);
		inputEl.value?.removeEventListener("blur", blurHandler);
		inputEl.value?.removeEventListener("input", inputHandler);
	});

	return {
		inputEl,
		focus,
		blur
	};
}

export function useInputClearable(emit: InputEmits, modelValueContext: ReturnType<typeof useInputModelValue>) {
	const {model} = modelValueContext;

	function clearHandler() {
		model.value = "";
		emit("clear");
	}

	return {clearHandler};
}

最后,在input.vue 单文件模板中,我们引入composables.ts 中的函数进行组合。

<!-- input.vue -->
<script setup lang="ts">
	import {
		defaultInputProps,
		InputProps,
		InputEmits
	} from './props';
	import {
		useInputModelValue,
		useInputClearable,
		useInputEvents
	} from './composables'

	defineOptions({
		// 自定义选项
	})

	const props = withDefaults(
		defineProps<InputProps>(),
		defaultInputProps(),
	)

	const emit = defineEmits<InputEmits>()

	// 组件实现逻辑
	const modelValueContext = useInputModelValue(props, emit)
	const { model } = modelValueContext

	const { inputEl, focus, blur } = useInputEvents(emit, modelValueContext)

	const { clearHandler } = useInputClearable(emit, modelValueContext)

	defineExpose({
		clear: clearHandler,
		focus,
		blur,
	})
</script>

<template>
	<div>
		<input ref="inputEl" :type="type" :value="model" :placeholder="placeholder" />
		<button v-if="clearable" @click="clearHandler">清除</button>
	</div>
</template>

完整的案例代码演示:单组件封装实践(http://x4e3.ym11.cn/23)

虽然组件的代码经过分离逻辑关注点后变得更加清晰,但是我们例子中的组合函数还是有很大的提升空间——composables.ts 中函数需要的参数被限定为 input 组件的 props 和 emits,这就使得我们的组合函数只能用于特定的组件,而缺乏通用性,这些逻辑很难被其他的组件复用。

发表回复