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
复制代码

未完待续...

猜你喜欢

转载自juejin.im/post/7033704638356717599