必不可少的UI组件一——组件的基础知识
本文由郭凯南同学分享,主要是基于组件库开发的场景,介绍了 Vue
组件开发的基础知识与优秀实践。
前言
很多同学随着自己前端技能的增长,不满足于常规的业务开发,开始想着做一些自己的技术积累。例如学会了 Vue
框架的使用以及如何封装组件之后,想要自己试着开发一套组件库引入到项目中,甚至共享给更多的人使用。但是在这个过程中,往往会遇到许多的问题:
- 组件库工程需要的基础设施该如何搭建,如何实现组件库的构建、提交门禁、测试、文档、发布?
- 对于复杂一些的组件,我在实现的过程中感觉逻辑越来越混乱,代码越来越难以维护,最终难以持续迭代下去。
- 有些组件交互复杂,甚至由多个子组件构成(例如 Form 和 FormItem),它们之间的通信和状态共享如何处理?感觉缺少思路,无从下手。
磨刀不误砍柴工,对于正处于经验积累阶段的前端同学,或许需要先重温一些基础知识,夯实内功,才能更好地实践。
实践 Vue
组件库的搭建,我们是需要掌握一些前置知识的:
- 一方面是前端工程化相关内容,它们是组件库的地基、脚手架般重要的存在,是整个组件库工程的基础;
- 另一方面是
Vue
组件开发的技巧与优秀实践,它们在实现组件库主体部分时发挥作用,决定了我们的能否实现、能否做好每一个组件。
本章分享的内容侧重于后者,我们将基于组件库开发的场景,介绍一些高频使用的 Vue
框架基础知识与实战技巧,主要内容如下:
- 组件的基本概念;
- 官方主推的组件开发范式:单文件组件与组合式 API;
- 深入组合式 API:响应式 API;
- 深入组合式 API:组件的生命周期;
- 深入组合式 API:组件之间的通信方式;
- 组件开发的优秀实践介绍。
我们在举例时,会尽量贴近当下环境中较新的实践——使用 Vue
的最新版本与 TypeScript
。如果读者在阅读过程中对代码示例中的内容感到困惑,可以前往以下文档补充学习:
组件的基本概念
对于组件库而言,组件的概念是用户界面 UI 中独立的、可重用的部分,用户倾向于多次复用组件,像搭积木一样,将多个组件组合为完整的用户界面。
不过,站在 Vue
框架层面来看,我们先前提到的“组件”的概念其实是 Vue
框架中“组件”概念的子集。对于 Vue
框架而言,万物都是组件——无论是大的用户界面,还是小的功能模块。
任何一个 Vue
应用都可以看做是以 App.vue
(入口组件可以叫其他名称) 为根节点的组件树。
既然我们的目标是编写组件库,那么下文将要讲解的基础知识将围绕着以下三个问题展开:
- 应该采用什么样的范式编写 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 的一大优势,在于可以将相同逻辑关注点的代码聚合为一组,而不用为了同一个逻辑关注点在不同的选项之间来回切换。
我们分享中的演示案例都将采用“单文件模板和组合式 API 结合”的形式,同样推荐大家编写自己的组件库时采纳这种实践。这主要基于以下理由:
- 单文件模板和组合式 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,可以进一步参考官方文档:
组件的生命周期
每个 Vue 组件实例在创建时都需要经历一系列的初始化步骤,比如设置好数据侦听,编译模板,挂载实例到 DOM,以及在数据改变时更新 DOM。在此过程中,它也会运行被称为生命周期钩子的函数,让开发者有机会在特定阶段运行自己的代码。
onBeforeMount()
: 在组件被挂载之前调用,此时组件已经完成了响应式状态的设置,但还没有创建 DOM 节点。onMounted()
: 在组件被挂载之后调用,此时组件已经创建了 DOM 节点,并插入了父容器中。可以在这个钩子中访问或操作 DOM 元素。onBeforeUpdate()
: 在组件即将因为响应式状态变更而更新其 DOM 树之前调用,可以在这个钩子中访问更新前的 DOM 状态。onUpdated()
: 在组件因为响应式状态变更而更新其 DOM 树之后调用,可以在这个钩子中访问更新后的 DOM 状态。onBeforeUnmount()
: 在组件实例被卸载之前调用,此时组件实例依然还保有全部的功能。onUnmounted()
: 在组件实例被卸载之后调用,此时组件实例已经失去了全部的功能。可以在这个钩子中清理一些副作用,如计时器、事件监听器等。onErrorCaptured()
: 在捕获了后代组件传递的错误时调用,可以在这个钩子中处理错误或阻止错误继续向上传递。onRenderTracked()
: 在组件渲染过程中追踪到响应式依赖时调用,仅在开发模式下可用,用于调试响应式系统。
在实际的开发过程中,我们最常用到的声明周期钩子是 onMounted
、onBeforeUnmount
、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
,父组件调用子组件时通过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
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
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
defineExpose / ref
- 子组件使用
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
provide / inject
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
插槽 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
封装组件的优秀实践
了解了 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>
- 在对组件进行二次封装时,用户可以引入
Props
、Emits
接口,通过继承在原组件的基础上继续拓展属性与事件的定义。
<!-- 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
官方推荐我们按照类似下面的实践,通过抽取组合式函数改善代码结构。
<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>
完整的案例代码演示:单组件封装实践
虽然组件的代码经过分离逻辑关注点后变得更加清晰,但是我们例子中的组合函数还是有很大的提升空间——composables.ts
中函数需要的参数被限定为 input
组件的 props
和 emits
,这就使得我们的组合函数只能用于特定的组件,而缺乏通用性,这些逻辑很难被其他的组件复用。
如果希望更进一步了解组件封装的技巧,可以持续关注后续的内容,本文的分享就到这里。
OpenTiny 社区招募贡献者啦
OpenTiny Vue 正在招募社区贡献者,欢迎加入我们🎉
你可以通过以下方式参与贡献:
- 在 issue 列表中选择自己喜欢的任务
- 阅读贡献者指南,开始参与贡献
你可以根据自己的喜好认领以下类型的任务:
- 编写单元测试
- 修复组件缺陷
- 为组件添加新特性
- 完善组件的文档
如何贡献单元测试:
- 在
packages/vue
目录下搜索it.todo
关键字,找到待补充的单元测试 - 按照以上指南编写组件单元测试
- 执行单个组件的单元测试:
pnpm test:unit3 button
如果你是一位经验丰富的开发者,想接受一些有挑战的任务,可以考虑以下任务:
- ✨ [Feature]: 希望提供 Skeleton 骨架屏组件
- ✨ [Feature]: 希望提供 Divider 分割线组件
- ✨ [Feature]: tree树形控件能增加虚拟滚动功能
- ✨ [Feature]: 增加视频播放组件
- ✨ [Feature]: 增加思维导图组件
- ✨ [Feature]: 添加类似飞书的多维表格组件
- ✨ [Feature]: 添加到 unplugin-vue-components
- ✨ [Feature]: 兼容formily
参与 OpenTiny 开源社区贡献,你将收获:
直接的价值:
- 通过参与一个实际的跨端、跨框架组件库项目,学习最新的
Vite
+Vue3
+TypeScript
+Vitest
技术 - 学习从 0 到 1 搭建一个自己的组件库的整套流程和方法论,包括组件库工程化、组件的设计和开发等
- 为自己的简历和职业生涯添彩,参与过优秀的开源项目,这本身就是受面试官青睐的亮点
- 结识一群优秀的、热爱学习、热爱开源的小伙伴,大家一起打造一个伟大的产品
长远的价值:
- 打造个人品牌,提升个人影响力
- 培养良好的编码习惯
- 获得华为云 OpenTiny 团队的荣誉和定制小礼物
- 受邀参加各类技术大会
- 成为 PMC 和 Committer 之后还能参与 OpenTiny 整个开源生态的决策和长远规划,培养自己的管理和规划能力
- 未来有更多机会和可能
关于 OpenTiny
OpenTiny 是一套企业级组件库解决方案,适配 PC 端 / 移动端等多端,涵盖 Vue2 / Vue3 / Angular 多技术栈,拥有主题配置系统 / 中后台模板 / CLI 命令行等效率提升工具,可帮助开发者高效开发 Web 应用。
核心亮点:
跨端跨框架
:使用 Renderless 无渲染组件设计架构,实现了一套代码同时支持 Vue2 / Vue3,PC / Mobile 端,并支持函数级别的逻辑定制和全模板替换,灵活性好、二次开发能力强。组件丰富
:PC 端有100+组件,移动端有30+组件,包含高频组件 Table、Tree、Select 等,内置虚拟滚动,保证大数据场景下的流畅体验,除了业界常见组件之外,我们还提供了一些独有的特色组件,如:Split 面板分割器、IpAddress IP地址输入框、Calendar 日历、Crop 图片裁切等配置式组件
:组件支持模板式和配置式两种使用方式,适合低代码平台,目前团队已经将 OpenTiny 集成到内部的低代码平台,针对低码平台做了大量优化周边生态齐全
:提供了基于 Angular + TypeScript 的 TinyNG 组件库,提供包含 10+ 实用功能、20+ 典型页面的 TinyPro 中后台模板,提供覆盖前端开发全流程的 TinyCLI 工程化工具,提供强大的在线主题配置平台 TinyTheme
联系我们:
- 官方公众号:
OpenTiny
- OpenTiny 官网:https://opentiny.design/
- OpenTiny 代码仓库:https://github.com/opentiny/
- Vue 组件库:https://github.com/opentiny/tiny-vue (欢迎 Star)
- Angluar组件库:https://github.com/opentiny/ng (欢迎 Star)
- CLI工具:https://github.com/opentiny/tiny-cli (欢迎 Star)
更多视频内容也可以关注OpenTiny社区,B站/抖音/小红书/视频号。

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
如何用华为云ModelArts平台玩转Llama2
本文分享自华为云社区《如何用华为云ModelArts平台玩转Llama2》,作者:码上开花_Lancer。 天哪~~ Llama2模型开源了拉!! Llama2不仅开源了预训练模型,而且还开源了利用对话数据SFT后的Llama2-Chat模型,并对Llama2-Chat模型的微调进行了详细的介绍。 开源模型目前有7B、13B、70B三种尺寸,预训练阶段使用了2万亿Token,SFT阶段使用了超过10w数据,人类偏好数据超过100w。 发布不到一周的Llama 2,已经在研究社区爆火,一系列性能评测、在线试用的demo纷纷出炉。 就连OpenAI联合创始人Karpathy用C语言实现了对Llama 2婴儿模型的推理。 既然Llama 2现已人人可用,那么如何在华为云上去微调实现更多可能的应用呢? 打开华为云的ModelArts 创建notebook,首先需要下载数据集上传到OBS对象存储空间中,再通过命令copy到本地。 数据集地址:https://huggingface.co/datasets/samsum 1. 下载模型 克隆Meta的Llama推理存储库(包含下载脚本): !git...
- 下一篇
一文详解数据仓库的物理细粒度备份恢复
本文分享自华为云社区《DTSE Tech Talk | 第43期:数仓数据可靠保证——物理细粒度备份恢复》,作者:华为云社区精选。 大数据时代,数据对企业的重要性不言而喻,如果发生数据丢失或因为误操作而造成数据丢失,将对企业的经营决策带来不可估量的损失。本期《备份恢复全掌握,数仓数据更安全》的主题直播中,我们邀请到华为云EI DTSE技术布道师李文鑫,针对GaussDB(DWS) 物理细粒度备份恢复与开发者和伙伴朋友们展开交流互动。 GaussDB(DWS)的备份恢复工具 为了应对故障场景,防止数据丢失,GaussDB(DWS)提供了两道防线,以保障数仓安全,分别是:高可靠技术和备份恢复技术。高可靠技术是第一道防线,备份恢复技术是最后一道防线。 GaussDB(DWS)的备份恢复工具—Roach,提供了备份、恢复、容灾功能。备份恢复部分包括集群级备份、集群级恢复、物理细粒度备份、物理细粒度恢复、逻辑备份和恢复;容灾部分包括双集群容灾、双集群迁移、细粒度容灾。 为什么需要使用物理细粒度备份恢复? 假设我们误删了一张表,想通过备份将这张表恢复出来,如果我们采用集群级恢复的方式,那么就需要对...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- Eclipse初始化配置,告别卡顿、闪退、编译时间过长
- SpringBoot2全家桶,快速入门学习开发网站教程
- MySQL8.0.19开启GTID主从同步CentOS8
- Docker安装Oracle12C,快速搭建Oracle学习环境
- Docker使用Oracle官方镜像安装(12C,18C,19C)
- CentOS7安装Docker,走上虚拟化容器引擎之路
- Docker快速安装Oracle11G,搭建oracle11g学习环境
- CentOS7编译安装Cmake3.16.3,解决mysql等软件编译问题
- CentOS7编译安装Gcc9.2.0,解决mysql等软件编译问题
- CentOS6,7,8上安装Nginx,支持https2.0的开启