3.30 Input 输入框

简介

输入框组件 Input 通过鼠标或键盘输入表单域内容,提供复合型型输入框,带搜索的输入框,还可以进行大小选择。 本文将分析其源码实现,耐心读完,相信会对您有所帮助。🔗 组件文档 Input 🔗 gitee源码

更多组件剖析详见 👉 📚 Element 2 源码剖析组件总览

模板HTML

组件的 props 声明,各属性功能描述详见官方文档Input#attributes

组件根元素是一个类名为el-textareael-input的 div 元素,元素内容包含了三部分:

  1. 封装原生 input 控件实现单行文本输入框。

  2. 封装原生 textarea 控件多行文本输入文本域。

// packages\input\src\input.vue
<template>
  <div :class="[type === 'textarea' ? 'el-textarea' : 'el-input',]" >
    <!-- 单行文本输入框 -->
    <template v-if="type !== 'textarea'">
      <!-- 输入框前置内容 -->
      <div class="el-input-group__prepend"></div> 
      <!-- 表单输入控件 -->
      <input>
      <!-- 输入框头部内容 -->
      <span class="el-input__prefix"></span>
      <!-- 输入框尾部内容 -->
      <span class="el-input__suffix"></span>
      <!-- 输入框后置内容 -->
      <div class="el-input-group__append"></div> 
    </template>
    <!-- 多行文本输入的文本域 -->
    <textarea></textarea>
  </div>
</template>

组件根元素

  • 属性type值确定使用 el-textareael-input

  • 组件尺寸inputSize生成样式 el-input--medium/small/mini

  • 禁用状态inputDisabled 生成样式 is-disabled

  • 开启输入长度限制,输入超限时inputExceed生成样式 is-disabled

  • 复合型输入框样式el-input-groupel-input-group--append/prepend根据前置/后置插槽内容生成。

  • 头部/尾部样式el-input--prefixel-input--suffix根据插槽内容或功能属性值生成。

  • 添加了鼠标移入移出事件,使用内部属性 hover 记录元素悬停状态。

<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--prefix': $slots.prefix || prefixIcon,
    'el-input--suffix': $slots.suffix || suffixIcon || clearable || showPassword
  }
  ]"
  @mouseenter="hovering = true"
  @mouseleave="hovering = false"
>
  // ...
</div>

单行文本输入

单行文本输入框通过封装原生 input 控件实现,支持控件的原生属性。组件通过组合以下多种元素实现文本框 text或密码框password等复合型输入框功能:

  1. 输入框前置内容,提供具名插槽prepend,内容一般为标签或按钮。

  2. 原生 input 表单输入控件,添加了自定义事件。

  3. 输入框头部内容,可以通过 prefix-icon 或具名插槽prefix增加显示图标。

  4. 输入框尾部内容,可以通过 suffix-icon 或具名插槽suffix增加显示图标。该元素也用于展示输入框清空Icon、密码显隐切换Icon以及展示输入字数统计。

  5. 输入框后置内容,提供具名插槽append,内容一般为标签或按钮。

<template v-if="type !== 'textarea'">
  <!-- 输入框前置内容 -->
  <div class="el-input-group__prepend" v-if="$slots.prepend">
    <slot name="prepend"></slot>
  </div>
  <!-- 表单输入控件 -->
  <input
    :tabindex="tabindex"  // 元素是否可以聚焦 键盘导航
    v-if="type !== 'textarea'"
    class="el-input__inner"
    v-bind="$attrs" // 透传 Attributes 例如 placeholder
    :type="showPassword ? (passwordVisible ? 'text': 'password') : type" // text/password  也支持其他原生input的type值
    :disabled="inputDisabled" // 是否禁用
    :readonly="readonly"  // 是否只读
    :autocomplete="autoComplete || autocomplete" // 自动补全
    ref="input"
    @compositionstart="handleCompositionStart" // 输入法编辑器 (IME) 事件
    @compositionupdate="handleCompositionUpdate"
    @compositionend="handleCompositionEnd"
    @input="handleInput" // 输入内容
    @focus="handleFocus" // 获取焦点
    @blur="handleBlur" // 失去焦点
    @change="handleChange" // 输入值变化
    :aria-label="label" // ARIA 无障碍属性
  >
  <!-- 输入框头部内容 -->
  <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="!showClear || !showPwdVisible || !isWordLimitVisible">
        <slot name="suffix"></slot>
        <i class="el-input__icon" v-if="suffixIcon" :class="suffixIcon"></i>
      </template>
      // ...
    </span>
    // ...
  </span>
  <!-- 输入框后置内容 -->
  <div class="el-input-group__append" v-if="$slots.append">
    <slot name="append"></slot>
  </div>
</template>

组件渲染效果如下:

输入框头部/尾部都提供了具名插槽用于增加显示图标,当然也可以传入文本等其他内容,但不建议这么做。

当设置头部/尾部内容时, input 输入框通过属性 padding 提供了 30px 的宽度区域用于内容展示。头部/尾部元素使用绝对布局,将内容偏移覆盖至 padding 区域。

.el-input__prefix {
  position: absolute;
  height: 100%;
  left: 5px;
  top: 0;  
}

.el-input__suffix {
  position: absolute;
  height: 100%;
  right: 5px;
  top: 0; 
}
.el-input--prefix .el-input__inner {
    padding-left: 30px; 
}
.el-input--suffix .el-input__inner { 
    padding-right: 30px; 
}

以下示例将自定义文本传入插槽。

<el-input placeholder="请输入内容" v-model="input">
  <template slot="prefix">
    <span style="display: flex; align-items: center; height: 100%">头部内容</span>
  </template>
  <template slot="suffix">
    <span style="display: flex; align-items: center; height: 100%">尾部内容</span>
  </template>
</el-input>

input 元素类型

组件默认使用 text 类型的input控件。当设置 showPassword可得到一个可切换显示隐藏的密码框,内部属性passwordVisible记录显隐状态,根据不同的状态使用 passwordtext 类型。

:type="showPassword ? (passwordVisible ? 'text': 'password') : type"

属性 type 值也可设置为其他原生input的type值

<el-input v-model="input" placeholder="请输入内容" type="color"></el-input>
<el-input v-model="input" placeholder="请输入内容" type="date"></el-input>
<el-input v-model="input" placeholder="请输入内容" type="datetime-local"></el-input> 
<el-input v-model="input" placeholder="请输入内容" type="file"></el-input> 
<el-input v-model="input" placeholder="请输入内容" type="month"></el-input> 
<el-input v-model="input" placeholder="请输入内容" type="time"></el-input> 
<el-input v-model="input" placeholder="请输入内容" type="week"></el-input>

使用其他原生类型时组件渲染效果,但是一般不建议这么使用!一般组件类库都会提供对应的更加丰富的功能组件,使用库组件页面样式风格更加统一,提升用户交互体验。

输入法编辑器(IME)事件

输入法在中文、日文和韩文等少数语言中使用。以中文拼音输入法为例,输入的过程大致可以分为组字(composition)提交(commit) 两阶段。比如想打“你好”两个字,会在输入框输入“nihao”的拼音,当输入第一个字母“n”时,组字过程就开始了。此时本地的 IME(input method editor) 软件(比如微软/搜狗拼音输入法)会为我们提供组字框候选列表的 UI 组件。

关于输入法事件更多介绍,请阅读 Web 键盘输入法应用开发指南 —— 输入法事件

compositionstartcompositionupdatecompositionend是一组事件。

  1. 首先是使用拼音输入法开始输入汉字时 compositionstart 被触发,组字框和候选列表相应出现;

  2. 此后,每按一个新键,就会触发 compositionupdate,此时组字框和候选列表的内容也发生变化;

  3. 当选择了候选列表中的某个字或词,或者敲击空格(中文输入法),compositionend 事件会触发,表明输入被提交。

  4. 当取消输入时,比如使用鼠标单击页面空白处,就会终止当前输入,也会触发compositionend 事件。

属性isComposing值为 true表示用户正在输入。输入结束后(选择字词或者取消),调用方法handleInput,触发组件 input 事件。

// @compositionstart="handleCompositionStart"
// @compositionupdate="handleCompositionUpdate"
// @compositionend="handleCompositionEnd"

handleCompositionStart() {
  this.isComposing = true; // 正在输入
},
handleCompositionUpdate(event) {
  const text = event.target.value;
  const lastCharacter = text[text.length - 1] || '';
  // 韩文字符编码 判断最后一个字符是否特殊的功能键 Process Key 
  this.isComposing = !isKorean(lastCharacter); 
},
handleCompositionEnd(event) {
  // 输入结束后,触发 input 事件
  if (this.isComposing) {
    this.isComposing = false;
    this.handleInput(event);
  }
},

input/change 事件

  • <input><select><textarea> 元素的 value 被修改时,会触发 input 事件。

  • 当用户更改<input><select><textarea> 元素的值并提交这个更改时,change 事件在这些元素上触发。基于表单元素的类型和用户对标签的操作的不同,change 事件触发的时机也不同。

对于文本输入元素,比如 <input type="text">,每当元素的 value 改变,input 事件都会被触,change 事件在控件失去焦点后才会触发。

// @input="handleInput" 
// @change="handleChange"

handleInput(event) {
  // 输入法下用户正在输入
  if (this.isComposing) return;

  // IE 11下 DatePicker组件 hack 写法,详见issues
  // https://github.com/ElemeFE/element/issues/8548
  // should remove the following line when we don't support IE
  if (event.target.value === this.nativeInputValue) return;
  
  // 触发触发当前实例上的input事件
  this.$emit('input', event.target.value);
 
  // Input 为受控组件 更新组件的绑定值
  this.$nextTick(this.setNativeInputValue);
},
handleChange(event) {
  // 触发触发当前实例上的change事件
  this.$emit('change', event.target.value);
},

对于需要使用输入法的语言, v-model 不会在输入法组合文字过程中得到更新,因为compositionend事件没触发是不会执行handleInput逻辑。

if (this.isComposing) return;

focus/blur 事件

元素获取或失去焦点时,调用事件监听方法,更新内部属性focused记录元素焦点状态,同时触发实例自定义 focusblur 事件。

// @focus="handleFocus"
// @blur="handleBlur"

handleFocus(event) {
  this.focused = true;
  // 触发触发当前实例上的focus事件
  this.$emit('focus', event);
},
handleBlur(event) {
  this.focused = false;
  // 触发触发当前实例上的blur事件
  this.$emit('blur', event);
  // 开启输入时是否触发表单的校验  
  if (this.validateEvent) {
    // 触发组件`FormItem`的自定义`el.form.blur`事件。
    this.dispatch('ElFormItem', 'el.form.blur', [this.value]);
  } 
},

provide/inject 依赖注入

在 Form 组件中,每一个表单域由一个 Form-Item 组件构成,表单域中可以放置各种类型的表单控件,包括 Input、Select、Checkbox、Radio、Switch、DatePicker、TimePicker等。

表单form和表单域form-item使用provide 选项指定给后代组件的状态,避免了 prop 逐级透传

// packages\form\src\form.vue
provide() {
  return {
    elForm: this
  };
},

// packages\form\src\form-item.vue
provide() {
  return {
    elFormItem: this
  };
}, 
inject: ['elForm'],
computed: { 
  // ...
  _formSize() {
    return this.elForm.size;
  },
  elFormItemSize() {
    return this.size || this._formSize;
  },
  sizeClass() {
    return this.elFormItemSize || (this.$ELEMENT || {}).size;
  }
}, 

组件 input 使用inject选项注入上层组件提供的数据,如尺寸、校验、禁用,用于组件内部状态的控制计算。

// packages\input\src\input.vue
inject: {
  elForm: {
    default: ''
  },
  elFormItem: {
    default: ''
  }
},
computed: {
  // 表单域下组件的尺寸
  _elFormItemSize() {
    return (this.elFormItem || {}).elFormItemSize;
  },
  // 表单域下组件的校验状态 校验中/成功/失败
  validateState() {
    return this.elFormItem ? this.elFormItem.validateState : '';
  },
  // 是否在输入框中显示校验结果反馈图标
  needStatusIcon() {
    return this.elForm ? this.elForm.statusIcon : false;
  },
  // 表单域下组件的校验状态图标
  validateIcon() {
    return {
      validating: 'el-icon-loading',
      success: 'el-icon-circle-check',
      error: 'el-icon-circle-close'
    }[this.validateState];
  },
  // 组件尺寸大小
  inputSize() {
    return this.size || this._elFormItemSize || (this.$ELEMENT || {}).size;
  },
  // 组件禁用状态
  inputDisabled() {
    return this.disabled || (this.elForm || {}).disabled;
  },
}


// this.$ELEMENT 来源于组件库的全局注册
const install = function(Vue, opts = {}) { 
  Vue.prototype.$ELEMENT = {
    size: opts.size || '',
    zIndex: opts.zIndex || 2000
  }; 
  // ...
};

生命周期

在生命周期钩子createdmountedupdated中,添加监听事件,初始化组件状态。

created() {
  // 监听当前实例上的自定义 inputSelect 事件,用于快速选中输入控件的所有内容
  this.$on('inputSelect', this.select);
}, 
mounted() {
  this.setNativeInputValue(); // 设置原生输入控件的value值
  this.resizeTextarea(); // 设置文本域的大小
  this.updateIconOffset(); // 输入框头部/尾部(图标)元素偏移
}, 
// 在数据更改导致的虚拟 DOM 重新渲染和更新完毕之后被调用。
updated() {
  // `updated` 不会保证所有的子组件也都被重新渲染完毕。
  //  使用 `vm.$nextTick` 确保整个视图都被重新渲染之后才会运行的代码
  this.$nextTick(this.updateIconOffset);
} 

组件对属性 valuenativeInputValuetype添加了侦听器,用于状态更新以及关联表单验证事件。

watch: {
  value(val) {
    this.$nextTick(this.resizeTextarea);
    // 开启输入时是否触发表单的校验
    if (this.validateEvent) {
      // 触发组件`FormItem`的自定义`el.form.change`事件,告知表单字段内容发生改变。
      this.dispatch('ElFormItem', 'el.form.change', [val]);
    }
  },
  // 原生输入控制value值处理
  nativeInputValue() {
    this.setNativeInputValue();
  }, 
  type() {
    // 组件渲染为 input 或者 textarea 
    // 类型切换会导致 DOM 也会发生改变 ,所以使用 `vm.$nextTick`。
    this.$nextTick(() => {
      this.setNativeInputValue();
      this.resizeTextarea();
      this.updateIconOffset();
    });
  }
},

下面逐一介绍下代这些方法的功能和作用。

select()

方法 select 用于选中输入控件的所有内容。

// methods
// 通过模板引用获取input/textarea实例
getInput() {
  return this.$refs.input || this.$refs.textarea;
}, 
// 选中输入控件的所有内容
select() {
  this.getInput().select();
},

setNativeInputValue()

方法setNativeInputValue用于设置原生控件的 value 属性,该属性时一个包含了文本框当前文字的DOMString。原生控件的value值默认是空字符串 ("").

计算属性nativeInputValue 用于将输入内容格式化成字符串。

nativeInputValue() { 
  return this.value === null || this.value === undefined ? '' : String(this.value);
},

在方法setNativeInputValue中使用nativeInputValue更新属性value值。

setNativeInputValue() {
  const input = this.getInput();
  if (!input) return;
  if (input.value === this.nativeInputValue) return;
  input.value = this.nativeInputValue;
},

resizeTextarea()

方法resizeTextarea用来设置文本域的大小,这个讲解 <textarea>详细介绍。

updateIconOffset()

方法updateIconOffset 根据前置/后置内容元素的 offsetWidth,在水平方向移动头部/尾部内容元素。

updateIconOffset() {
  this.calcIconOffset('prefix'); //计算头部图标偏移
  this.calcIconOffset('suffix'); //计算尾部图标偏移
},
calcIconOffset(place) {
  // 根据 el-input__prefix/el-input__suffix 选中元素节点
  let elList = [].slice.call(this.$el.querySelectorAll(`.el-input__${place}`) || []); 
  let el = null;
  // 找到当前实例的头部/尾部元素节点
  for (let i = 0; i < elList.length; i++) {
    if (elList[i].parentNode === this.$el) {
      el = elList[i];
      break;
    }
  }
  // 映射关系 头部对应前置, 尾部对应后置
  const pendantMap = {
    suffix: 'append',
    prefix: 'prepend'
  };

  const pendant = pendantMap[place];
  // 根据对应插槽是否传入内容,若传入内容,插槽内容渲染,图标需要移动;否则清除样式 
  if (this.$slots[pendant]) {
    // 尾部元素移动为负值
    el.style.transform = `translateX(${place === 'suffix' ? '-' : ''}${this.$el.querySelector(`.el-input-group__${pendant}`).offsetWidth}px)`;
  } else {
    el.removeAttribute('style');
  }
}, 

v-model

指令v-model常用于在表单输入元素(<input><textarea><select>)创建双向绑定数据绑定。它会根据控件类型自动选取正确的方法来更新元素。

它会根据所使用的元素类型自动使用对应的 DOM 属性和事件组合:

  • 文本类型的 <input><textarea> 元素会绑定 value property 并侦听 input 事件;

  • <input type="checkbox"><input type="radio"> 会绑定 checked property 并侦听 change 事件;

  • <select> 会绑定 value property 并侦听 change 事件。

v-model 会忽略任何表单元素上初始的 valuecheckedselected attribute。它将始终将当前绑定的 JavaScript 状态视为数据的正确来源。你应该在 JavaScript 中使用data 选项来声明该初始值。

v-model 本质是个语法糖,以组件el-input为例

<el-input v-model="searchText" />

上面的代码其实等价于下面这段 (编译器会对 v-model 进行展开):

<input 
  :value="searchText" 
  @input="newValue => searchText = newValue" 
/>

所以在组件 inputprops 选项里声明 value 时必须的,这也是文档中为什么会说属性 value/v-model 都是绑定值

当组件el-input属性 value 最新值需要更新到父组件属性searchText时,就会使用$emit触发实例 input 事件,实现双向绑定。

// @input="handleInput"  
handleInput(event) { 
  // ...
  
  // 触发当前实例上的input事件
  this.$emit('input', event.target.value); 
},

所以官方文档这句话 Input 为受控组件,它总会显示 Vue 绑定值 也就不难理解了。

单行文本输入 input

后置内容

后置内容不止用于展示图标,还提供了内容清空、密码显隐、输入文字统计以及表单验证状态图标等内容。

<!-- 后置内容 -->
<span class="el-input__suffix" v-if="getSuffixVisible()">
  <span class="el-input__suffix-inner">
    <template v-if="!showClear || !showPwdVisible || !isWordLimitVisible">
      // 图标
    </template>
    <!-- 可清空 -->
    <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>

后置内容元素渲染由很多数据状态控制。

getSuffixVisible() {
  return this.$slots.suffix ||       // 传入插槽对象
    this.suffixIcon ||               // 设置尾部图标
    this.showClear ||                // 可清空
    this.showPassword ||             // 密码框
    this.isWordLimitVisible ||       // 输入长度限制
    // 输入时触发表单的校验 并显示校验结果图标
    (this.validateState && this.needStatusIcon);
}

可清空

使用clearable属性即可得到一个可清空的输入框。 计算属性showClear根据组件状态、输入内容等判断功能是否开启。

showClear() {
  return this.clearable &&
    !this.inputDisabled &&       // 非禁用
    !this.readonly &&            // 非只读
    this.nativeInputValue &&     // 值不为空
    (this.focused || this.hovering);  // 获得元素焦点或者鼠标悬停
},

图标绑定 click 事件 ,调用方法 clear ,更新v-model值,触发组件实例的changeclear等自定义事件。

clear() {
  this.$emit('input', '');  // 用于 v-model 更新
  this.$emit('change', '');
  this.$emit('clear');
},

密码框

使用show-password属性即可得到一个可切换显示隐藏的密码框。

showPwdVisible() {
  return (
    this.showPassword &&
    !this.inputDisabled &&    // 非禁用
    !this.readonly &&         // 非只读
    (!!this.nativeInputValue || this.focused) // 值不为空 或 获得元素焦点
  );
},

图标绑定 click 事件 ,调用方法 handlePasswordVisible ,更新内部属性passwordVisible值,因为密码显隐是通过渲染不同类型 (textpassword) 的input控件实现,此时DOM会重新渲染,所以使用$nextTick,调用方法focus重新获取元素的焦点。

// html
:type="showPassword ? (passwordVisible ? 'text' : 'password') : type"

// methods
handlePasswordVisible() {
  this.passwordVisible = !this.passwordVisible;
  this.$nextTick(() => {
    this.focus();
  });
},
// 获取焦点
focus() {
  this.getInput().focus();
},

输入长度限制

通过设置 show-word-limit 属性来展示字数统计。只能对类型为 texttextarea 的输入框生效, 使用原生maxlength属性限制最大输入长度 。

isWordLimitVisible() {
  return (
    this.showWordLimit &&
    this.$attrs.maxlength &&  // 原生`maxlength`属性 透传 attribute
    (this.type === "text" || this.type === "textarea") && //类型为 `text` 或 `textarea`
    !this.inputDisabled &&    // 非禁用
    !this.readonly &&         // 非禁用
    !this.showPassword        // 非密码框
  );
},

使用了计算属性 textLengthupperLimit 显示了输入进度。

计算属性upperLimit 返回最大输入长度,使用$attrs获取原生属性 maxlength值。

// 最大输入长度
upperLimit() {
  return this.$attrs.maxlength; // 透传 attributes 
},

计算属性textLength返回当前输入内容的长度

textLength() {
  if (typeof this.value === "number") {
    return String(this.value).length;
  } 
  return (this.value || "").length;
},

计算属性 inputExceed 判断是否输入超限,用于生成组件根元素的样式is-exceed

inputExceed() { 
  return this.isWordLimitVisible && this.textLength > this.upperLimit;
},

表单验证结果反馈图标

当组件在表单中使用,表单form设置属性status-icon为输入框添加了表示校验结果的反馈图标。

computed: {
  // 表单域下组件的校验状态 校验中/成功/失败
  validateState() {
    return this.elFormItem ? this.elFormItem.validateState : '';
  },
  // 是否在输入框中显示校验结果反馈图标
  needStatusIcon() {
    return this.elForm ? this.elForm.statusIcon : false;
  },
  // 表单域下组件的校验状态图标
  validateIcon() {
    return {
      validating: 'el-icon-loading',
      success: 'el-icon-circle-check',
      error: 'el-icon-circle-close'
    }[this.validateState];
  }, 
} 

文本域 textarea

组件通过封装原生 textarea 控件实现多行文本输入文本域功能。组件做了封装统一处理,所以textarea 控件的属性/事件跟input控件是相似的。

<div :class="[type === 'textarea' ? '' : 'el-input']">
  <!-- 单行文本输入框 -->
  <template v-if="type !== 'textarea'">
     <!-- 表单输入控件 -->
     <input>
  </template>
  <!-- 多行文本输入的文本域 -->
  <textarea
    v-else
    :tabindex="tabindex"                           // 元素是否可以聚焦 键盘导航
    class="el-textarea__inner" 
    ref="textarea"
    v-bind="$attrs"                                // 透传 Attributes
    :disabled="inputDisabled"                      // 是否禁用
    :readonly="readonly"                           // 是否只读
    :autocomplete="autoComplete || autocomplete"   // 自动补全
    :style="textareaStyle"                         // 自定义样式
    @compositionstart="handleCompositionStart"     // 输入法编辑器 (IME) 事件
    @compositionupdate="handleCompositionUpdate"
    @compositionend="handleCompositionEnd"
    @input="handleInput"                           // 输入内容
    @focus="handleFocus"                           // 获取焦点
    @blur="handleBlur"                             // 失去焦点
    @change="handleChange"                         // 输入值变化
    :aria-label="label"                            // ARIA 无障碍属性
  >
  </textarea> 
  <!-- 展示字数统计 -->
  <span v-if="isWordLimitVisible && type === 'textarea'" class="el-input__count">
    {{ textLength }}/{{ upperLimit }}
  </span>
</div>

控件缩放

控件textarea 控件通过绑定计算属性textareaStyle设置内联样式,实现控件的高度自适应、文本区大小可调整。

计算属性textareaStyle中使用了内部属性textareaCalcStyle和prop属性 resize

  • 属性textareaCalcStyle值为文本域的高度属性(heightmin-height)样式,在方法resizeTextarea中由文本域内容和配置项计算生成。

  • 属性 resize 控制文本区是否可调整大小。

    • none 元素不能被用户缩放。

    • both 允许用户在水平和垂直方向上调整元素的大小。

    • horizontal 允许用户在水平方向上调整元素的大小。

    • vertical 允许用户在垂直方向上调整元素的大小。

data() {
  return {
    textareaCalcStyle: {}, 
  };
}, 
computed: { 
  textareaStyle() {
    return merge({}, this.textareaCalcStyle, { resize: this.resize });
  }, 
},
methods: { 
  resizeTextarea() {
    // 计算 textareaCalcStyle
  }, 
}, 

可自适应文本高度

方法 resizeTextarea用来改变文本域的高度大小的。实例挂载时会调用该方法,当实例类型、输入框的值改变时也会多次调用该方法。

通过设置 autosize 属性可以使得文本域的高度能够根据文本内容自动进行调整,并且 autosize 还可以设定为一个对象,指定最小行数和最大行数。

mounted() { 
  this.resizeTextarea(); 
}, 
watch: {
  value(val) {
    this.$nextTick(this.resizeTextarea); 
  }, 
  type() {
    this.$nextTick(() => {
      this.resizeTextarea(); 
    });
  }
},
methods: { 
  resizeTextarea() {
    // 若服务端渲染,方法中断返回
    if (this.$isServer) return;
    const { autosize, type } = this;
    // 此方法只用于 `textarea` 控件
    if (type !== 'textarea') return; 
    // 属性 autosize 未开启自适应内容高度   只计算控件最小高度
    if (!autosize) {
      this.textareaCalcStyle = {
        minHeight: calcTextareaHeight(this.$refs.textarea).minHeight
      };
      return;
    }
    // autosize 也可传入对象,如 { minRows: 2, maxRows: 6 }
    const minRows = autosize.minRows; // 最少行数 
    const maxRows = autosize.maxRows; // 最大行数 
    // 控件自适应高度样式
    this.textareaCalcStyle = calcTextareaHeight(this.$refs.textarea, minRows, maxRows);
  }, 
}, 

calcTextareaHeight.js

类库导出方法calcTextareaHeight,用于动态计算文本域的高度样式。

// packages\input\src\calcTextareaHeight.js

let hiddenTextarea; //  一个临时的文本域元素

// 用于隐藏创建的临时的文本域元素 hiddenTextarea
const HIDDEN_STYLE = `
  height:0 !important; 
  // ...
`;

// 指定实例中文本域元素样式属性列表,获取属性值后用于创建临时的文本域元素
const CONTEXT_STYLE = [  
  'width',
   // ...
];

// 获取指定元素节点的样式
function calculateNodeStyling(targetElement) { 
  // ...
}

// 计算文本域的高度
export default function calcTextareaHeight(
  targetElement,
  minRows = 1,
  maxRows = null
) {
  // ...
};

calculateNodeStyling()

方法 calculateNodeStyling 用于获取实例文本域元素节点的样式,并计算box-sizing相关属性值。

  • contextStyle 复制当前实例元素的样式用于创建隐藏文本域。通过 window.getComputedStyle() 获取元素计算后/渲染后的所有 CSS 属性的值,然后获取数组CONTEXT_STYLE中指定属性值生成样式对象并转化字符串。

  • boxSizing 获取元素box-sizing 属性值。

  • paddingSize 获取元素上下内边距和。

  • borderSize 获取元素上下边框宽度和。

// 指定实例中文本域元素样式属性列表,获取属性值后用于隐藏文本域创建
const CONTEXT_STYLE = [ 
  'line-height',
  'padding-top',
  'padding-bottom', 
  'font-weight',
  'font-size', 
  'width',
   // ...
];

// 获取指定元素节点的样式
function calculateNodeStyling(targetElement) {
  // 获取元素计算后/渲染后的所有 CSS 属性的值
  const style = window.getComputedStyle(targetElement);
  // box-sizing 属性定义了如何计算一个元素的总宽度和总高度。
  const boxSizing = style.getPropertyValue('box-sizing');
  // 只是计算高度 获取上下内边距和
  const paddingSize = (
    parseFloat(style.getPropertyValue('padding-bottom')) +
    parseFloat(style.getPropertyValue('padding-top'))
  );
  // 只是计算高度 获取上下边框宽度和
  const borderSize = (
    parseFloat(style.getPropertyValue('border-bottom-width')) +
    parseFloat(style.getPropertyValue('border-top-width'))
  );
  // 获取元素指定的属性值并生成对象
  const contextStyle = CONTEXT_STYLE
    .map(name => `${name}:${style.getPropertyValue(name)}`)
    .join(';');

  return { contextStyle, paddingSize, borderSize, boxSizing };
}

calcTextareaHeight()

方法 calcTextareaHeight通过创建一个跟实例元素一样的临时文本域,用于计算出自适应高度。

  1. 创建临时文本域元素 hiddenTextarea,获取实例元素的内容和样式值、内容赋值给临时元素,使其作为实例元素的复制镜像。通过样式 HIDDEN_STYLE 用于隐藏临时元素使其不可见。

  2. 获取元素内容高度scrollHeight,根据不同box-sizing 属性计算出元素总高度。

  3. 计算出单行文本行高度singleRowHeight

  4. 如果设置minRows,计算出元素属性min-height值。

  5. 如果设置maxRows,计算出元素最大高度,实际 height 不能超过最大高度。

  6. 清除临时文本域元素。

  7. 返回计算结果,格式 { height:20px }{ height:20px; minHeight:20px; }

// 用于隐藏创建的临时的文本域元素 hiddenTextarea
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
`;

// 计算文本域内容高度
export default function calcTextareaHeight(
  targetElement,    // 实例文本域元素
  minRows = 1,      // 最小行数
  maxRows = null    // 最大行数
) {
  // 创建一个临时文本域,下面所有的计算都是在其上模拟的
  if (!hiddenTextarea) {
    hiddenTextarea = document.createElement('textarea');
    document.body.appendChild(hiddenTextarea);
  }
  // 获取当前实例元素样式信息
  let {
    paddingSize,
    borderSize,
    boxSizing,
    contextStyle
  } = calculateNodeStyling(targetElement);

  // contextStyle 让临时文本域元素样式跟实例元素保持一致  
  // HIDDEN_STYLE 用于隐藏临时文本域元素
  hiddenTextarea.setAttribute('style', `${contextStyle};${HIDDEN_STYLE}`);
  // 设置临时文本域内容
  hiddenTextarea.value = targetElement.value || targetElement.placeholder || '';

  // 临时文本域内容高度,包括由于溢出导致的视图中不可见内容。 
  let height = hiddenTextarea.scrollHeight;
  const result = {};
  
  // 计算文本内容高度 因为 scrollHeight 包括元素的 padding,但不包括元素的 border 和 margin。
  if (boxSizing === 'border-box') { 
    height = height + borderSize;    // border-box   加上上下边框宽度和
  } else if (boxSizing === 'content-box') { 
    height = height - paddingSize;   // content-box  减去上下内边距和
  }
  
  hiddenTextarea.value = '';  // 清空内容计算单行高度, 
  let singleRowHeight = hiddenTextarea.scrollHeight - paddingSize;   // 单行文本高度
  
  if (minRows !== null) { 
    let minHeight = singleRowHeight * minRows;          // 最小行数高度和
    if (boxSizing === 'border-box') {
      minHeight = minHeight + paddingSize + borderSize; // border + padding + 内容的高度
    }
    height = Math.max(minHeight, height);
    result.minHeight = `${ minHeight }px`;              // 设置样式 { minHeight:20px; }
  }
  if (maxRows !== null) {
    let maxHeight = singleRowHeight * maxRows;          // 最大行数高度和
    if (boxSizing === 'border-box') {
      maxHeight = maxHeight + paddingSize + borderSize; // border + padding + 内容的高度
    }
    height = Math.min(maxHeight, height);               // 选择最小高度           
  }
  result.height = `${height}px`;                        // 设置样式 { height:20px; }
  // 计算结束,清除临时文本域元素
  hiddenTextarea.parentNode && hiddenTextarea.parentNode.removeChild(hiddenTextarea);
  hiddenTextarea = null; 
  // 返回 { height:20px } 或 { height:20px; minHeight:20px; }
  return result;   
};

属性scrollHeight是一个元素内容高度,包括由于溢出导致的视图中不可见内容。包括元素的 padding,但不包括元素的 border 和 margin。

属性 box-sizing 定义了如何计算一个元素的总宽度和总高度。

  • content-box 默认值,标准盒子模型。widthheight 只包括内容的宽和高,不包括边框(border),内边距(padding),外边距(margin)。

    • width = 内容的宽度

    • height = 内容的高度

  • border-box widthheight 属性包括内容,内边距和边框,但不包括外边距。

    • width = border + padding + 内容的宽度

    • height = border + padding + 内容的高度

样式实现

组件样式源码 packages\theme-chalk\src\input.scss 使用混合指令嵌套生成组件样式。

// packages\theme-chalk\src\input.scss 

// 生成 .el-textarea
@include b(textarea) {
  // ...
  // 生成 .el-textarea__inner
  @include e(inner) {
    // ...
    // 生成 .el-textarea__inner::placeholder 
    &::placeholder { /* ... */ }
    // 生成 .el-textarea__inner:hover
    &:hover { /* ... */ }
    // 生成 .el-textarea__inner:focus
    &:focus { /* ... */ }
  }
  // 生成 .el-textarea .el-input__count
  & .el-input__count { /* ... */ }
  
  @include when(disabled) {
    // 生成 .el-textarea.is-disabled .el-textarea__inner
    .el-textarea__inner {
      // ...
      // 生成  .el-textarea.is-disabled .el-textarea__inner::placeholder 
      &::placeholder { /* ... */ }
    }
  }
  @include when(exceed) {
    // 生成 .el-textarea.is-exceed .el-textarea__inner
    .el-textarea__inner { /* ... */ }
    // 生成 .el-textarea.is-exceed .el-input__count
    .el-input__count { /* ... */ }
  }
}
// 生成 .el-input
@include b(input) {
  // ...
  // @include scroll-bar;
  // 生成 .el-input .el-input__clear
  & .el-input__clear {
    // ...
    // 生成 .el-input .el-input__clear:hover 
    &:hover { /* ... */ }
  }
  // 生成 .el-input .el-input__count 
  & .el-input__count {
    // ...
    // 生成  .el-input .el-input__count .el-input__count-inner  
    .el-input__count-inner { /* ... */ }
  }
  // 生成 .el-input__inner 
  @include e(inner) {
    // ...
    // 生成 .el-input__inner::-ms-reveal 
    &::-ms-reveal { /* ... */ }
    // 生成 .el-input__inner::placeholder 
    &::placeholder { /* ... */ }
    // 生成 .el-input__inner:hover 
    &:hover { /* ... */ }
    // 生成 .el-input__inner:focus  
    &:focus { /* ... */ }
  }
  // 生成 .el-input__suffix 
  @include e(suffix) { /* ... */ }
  // 生成 .el-input__suffix-inner 
  @include e(suffix-inner) { /* ... */ }
  // 生成 .el-input__prefix 
  @include e(prefix) { /* ... */ }
  // 生成 .el-input__icon 
  @include e(icon) {
    // ...
    // 生成 .el-input__icon:after 
    &:after { /* ... */ }
  }
  // 生成 .el-input__validateIcon  
  @include e(validateIcon) { /* ... */ }
  @include when(active) {
    // 生成.el-input.is-active .el-input__inner 
    .el-input__inner { /* ... */ }
  }
  @include when(disabled) {
    // 生成 .el-input.is-disabled .el-input__inner 
    .el-input__inner {
      // ...
      // 生成 .el-input.is-disabled::placeholder
      &::placeholder { /* ... */ }
    }
    // 生成 .el-input.is-disabled .el-input__icon  
    .el-input__icon { /* ... */ }
  }
  @include when(exceed) {
    // 生成 .el-input.is-exceed .el-input__inner 
    .el-input__inner { /* ... */ }
    
    .el-input__suffix {
      // 生成 .el-input.is-exceed .el-input__suffix .el-input__count  
      .el-input__count { /* ... */ }
    }
  }
  @include m(suffix) {
    // 生成 .el-input--suffix .el-input__inner 
    .el-input__inner { /* ... */ }
  }
  @include m(prefix) {
    // 生成 .el-input--prefix .el-input__inner 
    .el-input__inner { /* ... */ }
  }
  // 生成 .el-input--medium 
  @include m(medium) {
    // ...
    // 生成 .el-input--medium .el-input__inner  
    @include e(inner) { /* ... */ }
    // 生成 .el-input--medium .el-input__icon 
    .el-input__icon { /* ... */ }
  } 
  // small/mini ...
  
}
// 生成 .el-input-group 
@include b(input-group) {
  // ...
  // 生成 .el-input-group > .el-input__inner 
  > .el-input__inner { /* ... */ }
  // 生成 .el-input-group__append, .el-input-group__prepend
  @include e((append, prepend)) {
    // ...
    // 生成 .el-input-group__append:focus,.el-input-group__prepend:focus 
    &:focus { /* ... */ }
    
    /* 生成
    .el-input-group__append .el-button,
    .el-input-group__append .el-select,
    .el-input-group__prepend .el-button,
    .el-input-group__prepend .el-select */
    .el-select,.el-button { /* ... */ }
    
    /* 生成
    .el-input-group__append button.el-button,
    .el-input-group__append div.el-select .el-input__inner,
    .el-input-group__append div.el-select:hover .el-input__inner,
    .el-input-group__prepend button.el-button,
    .el-input-group__prepend div.el-select .el-input__inner,
    .el-input-group__prepend div.el-select:hover .el-input__inner */
    button.el-button,
    div.el-select .el-input__inner,
    div.el-select:hover .el-input__inner {
      // ...
    }
    
    /* 生成
    .el-input-group__append .el-button,
    .el-input-group__append .el-select,
    .el-input-group__prepend .el-button,
    .el-input-group__prepend .el-select */
    .el-button,.el-input { /* ... */ }
  }
  // 生成 .el-input-group__prepend 
  @include e(prepend) { /* ... */ }
  // 生成 .el-input-group__append 
  @include e(append) { /* ... */ }
  // 生成
  @include m(prepend) {
    // 生成 .el-input-group--prepend .el-input__inner 
    .el-input__inner { /* ... */ }
    // 生成 .el-input-group--prepend .el-select .el-input.is-focus .el-input__inner
    .el-select .el-input.is-focus .el-input__inner { /* ... */ }
  }

  @include m(append) {
    // 生成 .el-input-group--append .el-input__inner 
    .el-input__inner { /* ... */ }
    // 生成 .el-input-group--append .el-select .el-input.is-focus .el-input__inner 
    .el-select .el-input.is-focus .el-input__inner { /* ... */ }
  }
}

/** disalbe default clear on IE */
.el-input__inner::-ms-clear { /* ... */ }

混合指令scroll-bar定义如下:


/* Scrollbar
 -------------------------- */
@mixin scroll-bar {
  $--scrollbar-thumb-background: #b4bccc;
  $--scrollbar-track-background: #fff;

  &::-webkit-scrollbar {
    z-index: 11;
    width: 6px;

    &:horizontal {
      height: 6px;
    }

    &-thumb {
      border-radius: 5px;
      width: 6px;
      background: $--scrollbar-thumb-background;
    }

    &-corner {
      background: $--scrollbar-track-background;
    }

    &-track {
      background: $--scrollbar-track-background;

      &-piece {
        background: $--scrollbar-track-background;
        width: 6px;
      }
    }
  }
} 

📚参考&关联阅读

"getComputedStyle",MDN "CSS/box-sizing",MDN "Element/scrollHeight",MDN "表单输入绑定",vuejs "input_event",MDN "change_event",MDN "Input/text",MDN "String",MDN

最后更新于