[Element plus]源码学习---Input输入框

988 阅读7分钟

1、结构

由下图可见,其实输入框组成部分其实若干个插槽配合input所使用的。本文input的类型一共分为两种,一种是input一种是textarea。但是他们都离不开这个结构。prepend、append、prefix-icon、suffix-icon是分别为 prepend slot、prefix slot、 suffix slot、append slot的插槽 image.png

总体结构

总体来看,整个组件也是非常好理解的,第一个div盒子就是包裹全部, 属性大致是一些命名空间,继承样式,以及鼠标事件...,里面有两个template,分别用来装input和textarea情况的 image.png

input类型结构

image.png 我们来逐一对应代码 image.png

textarea类型结构

image.png

2、Input-非textarea情况

hovering

hovering:hover状态,用于判断是否显示清空图标,状态修改的事件,一般多为handleMouseLeave,handleMouseEnter 给大家介绍另外一个状态 showClear:显示清空图标按钮

判断是否传递了clearable 是否被禁用了 是否只读 是否聚焦或者处于hover状态 image.png

关于计算图标偏移问题

image.png 关于图标是如何恰巧的放在input的左侧和右侧的,在这里我们可以看到详细的结构代码 image.png prefix-icon,suffix-icon 的位置受prepend 、 append 的影响,请看下面代码。

image.png

image.png

focus

focus 状态,用于判断 清空图标的显示、密码查看图标。状态修改的事件 为 focus,blur

image.png

image.png

isComposing

isComposing:正在输入状态,用于中文输入时,正在输入,但文本未确认时。此时也触发input事件,但是此时不应该修改v-model值

状态改变的事件 input:

compositionstart  文字输入之前触发

compositionupdate 输入过程中每次敲击键盘触发

compositionend 选择字词完成时触发

注册这三个事件的原因在于实现中文输入法下,仅在选词后触发 input 事件。

由于在输入拼音的时输入框不是立即获得输入的值,而是要确实后才能获取到

触发compositionstart时,文本框会填入待确认文本,同时触发 input 事件;如果不想触发 input ,需设置一个变量来控制。

image.png

value值的更新

纠正一下,我们都会遇到的一道面试题是el-input的值是如何更新的,很多小伙伴都会说是v-model。但是这个答案吧,深层的阅读源码后,我答案是不是。在源码里面是通过:value + @input事件 来进行值更新 在源码里面当value发生变化的时,也需要及时地去调用setNativeInputValue以达到更新当前组件地value

// 动态计算值
const _ref = computed(() => input.value || textarea.value)

// 更新值调用的函数
const setNativeInputValue = () => {
  const input = _ref.value
  if (!input || input.value === nativeInputValue.value) return
  input.value = nativeInputValue.value
}
// 监听nativeInputValue 如果变化则调用更新函数
watch(nativeInputValue, () => setNativeInputValue())

// when change between <input> and <textarea>,
// update DOM dependent value and styles
// https://github.com/ElemeFE/element/issues/14857
watch(
  () => props.type,
  async () => {
    await nextTick()
    setNativeInputValue()
    resizeTextarea()
    updateIconOffset()
  }
)

onMounted(() => {
  if (!props.formatter && props.parser) {
    debugWarn(
      'ElInput',
      'If you set the parser, you also need to set the formatter.'
    )
  }
  setNativeInputValue()
  updateIconOffset()
  nextTick(resizeTextarea)
})

//输入触发方法handleInput()
const handleInput = async (event: Event) => {
  recordCursor()

  let { value } = event.target as TargetElement

  if (props.formatter) {
    value = props.parser ? props.parser(value) : value
    value = props.formatter(value)
  }

  // should not emit input during composition
  // see: https://github.com/ElemeFE/element/issues/10516
  if (isComposing.value) return

  // hack for https://github.com/ElemeFE/element/issues/8548
  // should remove the following line when we don't support IE
  if (value === nativeInputValue.value) {
    setNativeInputValue()
    return
  }

  emit(UPDATE_MODEL_EVENT, value)
  emit('input', value)

  // ensure native input value is controlled
  // see: https://github.com/ElemeFE/element/issues/12850
  await nextTick()
  setNativeInputValue()
  setCursor()
}

根据我们前面的了解到在input选择字词完成时触发compositionend()方法是会调用到handleInput() 方法的 而当value发生变化时,handleInput()会及时地调用setNativeInputValue() 以达到更新当前组件的value 但事实上除了在handleInput()时调用setNativeInputValue()是远远不够的 所以我们需要通过监听以及onMounted生命周期去调用setNativeInputValue()去更新值

show-password

show-password 显示密码,可省略type=‘password’的用法,先看文档上所介绍的

image.png 其中切换的两种形态 image.png 我记得我的老师曾经跟我说过一句话:遇到不会的先去看官方文档,文档这里说了这是一个布尔值,默认是false,如果你给了这个属性,就是true image.png 值得注意的是,show-password只有在type=password才生效的,用例上也有说明,证明是搭配使用的,我们看看下方代码截图, 这里:type是用三元作了一层判断,这个passwordVisible就是我们等一下需要探讨的东西 image.png

// 是否显示密码,用于input type(password类型)状态切换,事件触发click密码查看图标
const passwordVisible = ref(false)
const handlePasswordVisible = () => {
//密码显示与隐藏
  passwordVisible.value = !passwordVisible.value
  //当切换时 光标位置置于最后的方法
  focus()
}
const focus = async () => {
  // see: https://github.com/ElemeFE/element/issues/18573
  await nextTick()
  //密码显示与隐藏的切换 使光标位置置后,使用 nextTick()可等页面更新后再进行聚焦(显示密码后再聚焦,焦点在文字后面)
  _ref.value?.focus()
}

$attrs与useAttrs()运用

包含了父作用域中不作为组件 props 或 自定义事件。当一个组件没有声明任何 prop 时,这里会包含所有父级作用域的绑定,并且可以通过 v-bind="$attrs" 传入内部组件——在创建高阶的组件时非常有用。

这里可以插播一条面试题诶

vue中常见的组件通信方式:
父子通信(部分可用来作为爷孙通信)
     1、父传子props,子传父用$emit
     2、通过$parent/$children。//但是这个方法其实在我接触到真实项目之中是不推荐使用的,因为很容易出bug
     3、provide/inject
     4、$attrs/$listeners
 兄弟通信
     1、$parent/$children
     2、vuex
     3、中央事件总线eventBus

$attrs 是在vue的2.4版本以上添加的,项目中如果有多层组件传参可以使用$attrs,可以使代码更加美观,更简洁,维护更方便。如果使用传统的父子传参prop$emit传参,$on会很繁琐;如果使用vuex会大材小用,只是在这几个组件里面使用就没有必要使用vuex,使用eventBus,如果使用不恰当就可能会引起出现多次事件执行. 作用:$attrs可父子孙一脉相传的组件间的通信 eg: 父子通信:如果父子组件之间通讯 在父组件传给子组件的数据,可用props来进行接收。如果不用props接收,父组件传过来的数据会在$attrs里面 父子孙通信:父组件的传给子组件的数据,子组件可以用props来接收,props没有接收完的数据会在$attrs中,孙组件要想接收这个数据可以在子组件加入v-bind='$attrs',来传给孙组件,这样孙子组件就可以接收它爷爷传过来的数据了。 $attrs的方便之处在于数据是一层一层传下去,不用每一层都用props接收之后再传给后面的组件,可以直接加一个v-bind:'$attrs'

在我们vue3里面在原先,可以通过该 API 来获取组件的上下文信息,包含了 attrs 、slots 、emit、expose 等父子组件通信数据和方法,该 API 将在 3.2 版本之后删除,context 里面的数据,会用新的 useSlots 和 useAttrs API 来代替。 useAttrs 可以是用来获取 attrs 数据的(也就是非 props 的属性值)。如果当前组件里没有将某个属性指定为 props,那么父组件绑定下来的属性值,都会进入到 attrs 里,通过这个新 API 来拿到。

<script lang="ts" setup>
import { useAttrs as useRawAttrs} from 'vue',//有删减哦,源码里不止引用那么点
import { useAttrs } from '@element-plus/hooks'//有删减哦,源码里不止引用那么点
// useAttrs props穿透
const rawAttrs = useRawAttrs()
// 目的是返回除了class style 及其他事件的属性
const containerAttrs = computed(() => {
  const comboBoxAttrs: Record<string, unknown> = {}
  if (props.containerRole === 'combobox') {
    comboBoxAttrs['aria-haspopup'] = rawAttrs['aria-haspopup']
    comboBoxAttrs['aria-owns'] = rawAttrs['aria-owns']
    comboBoxAttrs['aria-expanded'] = rawAttrs['aria-expanded']
  }
  return comboBoxAttrs
})

const attrs = useAttrs({
  excludeKeys: computed<string[]>(() => {
    return Object.keys(containerAttrs.value)
  }),
})

</script>
image.png 我们点进去看useAttrs()

interface Params {
  excludeListeners?: boolean
  excludeKeys?: ComputedRef<string[]>
}
// 默认排除的key值
const DEFAULT_EXCLUDE_KEYS = ['class', 'style']
//侦听器前缀
const LISTENER_PREFIX = /^on[A-Z]/

export const useAttrs = (
  params: Params = {}
): ComputedRef<Record<string, unknown>> => {
  const { excludeListeners = false, excludeKeys } = params
  const allExcludeKeys = computed<string[]>(() => {
    return (excludeKeys?.value || []).concat(DEFAULT_EXCLUDE_KEYS)
  })
// .concat是为 排除key的数组
// 以下是 使其变成响应式,可监听变化  ↓↓
  const instance = getCurrentInstance()
  if (!instance) {
    debugWarn(
      'use-attrs',
      'getCurrentInstance() returned null. useAttrs() must be called at the top of a setup function'
    )
    return computed(() => ({}))
  }
// 返回
  return computed(() =>
    fromPairs(
    // 这里返回一个 二维数组 [{key,value},{key,value}]根据变化修改attrs
      Object.entries(instance.proxy?.$attrs!).filter(
        ([key]) =>
          !allExcludeKeys.value.includes(key) &&
          !(excludeListeners && LISTENER_PREFIX.test(key))
      )
    )
  )
}

这里顺便还想讲讲debugWarn的原理,也就是我们日常看到在控制台报错的信息里面的源码信息

import { isString } from './types'

class ElementPlusError extends Error {
  constructor(m: string) {
    super(m)
    this.name = 'ElementPlusError'
  }
}

export function throwError(scope: string, m: string): never {
  throw new ElementPlusError(`[${scope}] ${m}`)
}

export function debugWarn(err: Error): void
export function debugWarn(scope: string, message: string): void
export function debugWarn(scope: string | Error, message?: string): void {
  if (process.env.NODE_ENV !== 'production') {
    const error: Error = isString(scope)
      ? new ElementPlusError(`[${scope}] ${message}`)
      : scope
    // eslint-disable-next-line no-console
    // 最后在这里标红console出来
    console.warn(error)
  }
}

- Textarea 自适应高度

textarea类型的自适应高度无非跟autosize有关,整理翻出了一些源码解释一下原理给大家看

import { calcTextareaHeight } from './utils'
...
// onMounted的时候调用
onMounted(() => {
 // 此处有删减一些跟章节无关调用的方法
  nextTick(resizeTextarea)
})
// 监听
watch(
  () => props.type,
  async () => {
    resizeTextarea()
  }
)
watch(
  () => props.modelValue,
  () => {
    nextTick(() => resizeTextarea()
  }
)
// resizeTextarea
const resizeTextarea = () => {
  const { type, autosize } = props

  if (!isClient || type !== 'textarea') return

  if (autosize) {
  // 传入autosize设置的值的情况
    const minRows = isObject(autosize) ? autosize.minRows : undefined
    const maxRows = isObject(autosize) ? autosize.maxRows : undefined
    textareaCalcStyle.value = {
      ...calcTextareaHeight(textarea.value!, minRows, maxRows),
    }
  } else {
  // 不设置内容自动调整高度的情况(让其自动撑开)
    textareaCalcStyle.value = {
      minHeight: calcTextareaHeight(textarea.value!).minHeight,
    }
  }
}
// utils.ts内calcTextareaHeight()函数

export function calcTextareaHeight(
  targetElement: HTMLTextAreaElement,
  minRows = 1,
  maxRows?: number
): TextAreaHeight {
  if (!hiddenTextarea) {
    hiddenTextarea = document.createElement('textarea')
    document.body.appendChild(hiddenTextarea)
  }
// targetElement 内容,minRow:最小行数(默认值为1), maxRows? 可传可不传的最大行数(数字型)
  const { paddingSize, borderSize, boxSizing, contextStyle } =
    calculateNodeStyling(targetElement)
// 创建一个隐藏样式的textarea 并添加到body上 并设置行内样式以及隐藏样式
  hiddenTextarea.setAttribute('style', `${contextStyle};${HIDDEN_STYLE}`)
  hiddenTextarea.value = targetElement.value || targetElement.placeholder || ''
// 获取真实高度 => 获取此隐藏textarea的scrollHeight
  let height = hiddenTextarea.scrollHeight
  const result = {} as TextAreaHeight
//这里是一些boxsizing是border-box还是content-box来决定高度是如何计算的(获取真是height)
  if (boxSizing === 'border-box') {
    height = height + borderSize
  } else if (boxSizing === 'content-box') {
    height = height - paddingSize
  }
// 计算单行高度 => 通过清空内容 获取单行高度
  hiddenTextarea.value = ''
  const singleRowHeight = hiddenTextarea.scrollHeight - paddingSize
// isNumber() 校验输入的是否为数字型
//通过 minRows和maxRows去得到minHeight和height
  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并重置
  hiddenTextarea.parentNode?.removeChild(hiddenTextarea)
  hiddenTextarea = undefined

  return result
}

以上是input框的相关内容-后续也会更新哒!!!大家如果有什么问题可以评论区提问我会回答哒,关于inputNumber类型请看另外一篇文档。多谢大家支持,谢谢谢谢谢谢