Element-ui 之 Input 组件源码分析

此篇文章将分析 Element-uiInput 组件的源码。从 Input 组件提供的功能、属性、插槽、事件、方法来一步一步的实现一个完整的 Input 组件。

一. Input 组件的功能

(1)可禁用。

(2)可清空。

(3)可以作为密码框。

(4)可以在前面或后面增加 icon

(5)可以调整尺寸。

(6)可以限制输入文字的长度。

二. Input 组件提供的属性、插槽、事件、方法

由于 Input 组件提供的属性较多,需要先将 Input 组件提供的属性进行分类,一类一类的去讲解,最后完成整个 Input 组件。首先,来将 Input 组件的属性进行分类:

2.1 属性

类别一:绑定值属性

序号参数说明类型可选值默认值
1value /v-model绑定值String/Number

类别二:原生属性

序号参数说明类型可选值默认值
1type类型Stringtext,textarea 和其他原生 input 的 type 值text
2maxlength原生属性,最大输入长度number
3minlength原生属性,最小输入长度number
4placeholder输入框占位文本string
5disabled禁用booleanfalse
6autocomplete原生属性,自动补全stringon, offoff
7name原生属性string
8readonly原生属性,是否只读boolean
9max原生属性,设置最大值
10min原生属性,设置最小值
11step原生属性,设置输入字段的合法数字间隔
12autofocus原生属性,自动获取焦点booleantrue, falsefalse

类别三:可清空属性

序号参数说明类型可选值默认值
1clearable是否可清空booleanfalse

类别四:显示密码图标属性

序号参数说明类型可选值默认值
1show-password是否显示切换密码图标booleanfalse

类别五:字数统计属性

序号参数说明类型可选值默认值
1show-word-limit是否显示输入字数统计,只在 type=text 或 type=textarea 时有效booleanfalse

类别六:尺寸与图标属性

序号参数说明类型可选值默认值
1size输入框尺寸,只在 type != textarea 时有效stringmedium / small / mini
2prefix-icon输入框头部图标string
3suffix-icon输入框尾部图标string

类别七:屏幕阅读器相关属性

序号参数说明类型可选值默认值
1label输入框关联的label文字string
2tabindex输入框的tabindexstring

2.2 插槽

序号name说明
1prefix输入框头部内容,只对 type=text 有效
2suffix输入框尾部内容,只对 type=text 有效
3prepend输入框前置内容,只对 type=text 有效
4append输入框后置内容,只对 type=text 有效

2.3 事件

序号事件说明回调参数
1blur在 Input 失去焦点时触发(event: Event)
2focus在 Input 获得焦点时触发(event: Event)
3change仅在输入框失去焦点或用户按下回车时触发(value: string
4input在 Input 值改变时触发(value: string
5clear在点击由 clearable 属性生成的清空按钮时触发

2.4 方法

序号方法名说明
1focus使 input 获取焦点
2blur使 input 失去焦点
3select选中 input 中的文字

三. Input 组件的具体实现

3.1 注册组件并实现基本HTML结构

3.1.1 组件的注册

components 文件夹里面新建一个 input 文件夹,里面新建 input.vue 文件。设置组件的 namecomponentName 都为 ElInput

main.js 文件中引入该组件,使用 Vue.component 完成组件的注册。

import Input from './components/input/input';

const components = [
  ...
  Input
]

components.forEach(component => {
  Vue.component(component.name, component);
});

3.1.2 Input 组件的基本 template 部分

基本的 template 部分是一个 div 包裹一个 input,之后在此基础上增加 input 的功能。

<template>
  <div class="el-input">
    <input class="el-input__inner" />
  </div>
</template>
<script>
export default {
  name: 'ElInput',

  componentName: 'ElInput',
}
</script>

3.1.3 引入组件部分

新建一个文件,文件内部引入 input 组件。

<template>
  <div>
    <el-input></el-input>
  </div>
</template>
<script>
export default {
  
}
</script>

3.2 实现每一类属性的功能

3.2.1 绑定值属性 value/v-model

实现步骤:

  1. 在引入 input 组件时绑定 v-model
<template>
  <div>
    <el-input v-model="val"></el-input>
  </div>
</template>
<script>
export default {
  data() {
    return {
      val: '输入框值'
    }
  }
}
</script>
  1. 组件内部接收 value 值,并且设置计算属性 nativeInputValue,监听 value 值的改变来给 input 赋值。
<template>
  <div class="el-input">
    <input
      class="el-input__inner"
      ref="input"
      @input="handleInput"
    />
  </div>
</template>
<script>
export default {
  props: {
    value: [String, Number],
  },
  computed: {
    // 监听 value 值的变化,如果 value 为 null 或者 undefined,则返回空,否则返回 value 转化为字符串后的值
    nativeInputValue() {
      return this.value === null || this.value === undefined ? '' : String(this.value);
    }
  },
  watch: {
    // 监听 value 值的变化,给 input 赋值
    nativeInputValue() {
      this.setNativeInputValue();
    }
  },
  methods: {
    // 获取 input
    getInput() {
      return this.$refs.input || this.$refs.textarea;
    },
    // 设置 input 元素的值
    setNativeInputValue() {
      // 获取到 input 元素
      const input = this.getInput();
      if (!input) return;
      // 如果 input 之前的 value 值与改变后的值相同,则 return
      if (input.value === this.nativeInputValue) return;
      // 将 input 的值赋值为最新的值
      input.value = this.nativeInputValue;
    },
    // 触发 input 事件
    handleInput(event) {
      // 解决 IE 浏览器中 Input 初始化自动执行的问题
      if (event.target.value === this.nativeInputValue) return;
      // 触发父级的 input 事件
      this.$emit('input', event.target.value);
      // 给 input 重新赋值
      this.$nextTick(this.setNativeInputValue);
    }
  },
  mounted() {
    // 设置 input 元素的值
    this.setNativeInputValue();
  }
}
</script>
  1. 处理拼音输入时 input 值的变化问题。

前两步绑定 v-model 值还存在一个小的问题,如果是输入的拼音,引入组件绑定 input 方法拿到的 value 是包含在输入法输入的拼音的那部分的,所以在还未输入到输入框时,不能设置 inputvalue 值,也不能触发父级的 input 方法。

通过设置变量 isComposing 来去判断是否是输入法输入(比如说输入拼音),如果是输入拼音,则不触发 input,如果拼音已经输入完成,则触发 input

引入 compositionstart、compositionupdate、compositionend 事件
以输入拼音为例,详细介绍这三个事件的触发时机:
(1)compositionstart:输入法编辑器开始新的输入合成时(开始输入拼音时)触发。
(2)compositionupdate:组合输入更新时(每输入一下拼音时)都会触发。
(3)compositionend:组合输入结束时(拼音输入结束,关闭中文输入法时)触发。

<template>
  <div class="el-input">
    <input
      ...
      @compositionstart="handleCompositionStart"
      @compositionupdate="handleCompositionUpdate"
      @compositionend="handleCompositionEnd"
      @input="handleInput"
    />
  </div>
</template>
<script>
export default {
  data() {
    return {
      isComposing: false, // 是否处于拼音输入状态
    }
  },
  methods: {
    // 输入法编辑器开始新的输入合成时(开始输入拼音时)触发
    handleCompositionStart(event) {
      // 触发父级的 compositionstart 事件
      this.$emit('compositionstart', event);
      // 将 isComposing 设置为 true,说明处于拼音输入的状态
      this.isComposing = true;
    },
    // 组合输入更新时(每输入一下拼音时)都会触发
    handleCompositionUpdate(event) {
      // 触发父级的 compositionupdate 事件
      this.$emit('compositionupdate', event);
      // 将 isComposing 设置为 true,说明处于拼音输入的状态
      this.isComposing = true;
    },
    // 组合输入结束时(拼音输入结束,关闭中文输入法时)触发
    handleCompositionEnd(event) {
      // 触发父级的 compositionend 事件
      this.$emit('compositionend', event);
      if (this.isComposing) {
        // 将 isComposing 设置为 false,说明此时已经不是拼音输入的状态
        this.isComposing = false;
        // 触发 handleInput 方法,给 input 赋值
        this.handleInput(event);
      }
    },
    handleInput(event) {
      // 如果正在输入拼音,直接 return
      if (this.isComposing) return;
      ...
    }
  }
}
</script>

其中 handleCompositionUpdate 在 element-ui 源代码中有一个是否是韩语的判断,如果是韩语则设置为 false,不是设置为 true,这里就不处理韩语了,统一在输入法键盘输入时设置为 true

3.2.2 原生属性

inheritAttrs 属性以及 v-bind="$attrs"

默认情况下父作用域的不被认做 props 的属性绑定将会作为普通的 HTML 属性应用在子组件的根元素上。通过设置 inheritAttrsfalse,将不会被默认绑定到子组件的根元素上。通过属性 $attrs 可以获取到这些属性显性的绑定到非根元素上。

inheritAttrs 设置为 false 不会影响到 classstyle 的绑定。

实现步骤:

通过设置 inheritAttrs: false 避免不被认做 props 的属性默认被绑定在 input 组件的根元素上。然后在 input 元素上使用 v-bind="$attrs" 来绑定传入的不被认做 props 的属性。

<template>
  <div class="el-input">
    <input
      ...
      v-bind="$attrs"
      ...
    />
  </div>
</template>
<script>
export default {
  inheritAttrs: false,
}
</script>

通过这样绑定后,可以不做特殊处理直接去绑定的原生属性有:maxlength、minlength、placeholder、autocomplete、name、max、min、step、autofocus

需要特殊处理的原生属性有:
(1)type:需要根据 type 去判断是 text 还是 textarea 去显示不同的控件。
(2)disabled:由于 input 可以放在 form 表单里面,所以需要单独获取 disabled 属性,然后设置计算属性去监听 inputdisabled 的变化和 form 表单的 disabled 变化,优先获取 inputdisabled 状态。
(3)readonlyreadonly 属性需要和“显示清空”、“显示密码框”、“计数功能”相互作用,所以需要单独用 props 接收 readonly 属性。

3.2.2.1 原生属性 —— type 的处理

实现步骤:

  1. props 接收 type 属性,并且根据 type 属性去展示不同的样式 class,在 input 元素中绑定 type 属性。
<template>
  <div :class="[
    type === 'textarea' ? 'el-textarea' : 'el-input',
    ]"
  >
    <template v-if="type !== 'textarea'">
      <input :type="type" />
    </template>
  </div>
</template>
<script>
export default {
  props: {
    type: {
      type: String,
      default: 'text'
    },
  }
}
</script>
  1. 监听 type 的变化,如果 type 变化了执行 setNativeInputValue 方法,重新给组件赋值。
watch: {
  type() {
    this.$nextTick(() => {
      this.setNativeInputValue();
    });
  }
}
3.2.2.2 原生属性 —— disabled 的处理

props 接收 disabled 属性,然后设置计算属性 inputDisabled 变量监听 disabled 的改变(后续还会监听 form 表单的 disabled 属性的改变)。然后将 disabled 属性和其样式 class 绑定在元素上。

<template>
  <div :class="[
    {
      'is-disabled': inputDisabled,
    }
    ]"
  >
    <template v-if="type !== 'textarea'">
      <input :disabled="inputDisabled" ... />
    </template>
  </div>
</template>
<script>
export default {
  props: {
    disabled: Boolean,
  },
  computed: {
    // 监听 disabled 属性的变化
    inputDisabled() {
      return this.disabled;
    },
  }
}
</script>
3.2.2.3 原生属性 —— readonly 的处理

接收 readonly 属性,待后续需要时会用到。

<script>
export default {
  readonly: Boolean,
}
</script>

3.2.3 可清空属性

3.2.3.1 可清空图标的显示情况

可清空图标是在输入框有值,并且输入框聚焦或者鼠标移入输入框的情况下显示的。点击清空图标可以讲输入框内容清空。

3.2.3.2 可清空功能的实现步骤
  1. 设置两个变量 hovering、focused 分别监听 mouseenter/mouseleavefocus/blur 属性,为显示可清空图标做准备。
<template>
  <div ...
    @mouseenter="hovering = true"
    @mouseleave="hovering = false"
  >
    <template v-if="type !== 'textarea'">
      <input ...
        @focus="handleFocus"
        @blur="handleBlur"
      />
    </template>
  </div>
</template>
<script>
export default {
  data() {
    return {
      hovering: false,
      focused: false,
      ...
    }
  },
  methods: {
    handleBlur(event) {
      this.focused = false;
      this.$emit('blur', event);
    },
    handleFocus(event) {
      this.focused = true;
      this.$emit('focus', event);
    },    
  },
}
</script>
  1. 接收可清空属性,并且设置计算属性 判断显示清空图标的条件。
<script>
export default {
  props: {
    clearable: {
      type: Boolean,
      default: false
    },
  },
  computed: {
    showClear() {
      // 在可清空属性设置为 true ,且 input 有值且不为禁用或只读状态,且鼠标移入输入框内部或输入框聚焦,则展示可清空图标
      return this.clearable &&
        !this.inputDisabled &&
        !this.readonly &&
        this.nativeInputValue &&
        (this.focused || this.hovering);
    },
  }
}
</script>
  1. 将清空图标放在 input 组件的后置内容中。
<template>
  <div>
    <template v-if="type !== 'textarea'">
      <input .../>
      <span
        class="el-input__suffix"
        v-if="getSuffixVisible()">
        <!-- 方法 getSuffixVisible 来判断后置内容是否显示 -->
        <span class="el-input__suffix-inner">
          <i v-if="showClear"
            class="el-input__icon el-icon-circle-close el-input__clear"
          ></i>
        </span>
      </span>
    </template>
  </div>
</template>

方法 getSuffixVisible

methods: {
  // 目前只判断 showClear 是否为 true,后续增加密码图标等功能时还会继续增加判断
  getSuffixVisible() {
    return this.showClear;
  }
}

将后置内容的 class 加在最外层的 div 上面,这个 class 是让 inner 部分的 padding-right 加宽一些:

<div :class="[
    ...
    {
      ...
      'el-input--suffix': clearable
    }
    ]"
>
</div>
  1. 点击清空按钮执行清空功能。
    给清空按钮绑定 click 事件,执行 clear 方法:
<i v-if="showClear"
  class="el-input__icon el-icon-circle-close el-input__clear"
  @click="clear"
></i>
clear() {
  // 触发父级的 input 事件,并且将值传为空,由于父级用 v-model 语法糖绑定值,则可清空组件的值
  this.$emit('input', '');
  // 触发父级的 change 事件
  this.$emit('change', '');
  // 触发父级的 clear 事件
  this.$emit('clear');
},

此时点击清空后,输入框中的内容就被清空了,但是还有一个问题,输入框在清空后失去焦点了,往往用户的操作习惯是清空后继续输入,所以不能让输入框失去焦点。

所以需要在清空按钮处增加 @mousedown.prevent,因为 mousedown 事件默认行为是除了点击对象外,所有焦点对象失去焦点,只要增加 @mousedown.prevent,输入框就不会失去焦点了。

<!-- @mousedown.prevent 用于阻止输入框失去焦点 -->
<i v-if="showClear"
   class="el-input__icon el-icon-circle-close el-input__clear"
   @mousedown.prevent
   @click="clear"
></i>

3.2.4 显示密码图标属性

3.2.4.1 密码图标的作用及显示情况
  1. 作用:将用户输入的内容显示成小圆点的密文形式。
  2. 密码图标的显示情况:当输入框设置了 show-password 属性,并且输入框有值或者输入框在聚焦的情况下,显示密码图标。
3.2.4.2 密码图标功能的实现步骤
  1. 显示密码图标。
    props 接收 show-password 属性,并且设置计算属性 showPwdVisible 判断密码图标的显示条件,再将密码图标放在组件的后置内容中。
props: {
  showPassword: {
    type: Boolean,
    default: false
  },
}

computed: {
  showPwdVisible() {
    // 当显示密码图标属性被设置为 true 时,且输入框不属于禁用、只读状态,且输入框有值或者输入框在聚焦的情况下,显示密码图标
    return this.showPassword &&
      !this.inputDisabled &&
      !this.readonly &&
      (!!this.nativeInputValue || this.focused);
  },
}

将密码图标放在组件的后置内容中,并且修改 getSuffixVisible 方法,将密码图标显示出来。

<span
  class="el-input__suffix"
  v-if="getSuffixVisible()"
>
  <span class="el-input__suffix-inner">
    ...
    <i v-if="showPwdVisible"
        class="el-input__icon el-icon-view el-input__clear"
    ></i>
  </span>
</span>

getSuffixVisible 方法增加 showPassword 的判断:

getSuffixVisible() {
  return this.showClear || this.showPassword;
}

el-input--suffixclass 加上 showPassword 的判断:

<div :class="[
  ...
  {
    ...
    'el-input--suffix': clearable || showPassword
  }
  ]"
>
</div>
  1. 点击密码图标进行明文和密文的切换。
    设置 passwordVisible 变量,作为明文和密文的类型判断。通过改变 input 元素的 type 属性(显示明文时 type=text,显示密码时 type=password)。点击密码图标时切换变量 passwordVisible
data() {
  return {
    passwordVisible: false
  }
},

methods: {
  focus() {
    this.getInput().focus();
  },
  handlePasswordVisible() {
    this.passwordVisible = !this.passwordVisible;
    this.$nextTick(() => {
      this.focus();
    });
  },
}

修改绑定的 type 属性,增加 passwordVisible 的判断,在 showPasswordtrue 的情况下,判断是否已经展示了密码,如果是明文展示的,则 typetext,否则为 password

<input
  :type="showPassword ? (passwordVisible ? 'text': 'password') : type"
/>

3.2.5 字数统计属性

3.2.5.1 字数统计属性实现思路
  1. 接收传入的 show-word-limit 属性。
  2. 判断字数统计属性在何种情况下显示:
    (1)show-word-limit 为 true
    (2)传入了 max-length
    (3)是 text 类型或者 textarea 类型
    (4)不是禁用或只读状态
    (5)show-password 不为 true,也就是不是密码框
  3. 设置计算属性 upperLimit 来获取 maxlength 的值。
  4. 设置计算属性 textLength 来实时获取 value 值的长度。
  5. 设置计算属性 inputExceed 判断是否超出字数。
  6. 页面显示字数统计样式,超出最大字数的样式。
3.2.5.2 字数统计属性代码实现
// 接收传入的 show-word-limit 属性
props: {
  showWordLimit: {
      type: Boolean,
      default: false
    },
}
computed: {
  // 判断字数统计属性在何种情况下显示
  isWordLimitVisible() {
    // 当showWordLimit属性为true,且有最大字数限制,且类型为 text 或 textarea,且不为禁用、只读、密码的状态时,显示字数统计
    return this.showWordLimit &&
      this.$attrs.maxlength &&
      (this.type === 'text' || this.type === 'textarea') &&
      !this.inputDisabled &&
      !this.readonly &&
      !this.showPassword;
  },
  // 设置计算属性 upperLimit 来获取 maxlength 的值
  upperLimit() {
    return this.$attrs.maxlength;
  },
  // 获取 value 值的长度
  textLength() {
    if (typeof this.value === 'number') {
      return String(this.value).length;
    }

    return (this.value || '').length;
  },
  // 判断字数是否超出限制
  inputExceed() {
    // 如果显示字数统计且字数超限,则说明字数超出限制
    return this.isWordLimitVisible &&
      (this.textLength > this.upperLimit);
  }
}
// 在方法 getSuffixVisible 上面增加 showWordLimit 的判断
getSuffixVisible() {
  return this.showClear|| 
    this.showPassword || 
    this.isWordLimitVisible;
}

页面结构部分:

<!-- 字数统计样式 -->
<span class="el-input__suffix-inner">
  <span v-if="isWordLimitVisible" class="el-input__count">
    <span class="el-input__count-inner">
      {{ textLength }}/{{ upperLimit }}
    </span>
  </span>
</span>
<!-- 超出最大字数的样式,在最外层增加样式 -->
:class="[
  {'is-exceed': inputExceed,}
]"

3.2.6 尺寸与图标属性

3.2.6.1 尺寸属性

实现思路:

  • props 接收 size 属性,并且设置计算属性 inputSize 监听 size 的改变。(后续结合 form 组件,在计算属性里面还需要监听 formsize)。
  • 在最外层根据不同的 size 增加不同的样式 class

代码实现:

// props 接收 size 属性
props: {
  size: String,
}

// 计算属性 inputSize 监听 size 的改变
computed: {
  inputSize() {
    return this.size;
  },
}
<!-- 根据不同的 size 增加不同的样式 class -->
<!-- 不同的尺寸的高度和字号不同 -->
inputSize ? 'el-input--' + inputSize : '',
3.2.6.2 图标属性

实现思路:

  • props 接收 suffixIconprefixIcon 属性。
  • 增加 suffixprefixclass 样式。
  • HTML 中增加前置图标和后置图标元素。

代码实现:

// props 接收 suffixIcon 和 prefixIcon 属性
props: {
   suffixIcon: String,
   prefixIcon: String,
}
<!-- 增加 suffix 和 prefix 的 class 样式 -->
'el-input--prefix': prefixIcon,
'el-input--suffix': suffixIcon || clearable || showPassword

<!-- 在 HTML 中增加前置图标元素 -->
<span class="el-input__prefix" v-if="prefixIcon">
  <i class="el-input__icon"
    v-if="prefixIcon"
    :class="prefixIcon">
  </i>
</span>

<!-- 在 HTML 中增加后置图标元素 -->
<span class="el-input__suffix-inner">
  <template v-if="suffixIcon">
    <i class="el-input__icon"
      v-if="suffixIcon"
      :class="suffixIcon">
    </i>
  </template>
</span>
getSuffixVisible() {
    return this.suffixIcon ||
      this.showClear|| 
      this.showPassword || 
      this.isWordLimitVisible;
}

3.2.7 屏幕阅读器相关属性

增加 aria-labeltabIndex 属性。
aria-label 属性用于没有给输入框设计对应的 label 文本位置时,aria-label 为读屏软件提供描述信息。

代码实现:

// 接收 label 和 tabIndex 属性
props: {
  label: String,
  tabindex: String
}
<!-- 绑定在 input 元素上 -->
<input
  :aria-label="label"
  :tabindex="tabindex"
/>

3.3 实现插槽功能

3.3.1 实现 prefix 和 suffix 插槽

(1)prefixsuffix 位置描述
prefixsuffix 是输入框头部和尾部的内容,展示在输入框的内部,和传入的头部图标尾部图标展示的位置一致。

(2)代码实现

  • 分别在 classel-input__prefixel-input__suffix 元素的内部,采用具名插槽的形式,将传入的元素插入。
  • v-if 显示判断增加 $slots.prefix$slots.suffix
  • 最外层的 class 增加 $slots.prefix$slots.suffix 的判断。
<!-- 前置内容 -->
<span class="el-input__prefix" v-if="$slots.prefix || prefixIcon">
  <slot name="prefix"></slot>
  ...
</span>
  
<!-- 后置内容 -->
<span class="el-input__suffix-inner">
  <template v-if="$slots.suffix || suffixIcon">
    <slot name="suffix"></slot>
    ...
  </template>
</span>
  • 在方法 getSuffixVisible 增加 this.$slots.suffix 的显示判断。
getSuffixVisible() {
  return this.$slots.suffix ||
    this.suffixIcon ||
    this.showClear|| 
    this.showPassword || 
    this.isWordLimitVisible;
}

3.3.2 实现 prepend 和 append 插槽

(1)prependappend 位置描述

prependappend 是输入框前置和输入框后置的内容,展示在输入框的外部。

(2)将 prependappend 元素放置在组件中

  • 在组件的HTML结构中增加前置元素和后置元素。
  • 增加前置元素和后置元素的 class
<!-- 在 type!=textarea 的 template 元素中增加前置元素和后置元素 -->
<!-- 前置元素 -->
<div class="el-input-group__prepend" v-if="$slots.prepend">
  <slot name="prepend"></slot>
</div>
<!-- 后置元素 -->
<div class="el-input-group__append" v-if="$slots.append">
  <slot name="append"></slot>
</div>

在最外层增加前置元素和后置元素的 class
'el-input-group': $slots.prepend || $slots.append,
'el-input-group--append': $slots.append,
'el-input-group--prepend': $slots.prepend,

(3)重新计算 prefixsuffix 的位置

由于 prefixsuffix 的位置是参照输入框绝对定位的元素,所以在增加了 prependappend 元素后,如果输入框还存在 prefixsuffix 元素,需要将其的横向位置分别向右或者向左进行移动,避免图标位置与 prefixsuffix 的位置重叠。

计算图标横向位移的方法:

updateIconOffset() {
  // 提供 calcIconOffset 方法接收 prefix 和 suffix 两个参数
  this.calcIconOffset('prefix');
  this.calcIconOffset('suffix');
},

calcIconOffset 方法(用于计算图表横向位移):

calcIconOffset(place) {
  // 获取到 class 为 el-input__prefix 或 el-input__suffix
  let elList = [].slice.call(this.$el.querySelectorAll(`.el-input__${place}`) || []);
  if (!elList.length) return;
  // 设置变量 el
  let el = null;
  for (let i = 0; i < elList.length; i++) {
    // 如果获取到的 el-input__prefix 或 el-input__suffix 的父级元素就是该组件的根元素
    if (elList[i].parentNode === this.$el) {
      // 则将 el-input__prefix 或 el-input__suffix 赋值给 el 变量
      el = elList[i];
      break;
    }
  }
  if (!el) return;
  const pendantMap = {
    suffix: 'append',
    prefix: 'prepend'
  };
  
  const pendant = pendantMap[place];
  if (this.$slots[pendant]) {
    // 如果存在前置元素或后置元素,则获取前置元素或后置元素的 offsetWidth,将 el 向右或向左移动对应的宽度
    el.style.transform = `translateX(${place === 'suffix' ? '-' : ''}${this.$el.querySelector(`.el-input-group__${pendant}`).offsetWidth}px)`;
  } else {
    // 如果不存在则 el 移除 style 属性
    el.removeAttribute('style');
  }
},

mountedupdated 钩子函数中调用 updateIconOffset,在载入后和更新后都需要重新计算图标的位置。

mounted() {
  ...
  this.updateIconOffset();
},
updated() {
  this.$nextTick(this.updateIconOffset);
}

3.4 补齐事件和方法

3.4.1 补齐事件

目前已经实现的事件有 blurfocusinputclear,未实现的有 change

input 元素上面增加 change 事件,执行 handleChange 方法,方法内部采用 emit 触发父组件的 change 事件。

// handleChange 方法
handleChange(event) {
  this.$emit('change', event.target.value);
},

3.4.2 补齐方法

目前对外提供的可以调用到的方法有 focus,还需要提供 blurselect

blur 方法:

blur() {
  this.getInput().blur();
},

select 方法:

select() {
  this.getInput().select();
},

四. 整体代码详解

包含 Input 与 Form 组件相互作用的部分。

<template>
  <div :class="[
    type === 'textarea' ? 'el-textarea' : 'el-input',
    inputSize ? 'el-input--' + inputSize : '',
    {
      'is-disabled': inputDisabled,
      'is-exceed': inputExceed,
      'el-input-group': $slots.prepend || $slots.append,
      'el-input-group--append': $slots.append,
      'el-input-group--prepend': $slots.prepend,
      'el-input--suffix': clearable || showPassword,
      'el-input--prefix': $slots.prefix || prefixIcon,
      'el-input--suffix': $slots.suffix || suffixIcon || clearable || showPassword
    }
    ]"
    @mouseenter="hovering = true"
    @mouseleave="hovering = false"
  >
    <template v-if="type !== 'textarea'">
      <!-- 前置元素 -->
      <div class="el-input-group__prepend" v-if="$slots.prepend">
        <slot name="prepend"></slot>
      </div>
      <input
        class="el-input__inner"
        v-bind="$attrs"
        :type="showPassword ? (passwordVisible ? 'text': 'password') : type"
        :disabled="inputDisabled"
        ref="input"
        @compositionstart="handleCompositionStart"
        @compositionupdate="handleCompositionUpdate"
        @compositionend="handleCompositionEnd"
        @input="handleInput"
        @focus="handleFocus"
        @blur="handleBlur"
        @change="handleChange"
        :aria-label="label"
        :tabindex="tabindex"
      />
      <!-- 前置内容 -->
      <span class="el-input__prefix" v-if="$slots.prefix || prefixIcon">
        <slot name="prefix"></slot>
        <i class="el-input__icon"
           v-if="prefixIcon"
           :class="prefixIcon">
        </i>
      </span>
      <!-- 后置内容 -->
      <span
        class="el-input__suffix"
        v-if="getSuffixVisible()">
        <span class="el-input__suffix-inner">
          <template v-if="$slots.suffix || suffixIcon">
            <slot name="suffix"></slot>
            <i class="el-input__icon"
              v-if="suffixIcon"
              :class="suffixIcon">
            </i>
          </template>
          <!-- @mousedown.prevent 用于阻止输入框失去焦点 -->
          <i v-if="showClear"
            class="el-input__icon el-icon-circle-close el-input__clear"
            @mousedown.prevent
            @click="clear"
          ></i>
          <i v-if="showPwdVisible"
            class="el-input__icon el-icon-view el-input__clear"
            @click="handlePasswordVisible"
          ></i>
          <span v-if="isWordLimitVisible" class="el-input__count">
            <span class="el-input__count-inner">
              {{ textLength }}/{{ upperLimit }}
            </span>
          </span>
        </span>
        <!-- 校验结果反馈图标 -->
        <i class="el-input__icon"
          v-if="validateState"
          :class="['el-input__validateIcon', validateIcon]">
        </i>
      </span>
      <!-- 后置元素 -->
      <div class="el-input-group__append" v-if="$slots.append">
        <slot name="append"></slot>
      </div>
    </template>
    <textarea
      v-else
      :tabindex="tabindex"
      class="el-textarea__inner"
      @compositionstart="handleCompositionStart"
      @compositionupdate="handleCompositionUpdate"
      @compositionend="handleCompositionEnd"
      @input="handleInput"
      ref="textarea"
      v-bind="$attrs"
      :disabled="inputDisabled"
      :readonly="readonly"
      :style="textareaStyle"
      @focus="handleFocus"
      @blur="handleBlur"
      @change="handleChange"
      :aria-label="label"
    >
    </textarea>
    <span v-if="isWordLimitVisible && type === 'textarea'" class="el-input__count">{{ textLength }}/{{ upperLimit }}</span>
  </div>
</template>
<script>
import calcTextareaHeight from './calcTextareaHeight';
import emitter from '../../utils/mixins/emitter';

export default {
  name: 'ElInput',

  componentName: 'ElInput',
  
  mixins: [emitter],
  
  inheritAttrs: false,
  
  // inject 去接收 Form 组件和 Form-Item 组件传过来的数据
  inject: {
    elForm: {
      default: ''
    },
    elFormItem: {
      default: ''
    }
  },
  
  data() {
    return {
      textareaCalcStyle: {},
      hovering: false,
      focused: false,
      isComposing: false, // 是否处于拼音输入状态
      passwordVisible: false
    }
  },
  
  props: {
    value: [String, Number],
    size: String,
    resize: String,
    disabled: Boolean,
    readonly: Boolean,
    type: {
      type: String,
      default: 'text'
    },
    autosize: {
      type: [Boolean, Object],
      default: false
    },
    // 输入时是否触发表单的校验
    validateEvent: {
      type: Boolean,
      default: true
    },
    suffixIcon: String,
    prefixIcon: String,
    clearable: {
      type: Boolean,
      default: false
    },
    showPassword: {
      type: Boolean,
      default: false
    },
    showWordLimit: {
      type: Boolean,
      default: false
    },
    label: String,
    tabindex: String
  },
  
  computed: {
    // 获取 Form-Item 组件的 elFormItemSize 变量
    _elFormItemSize() {
      return (this.elFormItem || {}).elFormItemSize;
    },
    // 接收 Form-Item 组件传过来的表单项验证状态 validateState
    validateState() {
      return this.elFormItem ? this.elFormItem.validateState : '';
    },
    // 接收 Form 组件传过来的是否显示校验结果反馈图标 statusIcon 属性
    needStatusIcon() {
      return this.elForm ? this.elForm.statusIcon : false;
    },
    // 根据 validateState 判断显示的图标
    validateIcon() {
      return {
        validating: 'el-icon-loading',
        success: 'el-icon-circle-check',
        error: 'el-icon-circle-close'
      }[this.validateState];
    },
    textareaStyle() {
      return Object.assign({}, this.textareaCalcStyle, { resize: this.resize });
    },
    // Input 的 size
    inputSize() {
      // 优先级:组件本身的 size > Form-Item 的 size
      return this.size || this._elFormItemSize;
    },
    // Input 的 disabled
    inputDisabled() {
      // 优先级:组件本身的 disabled > Form 的 disabled
      return this.disabled || (this.elForm || {}).disabled;
    },
    // 监听 value 值的变化,如果 value 为 null 或者 undefined,则返回空,否则返回 value 转化为字符串后的值
    nativeInputValue() {
      return this.value === null || this.value === undefined ? '' : String(this.value);
    },
    // 显示可清空图标
    showClear() {
      // 在可清空属性设置为 true ,且 input 有值且不为禁用或只读状态,且鼠标移入输入框内部或输入框聚焦,则展示可清空图标
      return this.clearable &&
        !this.inputDisabled &&
        !this.readonly &&
        this.nativeInputValue &&
        (this.focused || this.hovering);
    },
    // 判断密码图表在哪种情况下显示
    showPwdVisible() {
      // 当显示密码图标属性被设置为 true 时,且输入框不属于禁用、只读状态,且输入框有值或者输入框在聚焦的情况下,显示密码图标
      return this.showPassword &&
        !this.inputDisabled &&
        !this.readonly &&
        (!!this.nativeInputValue || this.focused);
    },
    // 判断字数统计属性在何种情况下显示
    isWordLimitVisible() {
      // 当showWordLimit属性为true,且有最大字数限制,且类型为 text 或 textarea,且不为禁用、只读、密码的状态时,显示字数统计
      return this.showWordLimit &&
        this.$attrs.maxlength &&
        (this.type === 'text' || this.type === 'textarea') &&
        !this.inputDisabled &&
        !this.readonly &&
        !this.showPassword;
    },
    // 设置计算属性 upperLimit 来获取 maxlength 的值
    upperLimit() {
      return this.$attrs.maxlength;
    },
    // 获取 value 值的长度
    textLength() {
      if (typeof this.value === 'number') {
        return String(this.value).length;
      }

      return (this.value || '').length;
    },
    // 判断字数是否超出限制
    inputExceed() {
      // 如果显示字数统计且字数超限,则说明字数超出限制
      return this.isWordLimitVisible &&
        (this.textLength > this.upperLimit);
    }
  },
  
  watch: {
    value(val) {
      this.$nextTick(this.resizeTextarea);
      // 如果输入时触发表单的校验,则在 value 值的改变时即向上派发 el.form.change 事件
      if (this.validateEvent) {
        this.dispatch('ElFormItem', 'el.form.change', [val]);
      }
    },
    // 监听 value 值的变化,给 input 赋值
    nativeInputValue() {
      this.setNativeInputValue();
    },
    type() {
      this.$nextTick(() => {
        this.setNativeInputValue();
        this.resizeTextarea();
      });
    }
  },
  
  methods: {
    focus() {
      this.getInput().focus();
    },
    blur() {
      this.getInput().blur();
    },
    select() {
      this.getInput().select();
    },
    resizeTextarea() {
      // 解构出来 autosize 和 type 属性
      const { autosize, type } = this;
      // 如果不是 textarea 则不继续向下执行
      if (type !== 'textarea') return;
      // 如果没有 autosize 则直接计算出最小高度
      if (!autosize) {
        this.textareaCalcStyle = {
          minHeight: calcTextareaHeight(this.$refs.textarea).minHeight
        };
        return;
      }
      // 如果有 autosize 需要自适应高度,则根据传入的 minRows 和 maxRows 计算出最小高度和自适应的高度
      const minRows = autosize.minRows;
      const maxRows = autosize.maxRows;
      // 将计算后的结果赋值给 textareaCalcStyle 变量
      this.textareaCalcStyle = calcTextareaHeight(this.$refs.textarea, minRows, maxRows);
    },
    // 获取 input
    getInput() {
      return this.$refs.input || this.$refs.textarea;
    },
    // 设置 input 元素的值
    setNativeInputValue() {
      // 获取到 input 元素
      const input = this.getInput();
      if (!input) return;
      // 如果 input 之前的 value 值与改变后的值相同,则 return
      if (input.value === this.nativeInputValue) return;
      // 将 input 的值赋值为最新的值
      input.value = this.nativeInputValue;
    },
    handleBlur(event) {
      this.focused = false;
      this.$emit('blur', event);
      // 如果输入时触发表单的校验,则在输入框 blur 时即向上派发 el.form.blur 事件
      if (this.validateEvent) {
        this.dispatch('ElFormItem', 'el.form.blur', [this.value]);
      }
    },
    handleFocus(event) {
      this.focused = true;
      this.$emit('focus', event);
    },
    // 输入法编辑器开始新的输入合成时(开始输入拼音时)触发
    handleCompositionStart(event) {
      // 触发父级的 compositionstart 事件
      this.$emit('compositionstart', event);
      // 将 isComposing 设置为 true,说明处于拼音输入的状态
      this.isComposing = true;
    },
    // 组合输入更新时(每输入一下拼音时)都会触发
    handleCompositionUpdate(event) {
      // 触发父级的 compositionupdate 事件
      this.$emit('compositionupdate', event);
      // 将 isComposing 设置为 true,说明处于拼音输入的状态
      this.isComposing = true;
    },
    // 组合输入结束时(拼音输入结束,关闭中文输入法时)触发
    handleCompositionEnd(event) {
      // 触发父级的 compositionend 事件
      this.$emit('compositionend', event);
      if (this.isComposing) {
        // 将 isComposing 设置为 false,说明此时已经不是拼音输入的状态
        this.isComposing = false;
        // 触发 handleInput 方法,给 input 赋值
        this.handleInput(event);
      }
    },
    // 触发 input 事件
    handleInput(event) {
      // 如果正在输入拼音,直接 return
      if (this.isComposing) return;
      // 解决 IE 浏览器中 Input 初始化自动执行的问题
      if (event.target.value === this.nativeInputValue) return;
      // 触发父级的 input 事件
      this.$emit('input', event.target.value);
      // 给 input 重新赋值
      this.$nextTick(this.setNativeInputValue);
    },
    handleChange(event) {
      this.$emit('change', event.target.value);
    },
    calcIconOffset(place) {
      // 获取到 class 为 el-input__prefix 或 el-input__suffix
      let elList = [].slice.call(this.$el.querySelectorAll(`.el-input__${place}`) || []);
      if (!elList.length) return;
      // 设置变量 el
      let el = null;
      for (let i = 0; i < elList.length; i++) {
        // 如果获取到的 el-input__prefix 或 el-input__suffix 的父级元素就是该组件的根元素
        if (elList[i].parentNode === this.$el) {
          // 则将 el-input__prefix 或 el-input__suffix 赋值给 el 变量
          el = elList[i];
          break;
        }
      }
      if (!el) return;
      const pendantMap = {
        suffix: 'append',
        prefix: 'prepend'
      };
      
      const pendant = pendantMap[place];
      if (this.$slots[pendant]) {
        // 如果存在前置元素或后置元素,则获取前置元素或后置元素的 offsetWidth,将 el 向右或向左移动对应的宽度
        el.style.transform = `translateX(${place === 'suffix' ? '-' : ''}${this.$el.querySelector(`.el-input-group__${pendant}`).offsetWidth}px)`;
      } else {
        // 如果不存在则 el 移除 style 属性
        el.removeAttribute('style');
      }
    },
    updateIconOffset() {
      this.calcIconOffset('prefix');
      this.calcIconOffset('suffix');
    },
    // 清空操作
    clear() {
      // 触发父级的 input 事件,并且将值传为空,由于父级用 v-model 语法糖绑定值,则可清空组件的值
      this.$emit('input', '');
      // 触发父级的 change 事件
      this.$emit('change', '');
      // 触发父级的 clear 事件
      this.$emit('clear');
    },
    handlePasswordVisible() {
      this.passwordVisible = !this.passwordVisible;
      this.$nextTick(() => {
        this.focus();
      });
    },
    // 判断后置内容区域是否显示
    getSuffixVisible() {
      return this.$slots.suffix ||
        this.suffixIcon ||
        this.showClear|| 
        this.showPassword || 
        this.isWordLimitVisible ||
        (this.validateState && this.needStatusIcon);;
    }
  },
  
  mounted() {
    // 设置 input 元素的值
    this.setNativeInputValue();
    this.resizeTextarea();
    this.updateIconOffset();
  },
  
  updated() {
    this.$nextTick(this.updateIconOffset);
  }
}
</script>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值