• 从0搭建vue3组件库: Input组件


    本篇文章将为我们的组件库添加一个新成员:Input组件。其中Input组件要实现的功能有:

    • 基础用法
    • 禁用状态
    • 尺寸大小
    • 输入长度
    • 可清空
    • 密码框
    • 带Icon的输入框
    • 文本域
    • 自适应文本高度的文本域
    • 复合型输入框

    每个功能的实现代码都做了精简,方便大家快速定位到核心逻辑,接下来就开始对这些功能进行一一的实现。

    基础用法

    首先先新建一个input.vue文件,然后写入一个最基本的input输入框

    
    

    然后在我们的 vue 项目examples下的app.vue引入Input组件

    
    <script lang="ts" setup>
    import { Input } from "kitty-ui";
    script>
    

    此时页面上便出现了原生的输入框,所以需要对这个输入框进行样式的添加,在input.vue同级新建style/index.less,Input样式便写在这里

    .k-input {
      font-size: 14px;
      display: inline-block;
      position: relative;
    
      .k-input__inner {
        background-color: #fff;
        border-radius: 4px;
        border: 1px solid #dcdfe6;
        box-sizing: border-box;
        color: #606266;
        display: inline-block;
        font-size: inherit;
        height: 40px;
        line-height: 40px;
        outline: none;
        padding: 0 15px;
        width: 100%;
        &::placeholder {
          color: #c2c2ca;
        }
    
        &:hover {
          border: 1px solid #c0c4cc;
        }
    
        &:focus {
          border: 1px solid #409eff;
        }
      }
    }
    

    image.png

    接下来要实现Input组件的核心功能:双向数据绑定。当我们在 vue 中使用input输入框的时候,我们可以直接使用v-model来实现双向数据绑定,v-model其实就是value @input结合的语法糖。而在 vue3 组件中使用v-model则表示的是modelValue @update:modelValue的语法糖。比如Input组件为例

    <Input v-model="tel" />
    

    其实就是

    <Input :modelValue="tel" @update:modelValue="tel = $event" />
    

    所以在input.vue中我们就可以根据这个来实现Input组件的双向数据绑定,这里我们使用setup语法

    
    
    

    GIF333.gif

    到这里基础用法就完成了,接下来开始实现禁用状态

    禁用状态

    这个比较简单,只要根据propsdisabled来赋予禁用类名即可

    
    
    

    然后给is-disabled写些样式

    //...
    
    .k-input.is-disabled {
      .k-input__inner {
        background-color: #f5f7fa;
        border-color: #e4e7ed;
        color: #c0c4cc;
        cursor: not-allowed;
        &::placeholder {
          color: #c3c4cc;
        }
      }
    }
    

    image.png

    尺寸

    按钮尺寸包括medium,small,mini,不传则是默认尺寸。同样的根据propssize来赋予不同类名

    const styleClass = computed(() => {
      return {
        "is-disabled": inputProps.disabled,
        [`k-input--${inputProps.size}`]: inputProps.size,
      };
    });
    

    然后写这三个类名的不同样式

    //...
    .k-input.k-input--medium {
      .k-input__inner {
        height: 36px;
        &::placeholder {
          font-size: 15px;
        }
      }
    }
    
    .k-input.k-input--small {
      .k-input__inner {
        height: 32px;
    
        &::placeholder {
          font-size: 14px;
        }
      }
    }
    
    .k-input.k-input--mini {
      .k-input__inner {
        height: 28px;
    
        &::placeholder {
          font-size: 13px;
        }
      }
    }
    

    继承原生 input 属性

    原生的inputtype,placeholder等属性,这里可以使用 vue3 中的useAttrs来实现props穿透.子组件可以通过v-bindprops绑定

    
    <script lang="ts" setup>
    //...
    
    const attrs = useAttrs();
    script>
    

    可清空

    通过clearable属性、Input的值是否为空以及是否鼠标是否移入来判断是否需要显示可清空图标。图标则使用组件库的Icon组件

    
    <script setup lang="ts">
    //...
    import Icon from "../icon/index";
    //...
    //双向数据绑定&接收属性
    type InputProps = {
      modelValue?: string | number;
      disabled?: boolean;
      size?: string;
      clearable?: boolean;
    };
    //...
    const isClearAbled = ref(false);
    const changeInputVal = (event: Event) => {
      //可清除clearable
      (event.target as HTMLInputElement).value
        ? (isClearAbled.value = true)
        : (isClearAbled.value = false);
    
      inputEmits("update:modelValue", (event.target as HTMLInputElement).value);
    };
    
    //清除input value
    const isEnter = ref(true);
    const clearValue = () => {
      inputEmits("update:modelValue", "");
    };
    script>
    

    清除图标部分 css 样式

    .k-input__suffix {
      position: absolute;
      right: 10px;
      height: 100%;
      top: 0;
      display: flex;
      align-items: center;
      cursor: pointer;
      color: #c0c4cc;
    }
    

    image.png

    密码框 show-password

    通过传入show-password属性可以得到一个可切换显示隐藏的密码框。这里要注意的是如果传了clearable则不会显示切换显示隐藏的图标

    
    <script setup lang="ts">
    //...
    const attrs = useAttrs();
    
    //...
    
    //显示隐藏密码框 showPassword
    const ipt = ref();
    Promise.resolve().then(() => {
      if (inputProps.showPassword) {
        ipt.value.type = "password";
      }
    });
    const eyeIcon = ref("browse");
    const isShowEye = computed(() => {
      return (
        inputProps.showPassword && inputProps.modelValue && !inputProps.clearable
      );
    });
    const changeType = () => {
      if (ipt.value.type === "password") {
        eyeIcon.value = "eye-close";
        ipt.value.type = attrs.type || "text";
        return;
      }
      ipt.value.type = "password";
      eyeIcon.value = "browse";
    };
    script>
    

    这里是通过获取input元素,然后通过它的type属性进行切换,其中browseeye-close分别是Icon组件中眼睛开与闭,效果如下

    password.gif

    带 Icon 的输入框

    通过prefix-iconsuffix-icon 属性可以为Input组件添加首尾图标。

    可以通过计算属性判断出是否显示首尾图标,防止和前面的clearableshow-password冲突.这里代码做了

    
    <script setup lang="ts">
    //...
    type InputProps = {
      prefixIcon?: string;
      suffixIcon?: string;
    };
    
    //...
    
    //带Icon输入框
    const isShowSuffixIcon = computed(() => {
      return (
        inputProps.suffixIcon && !inputProps.clearable && !inputProps.showPassword
      );
    });
    const isShowPrefixIcon = computed(() => {
      return inputProps.prefixIcon;
    });
    script>
    

    相关样式部分

    .k-input__suffix,
    .k-input__prefix {
      position: absolute;
      right: 10px;
      height: 100%;
      top: 0;
      display: flex;
      align-items: center;
      cursor: pointer;
      color: #c0c4cc;
      font-size: 15px;
    }
    
    .no-cursor {
      cursor: default;
    }
    
    .k-input--prefix.k-input__inner {
      padding-left: 30px;
    }
    
    .k-input__prefix {
      position: absolute;
      width: 20px;
      cursor: default;
      left: 10px;
    }
    

    app.vue中使用效果如下

    
    <script lang="ts" setup>
    import { Input } from "kitty-ui";
    import { ref } from "vue";
    const tel = ref("");
    script>
    <style lang="less">
    .input-demo {
      width: 200px;
    }
    style>
    

    image.png

    文本域

    type属性的值指定为textarea即可展示文本域模式。它绑定的事件以及属性和input基本一样

    
    

    样式基本也就是focus,hover改变 border 颜色

    .k-textarea {
      width: 100%;
    
      .k-textarea__inner {
        display: block;
        padding: 5px 15px;
        line-height: 1.5;
        box-sizing: border-box;
        width: 100%;
        font-size: inherit;
        color: #606266;
        background-color: #fff;
        background-image: none;
        border: 1px solid #dcdfe6;
        border-radius: 4px;
    
        &::placeholder {
          color: #c2c2ca;
        }
    
        &:hover {
          border: 1px solid #c0c4cc;
        }
    
        &:focus {
          outline: none;
          border: 1px solid #409eff;
        }
      }
    }
    

    image.png

    可自适应高度文本域

    组件可以通过接收autosize属性来开启自适应高度,同时autosize也可以传对象形式来指定最小和最大行高

    type AutosizeObj = {
        minRows?: number
        maxRows?: number
    }
    type InputProps = {
        autosize?: boolean | AutosizeObj
    }
    

    具体实现原理是通过监听输入框值的变化来调整textarea的样式,其中用到了一些原生的方法譬如window.getComputedStyle(获取原生css对象),getPropertyValue(获取css属性值)等,所以原生js忘记的可以复习一下

    ...
    const textareaStyle = ref()
    const textarea = shallowRef<HTMLTextAreaElement>()
    watch(() => inputProps.modelValue, () => {
        if (attrs.type === 'textarea' && inputProps.autosize) {
            const minRows = isObject(inputProps.autosize) ? (inputProps.autosize as AutosizeObj).minRows : undefined
            const maxRows = isObject(inputProps.autosize) ? (inputProps.autosize as AutosizeObj).maxRows : undefined
            nextTick(() => {
                textareaStyle.value = calcTextareaHeight(textarea.value!, minRows, maxRows)
            })
        }
    
    }, { immediate: true })
    

    其中calcTextareaHeight

    const isNumber = (val: any): boolean => {
        return typeof val === 'number'
    }
    //隐藏的元素
    let hiddenTextarea: HTMLTextAreaElement | undefined = undefined
    
    //隐藏元素样式
    const HIDDEN_STYLE = `
      height:0 !important;
      visibility:hidden !important;
      overflow:hidden !important;
      position:absolute !important;
      z-index:-1000 !important;
      top:0 !important;
      right:0 !important;
    `
    
    const CONTEXT_STYLE = [
        'letter-spacing',
        'line-height',
        'padding-top',
        'padding-bottom',
        'font-family',
        'font-weight',
        'font-size',
        'text-rendering',
        'text-transform',
        'width',
        'text-indent',
        'padding-left',
        'padding-right',
        'border-width',
        'box-sizing',
    ]
    
    type NodeStyle = {
        contextStyle: string
        boxSizing: string
        paddingSize: number
        borderSize: number
    }
    
    type TextAreaHeight = {
        height: string
        minHeight?: string
    }
    
    function calculateNodeStyling(targetElement: Element): NodeStyle {
      //获取实际textarea样式返回并赋值给隐藏的textarea
        const style = window.getComputedStyle(targetElement)
    
        const boxSizing = style.getPropertyValue('box-sizing')
    
        const paddingSize =
            Number.parseFloat(style.getPropertyValue('padding-bottom')) +
            Number.parseFloat(style.getPropertyValue('padding-top'))
    
        const borderSize =
            Number.parseFloat(style.getPropertyValue('border-bottom-width')) +
            Number.parseFloat(style.getPropertyValue('border-top-width'))
    
        const contextStyle = CONTEXT_STYLE.map(
            (name) => `${name}:${style.getPropertyValue(name)}`
        ).join(';')
    
        return { contextStyle, paddingSize, borderSize, boxSizing }
    }
    
    export function calcTextareaHeight(
        targetElement: HTMLTextAreaElement,
        minRows = 1,
        maxRows?: number
    ): TextAreaHeight {
        if (!hiddenTextarea) {
          //创建隐藏的textarea
            hiddenTextarea = document.createElement('textarea')
            document.body.appendChild(hiddenTextarea)
        }
        //给隐藏的teatarea赋予实际textarea的样式以及值(value)
        const { paddingSize, borderSize, boxSizing, contextStyle } =
            calculateNodeStyling(targetElement)
        hiddenTextarea.setAttribute('style', `${contextStyle};${HIDDEN_STYLE}`)
        hiddenTextarea.value = targetElement.value || targetElement.placeholder || ''
        //隐藏textarea整个高度,包括内边距padding,border
        let height = hiddenTextarea.scrollHeight
        const result = {} as TextAreaHeight
        //判断boxSizing,返回实际高度
        if (boxSizing === 'border-box') {
            height = height + borderSize
        } else if (boxSizing === 'content-box') {
            height = height - paddingSize
        }
    
        hiddenTextarea.value = ''
        //计算单行高度
        const singleRowHeight = hiddenTextarea.scrollHeight - paddingSize
    
        if (isNumber(minRows)) {
            let minHeight = singleRowHeight * minRows
            if (boxSizing === 'border-box') {
                minHeight = minHeight + paddingSize + borderSize
            }
            height = Math.max(minHeight, height)
            result.minHeight = `${minHeight}px`
        }
        if (isNumber(maxRows)) {
            let maxHeight = singleRowHeight * maxRows!
            if (boxSizing === 'border-box') {
                maxHeight = maxHeight + paddingSize + borderSize
            }
            height = Math.min(maxHeight, height)
        }
        result.height = `${height}px`
        hiddenTextarea.parentNode?.removeChild(hiddenTextarea)
        hiddenTextarea = undefined
    
        return result
    }
    
    

    这里的逻辑稍微复杂一点,大致就是创建一个隐藏的textarea,然后每次当输入框值发生变化时,将它的value赋值为组件的textareavalue,最后计算出这个隐藏的textareascrollHeight以及其它padding之类的值并作为高度返回赋值给组件中的textarea

    最后在app.vue中使用

    
    

    GIFtextarea.gif

    复合型输入框

    我们可以使用复合型输入框来前置或者后置我们的元素,如下所示

    image.png

    这里我们借助 vue3 中的slot进行实现,其中用到了useSlots来判断用户使用了哪个插槽,从而展示不同样式

    import { useSlots } from "vue";
    
    //复合输入框
    const slots = useSlots();
    

    同时template中接收前后两个插槽

    
    <script setup lang="ts">
    import { useSlots } from "vue";
    const styleClass = computed(() => {
      return {
        ["k-input-group k-input-prepend"]: slots.prepend,
        ["k-input-group k-input-append"]: slots.append,
      };
    });
    //复合输入框
    const slots = useSlots();
    script>
    

    最后给两个插槽写上样式就实现了复合型输入框

    .k-input.k-input-group.k-input-append,
    .k-input.k-input-group.k-input-prepend {
      line-height: normal;
      display: inline-table;
      width: 100%;
      border-collapse: separate;
      border-spacing: 0;
    
      .k-input__inner {
        border-radius: 0 4px 4px 0;
      }
    
      //复合输入框
      .k-input__prepend,
      .k-input__append {
        background-color: #f5f7fa;
        color: #909399;
        vertical-align: middle;
        display: table-cell;
        position: relative;
        border: 1px solid #dcdfe6;
        border-radius: 4 0px 0px 4px;
        padding: 0 20px;
        width: 1px;
        white-space: nowrap;
      }
    
      .k-input__append {
        border-radius: 0 4px 4px 0px;
      }
    }
    
    .k-input.k-input-group.k-input-append {
      .k-input__inner {
        border-top-right-radius: 0px;
        border-bottom-right-radius: 0px;
      }
    }
    

    app.vue中使用

    
    

    总结

    一个看似简单的Input组件其实包含的内容还是很多的,做完之后会发现对自己很多地方都有提升和帮助。

    如果你对vue3组件库开发也感兴趣的话可以关注我,组件库的所有实现细节都在以往文章里,包括环境搭建自动打包发布文档搭建vitest单元测试等等。

    如果这篇文章对你有所帮助动动指头点个赞👍吧~

    源码地址

    kitty-ui: 一个使用Vite+Ts搭建的Vue3组件库

  • 相关阅读:
    Java基础知识
    Go语言数据结构(二)堆/优先队列
    Android AOP二三事:使用APT仿写ButterKnife
    C++笔记 - - list的模拟实现和使用
    静态方法、类方法
    Spring的web集成 (Spring监听器)
    java高校多媒体设备运维管理系统服务端计算机毕业设计MyBatis+系统+LW文档+源码+调试部署
    肩胛骨筋膜炎怎么治疗最有效
    手写Redux(二):实现React-redux
    卡特兰数(Catalan)的应用场景
  • 原文地址:https://www.cnblogs.com/zdsdididi/p/16880256.html