Element 2 组件源码剖析之Input输入框(下)

1,427 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第19天,点击查看活动详情

简介

书接上文,将继续深入分析组件的文本域功能实现,耐心读完,相信会对您有所帮助。

为了更好的理解本文,请先阅读以下文章

  1. Input输入框(上) 单行输入实现
  2. Input输入框(中) 组件生命周期/事件
  3. Input输入框(下) 文本域 textarea

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

本专栏的 gitbook 版本地址已经发布 📚《learning element-ui》 ,内容同步更新中!

文本域 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 默认值,标准盒子模型。width与 height 只包括内容的宽和高,不包括边框(border),内边距(padding),外边距(margin)。
    • width = 内容的宽度
    • height = 内容的高度
  • border-box width与 height 属性包括内容,内边距和边框,但不包括外边距。
    • 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

关注专栏

如果本文对您有所帮助请关注➕、 点赞👍、 收藏⭐!您的认可就是对我的最大支持!

此文章已收录到专栏中 👇,可以直接关注。