21

第八集: 从零开始实现(输入框input,textarea组件)

本集定位:
input组件是交互的一大利器, 他与用户的交流最为密切, 所以奠定了他在组件界的重要地位.
textarea也算是一种input, 如果可以的话, 本集也会一起说完, 毕竟是一个类型的, 一起学完收获会很大.
古人云:"组件不封输入框,一到面试就发慌"

一. v-model 简介
大家如果对 v-model这个指令的原理不熟悉, 建议去学习下vue源码或者看看相关的分析文章, 很重要的知识, 封装组件多了就会知道这个指令真是太棒了! 这里我就简单说一下他的规则.
1: 父级在组件上绑定了v-model时, 其实就是在往组件里面传递value变量.
2: 你的组件在props上定义value, 就可以取到值.
3: 每当组件里this.$emit("input",n)往外面发送事件的时候, 外面会把这个n值 赋值给value
4: 这么设计的原因: 你在组件里面无权改变传入的值, 这个值你想改成什么值就要吐出去, 让外面改.

好了说了这么多开始实战吧!

二. 基本结构
vue-cc-ui/src/components/Input/index.js
老套路, 统一导出为了适配vue.use的使用方式

import Input from './main/input.vue'

Input.install = function(Vue) {
  Vue.component(Input.name, Input);
};

export default Input

vue-cc-ui/src/components/Input/main/input.vue

  1. type: 这个属性比较重要, 因为要通过它来区分input与textarea, 还可以为input指定number模式.
  2. 命名依然是bem
  3. v-bind="$attrs" 解释下这个的意思, $attrs指的就是用户传进来的属性, 但是不包括我们组件内部用props接收的属性, 也不包括class style这种, 写它是为了用户可以传很多input原生的属性, 毕竟我们没必要把所有属性都做处理, 让组件保有原生功能.
  4. placeholder这种模式基本也被现代抛弃了, 针对他也可以封装成一个具体的组件, 这个属性想调整属性实在是太困难了, 更别说我们现在还需要placeholder轮播,变色,点击等等效果.
  5. vue 在行间写事件的时候, 事件对象会以$event的形式传给你使用, 其实从代码的角度来说, 是监控到你这里用了$event关键词,则把对应的参数赋值为事件对象.
<template>
  <div class="cc-input">
      <input type="text"
             class='cc-input__inner'
             :value="value"
             v-bind="$attrs"
             :placeholder="placeholder"
             @input="$emit('input',$event.target.value)"/>
    </div>
</template>
props: {
    value: [String, Number],
    placeholder: [String, Number],
    type: {
      type: String,
      default: "text"
    }
  },

三. 丰富事件

  1. 输入框有很多种事件, 他们能给用户更好的体验性.
  2. 比如在手机端, 我们项目之前遇到的问题就是, 用户点击输入框的时候, 会弹出手机键盘, 但是弹出的键盘会把输入框顶上去, 某些型号的手机会出现, 就算输入完毕点击完成, 可是输入框还是被顶上去的状态, 后来我是借助blur 与 focus事件才兼容了这写手机
  3. 很多输入框也采取节流与防抖, 比如做搜索的相关模糊匹配
  4. 有些以搜索为主的页面, 需要自动聚焦
<input :type="type"
     class='cc-input__inner'
     :value="value"
     v-bind="$attrs"
     :autofocus="autofocus" // 是否自动聚焦
     :placeholder="placeholder"
     @blur="blur($event.target.value)" 
     @input="$emit('input',$event.target.value)"
     // 这里有个小细节, 就是这个事件绑定了两个操作
     // 不仅触发聚焦事件, 还把变量focus设定为真
     @focus="$emit('focus',$event.target.value);focus=true"
     @change="$emit('change',$event.target.value)" />

四. 各种状态

  1. 禁用状态, 置灰并且把鼠标变为禁止状态 (disabled)
  2. 只读, 并不置灰, 但是也不能改 (readonly)

具体样式会在后面出来详细解释

 <input :type="type"
         :disabled="disabled" // 都是原生属性, 但要添加样式
         :readonly="readonly" // 都是原生属性, 不用添加样式
         :class="{ 'cc-input--input__disabled':disabled }" />

五. 为输入框添加状态, 并附上icon选项

  1. 很多输入框左右都要放个icon充充门面, 分为左侧与右侧icon
  2. 右侧icon允许输入文字, icon要有相应的点击效果
  3. 当组件为disabled状态的时候, icon也要相应的置灰
<template>
  <div class="cc-input"
       :class="{
           //  对每种状态给与相应的class
           'cc-input__error':error,
           'cc-input__normal':!disabled&&!normal,
           'cc-input__abnormal':normal,
           'cc-input__disabled':disabled,
       }"
       :style="{
          // 输入框有悬停放大的效果, 这里可以调节放大的角度, 下面有图演示
           'transform-origin':`${transformOrigin} 0`
       }">
      
      <nav v-if="leftIcon"
           class="cc-input__prefix is-left"
           // 返回相应的点击事件
           @click="$emit('clickLeftIcon')">
        <ccIcon :name='leftIcon'
                :color='iconColor'
               // 这里图标也要置灰
                :disabled='disabled' />
      </nav>
      <input :type="type"
             class='cc-input__inner'
             :value="value"
             v-bind="$attrs"
             :disabled="disabled"
             :readonly="readonly"
             :autofocus="autofocus"
             :placeholder="placeholder"
             :class="{ 'cc-input--input__disabled':disabled }"
             @blur="blur($event.target.value)"
             @input="$emit('input',$event.target.value)"
             @focus="$emit('focus',$event.target.value);focus=true"
             @change="$emit('change',$event.target.value)" />
      <nav v-if="icon&&!clear"
           class="cc-input__prefix is-right"
           @click="$emit('clickRightIcon')">
        <ccIcon :name="clear?'cc-close':icon"
                :color='iconColor'
                :disabled='disabled' />
        // 允许用户插入各种节点
        <slot />
      </nav>
    </div>
</template>

效果图
图片描述
图片描述
图片描述
图片描述

六. 清空按钮
现在的输入框基本都有这个清空按钮, 毕竟可以节省用的时间, 也算是个好功能,
当用户传入clear的时候会判断, 是否禁止修改, 框内是否有值, 是否是hover状态

hover事件放在父级上

 <div class="cc-input"
       @mouseenter="hovering = true"
       @mouseleave="hovering = false">
<nav v-if="showClear"
           class="cc-input__clear"
           @click="clickClear">
        <ccIcon name="cc-close"
                :disabled='disabled' />
        // 这里是为了样式的统一
        // 比如用户在右侧按钮写了很多文字
        // 那么clear按钮不好定位, 所以才写了这个站位
        <span style=" opacity: 0;">
          <slot />
        </span>
      </nav>

清除事件, 对外返回空就ok

clickClear() {
      this.$emit("input", "");
      this.$emit("change", "");
    },

判断是否显示

  computed: {
    showClear() {
      if (
        this.clear &&      // 开启功能
        !this.disabled &&  // 不是禁用
        !this.readonly &&  // 不是只读
        this.value!== '' &&  // 不是空值
        (this.hovering || this.focus) // 聚焦或者hover状态下
      )return true;
      return false;
    }
  },

vue-cc-ui/src/style/Input.scss

// 引入老四样
@import './common/var.scss';
@import './common/extend.scss';
@import './common/mixin.scss';
@import './config/index.scss';
// 这里毕竟是两个月前写的组件, 命名方面不是很好, 接下来会统一改正
@include b(input) {
    cursor: pointer;
    position: relative;
    align-items: center;
    display: inline-flex; // 直接flex会独占一行
    background-color: white;
    transition: all .3s;
    @include b(input__inner) {
        border: none;
        flex: 1;
        width: 100%;
        font-size: 1em;
        padding: 9px 16px;
        &:focus { outline: 0; } // 这样写对障碍阅读不是很友好
        @include placeholder{ // placeholder设置颜色很头疼, 请看下面
            color: $--color-input-placeholder;
        }
    };
    @include b(input__prefix) {
        align-items: center;
        display: inline-flex;
        &:hover{transform: scale(1.1)}
        @include when(left) {
            padding-left:6px;
        }
        @include when(right) {
            padding-right:6px;
        }
    };
    @include b(input__clear){
        position: absolute;
        right: 24px;
        &:hover{ animation: size .5s infinite linear;}
    };
    @include b(input--input__disabled){
        @include commonShadow(disabled);
    };
    @at-root {
        @include b(input__normal){
            @include commonShadow($--color-black);
            &:hover {
                z-index: 6;
                transform: scale(1.2);
            }
        }
        @include b(input__error){
            @include commonShadow(danger);
        }
        @include b(input__abnormal){
            @include commonShadow($--color-black);
        }
    }
}

element 这个处理做的也不错

@mixin placeholder {
  &::-webkit-input-placeholder {
    @content;
  }

  &::-moz-placeholder {
    @content;
  }

  &:-ms-input-placeholder {
    @content;
  }
}

七. textarea 文本域

基本结构

  1. 在用户type输入的是textarea时候开启
  2. 把上面的基础功能复制下来, 直接放上就可以用的
  3. textareaCalcStyle: 来设置他的宽高, 毕竟他与input不同, 可能需要很大面积
  4. 用户可以设置最大高度与最小高度
  5. 难点: 如果用户选择了自动适应高度那就麻烦了, 这个组件没有提供原生的解决方案, 第一版我是采用获取其高度进行运算得出来的, 但是及特殊的情况会有bug, 最后参考了element-ui的实现方式, 这里也让我学习到了.
<template>
  <div class="cc-input" ....>
    <template v-if="type !== 'textarea'">
      <input :type="type" ..../>
    </template>
    <textarea v-else
               // 必须获取这个dom
              ref="textarea"
              class='cc-input__inner'
              :value="value"
              v-bind="$attrs" 
              :disabled="disabled"
              :readonly="readonly"
              :autofocus="autofocus"
              :placeholder="placeholder"
              @blur="$emit('blur',$event.target.value)"
              @input="$emit('input',$event.target.value)"
              @focus="$emit('focus',$event.target.value)"
              @change="$emit('change',$event.target.value)"
              :style="{
                  width:rows,
                  height:cols,
                  ...textareaCalcStyle}"
              :class="{ 
                  'cc-input--input__disabled':disabled,
                  'cc-input--input__autosize':autosize}" />
    </div>
</template>

针对textarea获取其真实高度进行高度的动态赋值;
我来说说他的原理, 制作一个与textarea对象相同的元素, 获取他的滚动距离与高度, 计算出总的高度, 然后赋值给真正的textarea, 这里的亮点就是怎么做一个相同的dom, 因为用户可能给这个dom不同的样式, 不同的class, 各种各样的父级, 腹肌还会影响这个元素的样式;

// 个人建议, 这种生命周期函数都放在最底部, 并且要保持单一职责
  mounted() {
    this.$nextTick(this.resizeTextarea);
  }

1: 判断是不是 autosize自动高度, 并且是组件autosize
2: 用户是否设置了最大高度与最小高度的限制
3: 这个函数只负责处理是否进行计算 calcTextareaHeight 负责计算.

resizeTextarea() {
      const { autosize, type } = this;
      if (type !== "autosize" || !autosize) return;
      const minRows = autosize.min;
      const maxRows = autosize.max;
      this.textareaCalcStyle = this.calcTextareaHeight(
        this.$refs.textarea,
        minRows,
        maxRows
      );
    },

calcTextareaHeight

calcTextareaHeight(el, min, max) {
     // 也算是单例模式, 制作一个元素就行了
      if (!window.hiddenTextarea) {
        window.hiddenTextarea = document.createElement("textarea");
        document.body.appendChild(window.hiddenTextarea);
      }
      // 取得他的属性, 具体获取属性函数下面会讲
      let [boxSizing, paddingSize, borderSize] = this.calculateNodeStyling(el);
      // 滚动距离
      let height = window.hiddenTextarea.scrollHeight;
      // 是否是怪异盒模型, 进行分别的计算
      if (boxSizing === "border-box") {
        height = height + borderSize;
      } else {
        height = height - paddingSize;
      }
      // 及时清理,让用户看不到这个元素
      window.hiddenTextarea.parentNode &&
        window.hiddenTextarea.parentNode.removeChild(window.hiddenTextarea);
      window.hiddenTextarea = null;

      if (min && height < min) height = min;
      else if (max && height > max) height = max;
      return { height: height + "px" };
    }

calculateNodeStyling

calculateNodeStyling(el) {
// 模拟元素通过值的输入模拟真正的元素
      window.hiddenTextarea.value = this.value;
      const style = window.getComputedStyle(el);
      const boxSizing = style.getPropertyValue("box-sizing");
      const paddingTop = style.getPropertyValue("padding-top");
      const paddingBottom = style.getPropertyValue("padding-bottom");
      const borderTopWidth = style.getPropertyValue("border-top-width");
      const borderBottomWidth = style.getPropertyValue("border-bottom-width");
      const contextStyle = this.CONTEXT_STYLE.map(
        name => `${name}:${style.getPropertyValue(name)}`
      ).join(";");
      window.hiddenTextarea.setAttribute(
        "style",
        `${contextStyle};${this.HIDDEN_STYLE}`
      );
      return [
        boxSizing,
        parseInt(paddingBottom) + parseInt(paddingTop),
        parseInt(borderBottomWidth) + parseInt(borderTopWidth)
      ];
    },

上面 用到的this.CONTEXT_STYLE数据是样式的列表

data() {
    return {
      focus: false, // 监听输入框的聚焦失焦
      hovering: false,
      textareaCalcStyle: {},
      CONTEXT_STYLE: [
        "width",
        "font-size",
        "box-sizing",
        "line-height",
        "padding-top",
        "font-family",
        "font-weight",
        "text-indent",
        "border-width",
        "padding-left",
        "padding-right",
        "letter-spacing",
        "padding-bottom",
        "text-rendering",
        "text-transform"
      ]
    };
  },

至此才把这个组件做完, 好辛苦
图片描述
图片描述

end
如果想做到面面俱到就没有简单的组件, element上的每个组件都值得借鉴.
其实很多原理明白之后学习才能更快捷, 最近拿出时间与大家风向一下vue的实现原理, vue-router vuex等等的实现原理, 希望能对大家对我自己都有帮助吧,, 只能说学海无涯回头是岸?.

希望大家一起进步, 实现自我价值!!
下一集准备聊聊 计数器
更多好玩的效果请关注个人博客: 链接描述
github: 链接描述


lulu_up
5.7k 声望6.9k 粉丝

自信自律, 终身学习, 创业者