vue3+ts+bootstrap对一些组件的封装

简介: vue3+ts+bootstrap对一些组件的封装

虽然现在又很多组件库,方便了我们的开发。但是自己对于组件的封装,对组件的认识也不能少。下面我们就来介绍一些常用组件的封装。


为了方便,我们的样式都使用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-inputvalidate-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组件。


这时候,你就需要了解一下createAppAPI了。请访问


  • 该函数接收一个根组件选项对象作为第一个参数


  • 使用第二个参数,我们可以将根 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>


相关文章
|
30天前
|
JavaScript
在 Vue 中处理组件选项与 Mixin 选项冲突的详细解决方案
【10月更文挑战第18天】通过以上的分析和探讨,相信你对在 Vue 中使用 Mixin 时遇到组件选项与 Mixin 选项冲突的解决方法有了更深入的理解。在实际开发中,要根据具体情况灵活选择合适的解决方案,以确保代码的质量和可维护性。
85 7
|
12天前
|
存储 JavaScript 开发者
Vue 组件间通信的最佳实践
本文总结了 Vue.js 中组件间通信的多种方法,包括 props、事件、Vuex 状态管理等,帮助开发者选择最适合项目需求的通信方式,提高开发效率和代码可维护性。
|
12天前
|
存储 JavaScript
Vue 组件间如何通信
Vue组件间通信是指在Vue应用中,不同组件之间传递数据和事件的方法。常用的方式有:props、自定义事件、$emit、$attrs、$refs、provide/inject、Vuex等。掌握这些方法可以实现父子组件、兄弟组件及跨级组件间的高效通信。
|
29天前
|
缓存 JavaScript UED
Vue 的动态组件与 keep-alive
【10月更文挑战第19天】总的来说,动态组件和 `keep-alive` 是 Vue.js 中非常实用的特性,它们为我们提供了更灵活和高效的组件管理方式,使我们能够更好地构建复杂的应用界面。深入理解和掌握它们,以便在实际开发中能够充分发挥它们的优势,提升我们的开发效率和应用性能。
45 18
|
24天前
|
缓存 JavaScript UED
Vue 中实现组件的懒加载
【10月更文挑战第23天】组件的懒加载是 Vue 应用中提高性能的重要手段之一。通过合理运用动态导入、路由配置等方式,可以实现组件的按需加载,减少资源浪费,提高应用的响应速度和用户体验。在实际应用中,需要根据具体情况选择合适的懒加载方式,并结合性能优化的其他措施,以打造更高效、更优质的 Vue 应用。
|
28天前
|
前端开发 UED
vue3知识点:Suspense组件
vue3知识点:Suspense组件
33 4
|
27天前
|
JavaScript 前端开发 测试技术
组件化开发:创建可重用的Vue组件
【10月更文挑战第21天】组件化开发:创建可重用的Vue组件
25 1
|
28天前
|
JavaScript 前端开发 Java
《vue3第五章》新的组件,包含:Fragment、Teleport、Suspense
《vue3第五章》新的组件,包含:Fragment、Teleport、Suspense
32 2
|
28天前
|
Java
vue3知识点:Teleport组件
vue3知识点:Teleport组件
27 1
|
29天前
|
JavaScript 前端开发
vue全局公共组件自动引入并注册,开发效率直接起飞!
【10月更文挑战第14天】vue全局公共组件自动引入并注册,开发效率直接起飞!
50 1
下一篇
无影云桌面