虽然现在又很多组件库,方便了我们的开发。但是自己对于组件的封装,对组件的认识也不能少。下面我们就来介绍一些常用组件的封装。
为了方便,我们的样式都使用bootstrap,方便开发。
下拉菜单
我们知道,下拉菜单需要每一个item项组成,所以我们就可以封装一个drop-down-item
的组件,并且封装其父组件drop-down
。
drop-down
组件,他只需要提供触发下拉菜单的文字。并且提供默认插槽,为定制不同的drop-down-item
。
<div class="dropdown" ref="refDom"> <a class="btn btn-outline-light dropdown-toggle" href="javascript:;" @click="openMenu" > hi, {{ name }} </a> <!-- bootstrap中默认dropdown为display: none --> <div class="dropdown-menu" style="display: block" v-if="isOpen"> <slot></slot> </div> </div>
这里需要注意的是,我们点击drop-down
组件外部范围内,该下拉菜单才会关闭。否则不会关闭。这时候,我们就需要js的一个API了。contains
这部分逻辑我们可以给它抽出,作为一个hooks。他的主要实现,就是传入下拉菜单根组件对象,即drop-down
组件的根组件,然后加以判断,返回一个boolean值。
import { ref, onMounted, onUnmounted, Ref } from "vue"; const useClickOutside = (refDom: Ref<null | HTMLElement>) => { const isClickOutside = ref(false); const handler = (e: MouseEvent) => { // 防止节点为获取到 if (refDom.value) { // 这个函数是判断点击区域是否是下拉菜单。 if (refDom.value.contains(e.target as HTMLElement)) { isClickOutside.value = false; } else { isClickOutside.value = true; } } }; onMounted(() => { window.addEventListener("click", handler); }); onUnmounted(() => { window.removeEventListener("click", handler); }); return { isClickOutside }; }; export default useClickOutside;
下面就来实现drop-down
组件的逻辑部分
<script lang="ts"> import { defineComponent, ref, watch } from 'vue' import useClickOutside from '../hooks/useClickOutside' export default defineComponent({ name: 'DropDown', props: { name: { type: String, required: true, }, }, setup() { const isOpen = ref(false) const openMenu = () => { isOpen.value = !isOpen.value } const refDom = ref<null | HTMLElement>(null) const { isClickOutside } = useClickOutside(refDom) watch(isClickOutside, () => { // 当点击是下拉菜单的外部并且下拉菜单处于展开状态。 if (isOpen.value && isClickOutside.value) { isOpen.value = false } }) return { isOpen, openMenu, refDom, } }, }) </script>
drop-down-item
组件,他就只需要提供默认插槽。并且根据外部传入的跳转url,来定制。
<template> <div class="drop-down-item"> <a class="dropdown-item" :href="path"> <slot></slot> </a> </div> </template> <script> import { defineComponent } from "vue"; export default defineComponent({ name: 'DropDownItem', props: { path: { type: String, required: true } } }) </script> <style scoped> .drop-down-item { cursor: pointer; } </style>
使用
<drop-down :name="user.username"> <drop-down-item path="/create">新建文章</drop-down-item> <drop-down-item :path="`/column/${user.column}`">我的专栏</drop-down-item> <drop-down-item path="/edit">编辑资料</drop-down-item> <drop-down-item path="/" @click="logout">退出登录</drop-down-item> </drop-down>
表单组件
我们知道表单组件,使用非常频繁。而且,通常情况下,我们都回去使用第三方的组件库,来完成这部分的展示。所以下面我们自己来封装一下表单组件吧。包括表单验证。
validate-form
组件 :
<template> <div class="validate-input pb-2"> <!-- :value="inputVal.val" @input="updateValue" 他两就相当于v-model="inputVal.val" --> <!-- 如果没有设置inheritAttribute为false的话,子组件中不是prop的属性将直接挂载到直接父元素上,这里将挂载到div.validate-input pb-2上 --> <div class="mb-3"> <label class="form-label">{{ inputLabel }}</label> <input v-if="tag === 'input'" class="form-control" :class="{ 'is-invalid': inputVal.error }" @blur="validate" v-bind="$attrs" v-model="inputVal.val" /> <textarea v-else-if="tag !== 'textarea'" class="form-control" :class="{ 'is-invalid': inputVal.error }" @blur="validate" v-bind="$attrs" v-model="inputVal.val" placeholder="请输入文章内容, 支持markdown语法" ></textarea> <small id="emailHelp" class="form-text text-muted invalid-feedback" v-if="inputVal.error" >{{ inputVal.message }}</small > </div> </div> </template>
对于单个表单元素它具有以下属性。
// 输入框中的约束 interface InputProps { // 表单绑定的值 val: string // 是否验证全部错误 error: boolean // 错误提示 message: string }
并且还需要具有一下表单验证规则的属性
//验证规则的约束 interface RuleProps { // 可以根据自己的需要,传入表单验证类型 type: 'required' | 'email' | 'password' | 'custom' // 表单验证错误信息 message: string // 当type类型为custom时,传入他,自定义验证函数。 valdator?: () => boolean }
这里我们就传入了两个表单类型,'input' | 'textarea'
。如果想要扩展,继续添加即可,然后再template中判断即可。
type Tag = 'input' | 'textarea';
validate-input
组件需要传入以下props。
props: { // 表单验证需要的rules数组 rules: Array as PropType<RulesProps>, //v-model实现的value modelValue: String, // 表单类型 tag: { type: String as PropType<Tag>, default: 'input', }, // 表示输入框的label值。 inputLabel: { type: String, required: true, }, }
实现表单值的双向绑定。
const inputVal: InputProps = reactive({ val: computed({ get: () => props.modelValue || '', set: val => { emit('update:modelValue', val) } }), error: false, message: '' })
实现表单验证函数。
const validate = () => { if (props.rules) { const allPassed = props.rules.every(rule => { let passed = true inputVal.message = rule.message switch (rule.type) { case 'required': passed = (inputVal.val.trim() !== '') break case 'email': passed = /^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/.test(inputVal.val) break case 'password': passed = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[^]{8,16}$/.test(inputVal.val) break // 如果传入的是自定义的验证函数,我们直接执行即可。 case 'custom': passed = rule.validator ? rule.validator() : true break default: break } return passed }) // 将全部验证通过后,将error设置为false inputVal.error = !allPassed return allPassed } return true }
其实我们也可以自定义触发验证的事件,这里默认指定是失去焦点(blur
)的时候,所以就不修改了。
接下来我们还需要将全部验证函数保存,发送给validate-form
组件,当点击按钮,我们将要判断时候验证都通过了,然后来进行请求的阻止或者发送。所以这些我们需要使用emitt
库来为我们服务。因为这是触发父组件中的按钮,然后将事件传到子组件中。
onMounted(() => { emitter.emit('all-true', validate) })
接下来我们就来看看validate-form
组件如何实现吧。 这里我们需要定义一个默认插槽,来放置若干个表单。还有一个表单提交的具名插槽。
<template> <div class="validate-form"> <form> <slot name="default"></slot> <div class="submit-area" @click.prevent="FormSubmit"> <slot name="submit"> <button type="submit" class="btn btn-primary">登录</button> </slot> </div> </form> </div> </template>
下面给出validate-input
和validate-form
组件的完整代码
// validate-form <template> <div class="validate-form"> <form> <slot name="default"></slot> <div class="submit-area" @click.prevent="FormSubmit"> <slot name="submit"> <button type="submit" class="btn btn-primary">登录</button> </slot> </div> </form> </div> </template> <script lang="ts"> import { defineComponent, onUnmounted } from 'vue' import emitter from '../mitt' type Func = () => boolean export default defineComponent({ name: 'ValidateForm', emits: ['form-submit'], setup(props, context) { let funcArr: Func[] = [] const FormSubmit = () => { // 调用数组中每一项,然后判断是否有false const val = funcArr.map((item) => item()).every((element) => element) context.emit('form-submit', val) } // 这里就是将全部验证函数保存在数组中。 const callback = (func?: Func) => { if (func) { funcArr.push(func) } } emitter.on('all-true', callback) onUnmounted(() => { emitter.off('all-true', callback) // 清空数组 funcArr = [] }) return { FormSubmit, } }, }) </script> <style scoped> .submit-area { margin-top: 30px; margin-bottom: 20px; } </style>
<template> <div class="validate-input pb-2"> <div class="mb-3"> <label class="form-label">{{ inputLabel }}</label> <input v-if="tag === 'input'" class="form-control" :class="{ 'is-invalid': inputVal.error }" @blur="validate" v-bind="$attrs" v-model="inputVal.val" /> <textarea v-else-if="tag !== 'textarea'" class="form-control" :class="{ 'is-invalid': inputVal.error }" @blur="validate" v-bind="$attrs" v-model="inputVal.val" ></textarea> <small id="emailHelp" class="form-text text-muted invalid-feedback" v-if="inputVal.error" >{{ inputVal.message }}</small > </div> </div> </template> <script lang="ts"> import { defineComponent, PropType, reactive, onMounted, ref, watch, computed, } from 'vue' import emitter from '../mitt' // 输入框中的约束 interface InputProps { val: string error: boolean message: string } //验证规则的约束 interface RuleProps { type: 'required' | 'email' | 'password' | 'custom' message: string valdator?: () => boolean } export type RulesProps = RuleProps[] // 判断输入框是普通输入框,还是多行输入框 type Tag = 'input' | 'textarea' export default defineComponent({ name: 'ValidateInput', // 将非props中的属性不要挂载到根组件上 inheritAttrs: false, props: { rules: Array as PropType<RulesProps>, //v-model实现的value modelValue: String, tag: { type: String as PropType<Tag>, default: 'input', }, // 表示输入框的label值。 inputLabel: { type: String, required: true, }, }, setup(props, context) { const inputVal: InputProps = reactive({ val: computed({ get() { return props.modelValue || '' }, set(val: string) { context.emit('update:modelValue', val) }, }), error: false, message: '', }) const validate = () => { if (props.rules) { const allPassed = props.rules.every(rule => { let passed = true inputVal.message = rule.message switch (rule.type) { case 'required': passed = (inputVal.val.trim() !== '') break case 'email': passed = /^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/.test(inputVal.val) break case 'password': passed = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[^]{8,16}$/.test(inputVal.val) break // 如果传入的是自定义的验证函数,我们直接执行即可。 case 'custom': passed = rule.validator ? rule.validator() : true break default: break } return passed }) // 将全部验证通过后,将error设置为false inputVal.error = !allPassed return allPassed } return true } onMounted(() => { emitter.emit('all-true', validate) }) return { inputVal, validate } }, }) </script> <style scoped> .form-text { color: #dc3545 !important; } </style>
loading组件
当我们发送请求的时候,我们需要控制loading的展示,提高用户体验。
下面来看看它的模板吧。
<template> <teleport to="#loader"> <div class="loader"> <div class="loader-mask"></div> <div class="container"> <div class="spinner-border text-primary" role="status"> <span class="sr-only"></span> </div> </div> </div> </teleport> </template>
由于loading组件是独立于各个组件之外的,所以我们应该将它挂在到body标签中,作为直接子元素。这时就需要用到vue3内置的teleport
组件了。
<script> import { defineComponent, onUnmounted } from "vue"; export default defineComponent({ name: 'Loader', setup() { const oLoader = document.createElement('div'); oLoader.id = 'loader' document.body.appendChild(oLoader) onUnmounted(() => { document.body.removeChild(oLoader) }) } }) </script>
下面来看看它的样式。
<style scoped> .loader { width: 100%; height: 100%; } .loader-mask { position: fixed; z-index: 9; left: 0; right: 0; top: 0; background: #000000; opacity: .4; width: 100%; height: 100%; } .spinner-border { position: absolute; top: 50%; left: 50%; } </style>
message组件
这个组件也是比较常见的,当用户输入错误信息,或者做了一些错误操作,我们就可以使用这个组件来提示用户。
他只需要传入提示信息和提示类型,来定制message组件。这个组件非常容易封装,下面直接给出代码。
<template> <teleport to="#message"> <div class=" message alert message-info fixed-top mx-auto d-flex justify-content-between mt-2"> <div class="alert" :class="`alert-${type}`" role="alert"> {{message}} </div> </div> </teleport> </template> <script lang="ts"> import { defineComponent, onUnmounted, PropType } from "vue"; export type MessageType = 'success' | 'error' | 'default' export default defineComponent({ name: 'Message', props: { type: { type: String as PropType<MessageType>, required: true }, message: String }, setup() { const oDiv = document.createElement('div'); oDiv.id = "message" document.body.appendChild(oDiv) onUnmounted(() => { document.body.removeChild(oDiv) }) } }) </script> <style scoped> .message { margin: 0 auto; } .alert { width:500px; text-align: center; } </style>
但是请思考一下,我们的提示信息,一般想要通过函数来调用。方便操作。因为当我们出现错误时,都是在逻辑代码中的,直接调动函数就可以创建一个message组件。
这时候,你就需要了解一下createApp
API了。请访问
- 该函数接收一个根组件选项对象作为第一个参数
- 使用第二个参数,我们可以将根 prop 传递给应用程序 下面就来看看
createMessage
函数组件怎么实现吧。
import { createApp } from 'vue' import Message from './Message.vue' export type MessageType = 'success' | 'error' | 'default' const createMessage = ( message: string, type: MessageType, timeout = 2000 ) => { const messageInstance = createApp(Message, { message, type }) const mountNode = document.createElement('div') document.body.appendChild(mountNode) messageInstance.mount(mountNode) // 指定时间内,移除message组件 setTimeout(() => { messageInstance.unmount(mountNode) document.body.removeChild(mountNode) }, timeout) } export default createMessage
文件上传组件
这个组件也是比较常见的,如果网站需要上传图片,就需要这个组件了。 我们可以对文件上传,做一些验证等操作。并且利用前面封装的组件来提高用户体验。
下面来看看它的模板吧。我们先隐藏input标签,然后出发btn-upload然后来间接出发input中的click事件,来提高用户体验。
<template> <div class="up-loader"> <div class="btn-upload" @click="triggerUpload"> <span v-if="status === 'ready'">点击上传头像</span> <div v-else-if="status === 'loading'"> <span>正在上传</span> <span class="spinner-border text-secondary"> <span class="sr-only"></span> </span> </div> <span v-else-if="status === 'error'">头像上传失败</span> <div class="img" v-else> <img :src="imgUrl" class="img-fluid" alt="" /> </div> </div> <input type="file" ref="inputRef" class="d-none" @change="handleFileChange" /> </div> </template>
我们可以给文件上传,定义四个状态。来展示不同的上传状态。
type StatusProps = 'ready' | 'loading' | 'success' | 'error'
组件需要接收这些props。
props: { // 文件上传路径 action: { type: String, required: true, }, // 验证函数 uploadCheck: { type: Function as PropType<UploadCheckFunc>, required: true, }, // 编辑时的图片地址,做回写时使用 editImg: { type: String, required: true, }, },
下面给出逻辑代码,我们来好好分析。
triggerUpload
: 他使用来间接触发input标签的click事件,增大作用范围,提高用户体验。
handleFileChange
: 真正来获取文件信息。注意target.files
是一个类数组,当获取到文件信息后,来调用传入的uploadCheck
事件来验证文件。验证通过就直接发送网络请求,然后在一定的时刻,改变上传的状态。 下面我们来看看验证函数uploadCheck
。
type ErrorType = "format" | "size" | null; interface FormatSizeProps { format?: string[]; size?: number; } export const beforeUploadCheck = (file: File, checkoutObj: FormatSizeProps) => { const { format, size } = checkoutObj; //验证格式 const isFormat = format ? format.includes(file.type) : true; // 大小的判断 const isSize = size ? file.size / 1024 / 1024 < size : true; let error: ErrorType = null; if (!isFormat) { error = "format"; } if (!isSize) { error = "size"; } return { passed: isFormat && isSize, error }; };
// 调用up-loader组件时的逻辑 const uploadCheck = (file: File) => { // 验证改文件是否是jpg或者png const result = beforeUploadCheck(file, {format: ['image/jpeg', 'image/png'], size: 1}) const {passed, error} = result isPassed.value = passed if(error === "format") { CreateMessage("头像上传失败,只能上传jpg/png", "danger") } if(error === "size") { CreateMessage('上传图片大小不能超过 1Mb', "danger") } return passed }
<script lang="ts"> import { defineComponent, PropType, ref, watch } from 'vue' import axios from 'axios' type StatusProps = 'ready' | 'loading' | 'success' | 'error' export type UploadCheckFunc = (file: File) => boolean export default defineComponent({ name: 'UpLoader', emits: ['on-success', 'on-error'], props: { action: { type: String, required: true, }, uploadCheck: { type: Function as PropType<UploadCheckFunc>, required: true, }, editImg: { type: String, required: true, }, }, setup(props, context) { const inputRef = ref<null | HTMLElement>(null) const status = ref<StatusProps>('ready') const imgUrl = ref('') // 监听editImg的变化 watch( () => props.editImg, (newVal) => { status.value = 'success' imgUrl.value = newVal } ) const triggerUpload = () => { // 调用表单的click事件 if (inputRef.value) { inputRef.value.click() } } const handleFileChange = (e: Event) => { const target = e.target as HTMLInputElement if (target.files) { // console.log('files', target.files) // target.files是一个类数组 const files = Array.from(target.files) // 设置状态为正在上传 status.value = 'loading' const upLoaderFile = files[0] // 验证上传的图片,如果验证不通过则不发送请求 if (props.uploadCheck) { const fileCheck = props.uploadCheck(upLoaderFile) if (!fileCheck) { return } } const formData = new FormData() formData.append(upLoaderFile.name, upLoaderFile) console.log('formData', formData.get(upLoaderFile.name)) axios .post(props.action, formData) .then((res) => { // console.log(res) // 设置状态为上传成功 status.value = 'success' // 为了做到组件化,当成功时,发送事件 context.emit('on-success', res.data) // console.log(res) imgUrl.value = res.data.url }) .catch(() => { status.value = 'error' context.emit('on-error') }) } } return { handleFileChange, inputRef, triggerUpload, status, imgUrl, } }, }) </script>
还有一些样式
<style scoped> .btn-upload { font-size: 36px; color: #6c757d; padding: 60px 0; cursor: pointer; } .text-secondary { vertical-align: middle; font-size: 16px !important; margin-left: 10px; } .img img { width: 100%; height: 400px; } </style>