textarea 高度自适应(扒element-plus源码)

2,823 阅读8分钟

目前谷歌浏览器 123 版本支持使用 field-sizing:content 直接实现高度自适应了!

详细参考:developer.chrome.com/docs/css-ui…

写在前面

我爱开源!!!!!

需求:使用 textarea 元素,实现高度自适应 —— 能够提前换行、可以指定最大高度。

GIF 22-7-13 18-51-09.gif

明明应该是一个很简单的功能,但是我愣是找了几个小时,都没找到一个让我满意的文章,并且能够实现的。我的脑中产生过好几个想法:

  • 是否可以利用一个元素撑开 textarea 实现高度自适应
  • 找找有没有伪元素会自动在 textarea 最后一个字符,然后获取该元素与右边框的距离
  • 创建一个元素,然后把内容复制过去,看看高度是多高,然后该根据该高度修改我们的高度
  • 是否可以获取到最后一行文本距离顶部的距离,然后通过该高度修改我们的高度
  • 通过计算字体大小、输入框宽度、然后计算出一行可以容纳多少字符、再计算已有字符从而确定高度

上面这些,基本都是想法,没有自己实际操作过(除了字体那个)。

然后,除了那个伪元素的,找不到这么一个伪元素外(有 first-letter first-line 而没有 last 属实有点强迫症),其他的基本都能找到对应的代码

textarea如何实现高度自适应? - 轩枫阁 – 前端开发 | web前端技术博客 (xuanfengge.com)

div模拟textarea文本域轻松实现高度自适应 « 张鑫旭-鑫空间-鑫生活 (zhangxinxu.com)

element-plus/utils.ts at dev · element-plus/element-plus (github.com)

倒是那个字体的,没有看到有人实现,所以我就尝试自己实现了一下,本来实现的感觉差不多了,结果还是“死掉”了

下面就给出我“失败”的经历吧

失败的思考过程

简单说明一下思路:确定一行最多能放 x 个字符,然后全部有 y 个字符,那么行数就是 y / x 行,根据这个关系修改 rows 不就可以了!

其中主要注意的就是,中文和数字字母之类的,所占用的“宽度”是不一样的,不过一般它们之间是 1:2 的关系,所以我们可以计算字符的字节数,从而将中文数字字母“一视同仁”。

其实,为了更加方便(“一刀切”),我们可以直接将那些不是标准 ASCII 的字符,默认当成两个字节,所以可以得到这么一个算法:

const a = '我a,'
console.log([0,...a].reduce((p,c) => c.charCodeAt()>10&&c.charCodeAt()<128 ? p+1 : p+2))

剩下的工作就是简单的计算问题了,大致说明一下:

有这么一个规律(单位均为 px)
输入框宽度为 w (width) 假定 w = 292
字体大小为 f (font-size) 假定 f = 16
一行可容纳的字符串字节数为 n (num),
比如 “一二三四五六七八九十一二三四五六七八” 字节数为 36
则存在
n <= w / f * 2
(36 < 36.5)

根据这么一个规律,可以实现高度自适应:

渲染完成时,输入框会固定
此时计算 w / f * 2 = y , y 表示一行最多可容纳的字节数,比如 即 y = 36.5
设当前行数为 x,比如 2
每次输入框变化时,计算 n 的值,
比如当前输入内容为 “一二三四五六七八九十一二三四五六七八1234”
即 n = 40
然后计算 n / y + 1 , 则可得到这段内容显示出来时的行数,该这个行数记作 z, 即z = 2
判断若 z >= rows ,则需要增加行数 z - x + 1(rows 是当前行数)
或者不用判断,直接设定当前行数为 z + 1 即可

这样一来,想要实现“提前换行”,只需要简单的让 n 变大就可以了。

a.gif

核心代码

const value = e.target.value
const style = window.getComputedStyle(e.target, null)
/* 加 10 是为了提前换行 */
const n = 10 + [0, ...value].reduce((p, c) => (c.charCodeAt() > 10 && c.charCodeAt() < 128 ? p + 1 : p + 2))
const w = parseInt(style['width']) - parseInt(style['border-left-width']) - parseInt(style['border-right-width']) - parseInt(style['padding-right']) - parseInt(style['padding-left'])
const f = parseInt(style['fontSize'])
const y = parseInt(w / f) * 2
const z = parseInt(n / y) + 1
this._rows = z

完整可在线运行代码

Vue SFC Playground (vuejs.org)

<script setup>
import { ref } from 'vue'

const _rows = ref(1)
let modelValue = ref('')

function input(e) {
  console.log(e.target)
  const value = e.target.value
  const style = window.getComputedStyle(e.target, null)
  /* 加 10 是为了提前换行 */
  const n = 10 + [0, ...value].reduce((p, c) => (c.charCodeAt() > 10 && c.charCodeAt() < 128 ? p + 1 : p + 2))
  const w = parseInt(style['width']) - parseInt(style['border-left-width']) - parseInt(style['border-right-width']) - parseInt(style['padding-right']) - parseInt(style['padding-left'])
  const f = parseInt(style['fontSize'])
  const y = parseInt(w / f) * 2
  const z = parseInt(n / y) + 1
  _rows.value = z
}

</script>

<template>
  <textarea class="transition" :rows="_rows" v-model="modelValue" @input="input" /> 
</template>

<style>
  textarea {
    width: 400px; /* 支持自适应次 width */
    word-break: break-word;
    white-space: pre-wrap;
    resize: none;
  }
</style>

a.gif

既然实现了,那为什么我又说它“死掉了”呢?这是因为,我这个方法默认了一行是能填满的!所以出现换行,我这个就失效了。但如果只是用户换行,其实也还可以,我可以检测到这个换行符,然后进行处理。

但问题是,输入框会自动对单词换行,比如这样:

image.png

这个可以通过设置 css 的样式 word-break: break-all; 解决

image.png

此时狗血的来了,当我设置了 word-break: break-all; 后,又出现一种新的情况了:

b.gif

鱼与熊掌不可兼得!所以最终这个方案“死掉”了

爱上开源

网上的文章没有我想要的,自己好不容易开始将自己想法落实到代码上时,又出现了“鱼与熊掌不可兼得”的情况,此时的我真的心灰意冷,但我就是不信邪,因为这个效果太常见的!许多 UI 组件库基本都会有这个。

对!UI组件库!于是我立马跑到 element-ui 中去找这个组件 Input 输入框 | Element Plus (element-plus.org)

果然,他们也实现了,虽然没有提前换行,但我就想看看这些UI组件库是怎么实现的,于是我进入他们的源代码仓库,最终找到了。

并发现,我的天,代码真的写的太优美了!!!这个优美,让我明白了什么叫做好的代码不需要注释!他们的代码真的非常贴合我的需求,而且用的方法 —— “将内容放到一个新的元素中计算高度,再返回该高度”。是我有考虑过的方法,但是我感觉这个太麻烦了,所以压根没去实现。

但没想到的是,他们实现了!而且实现的非常优雅!!为什么我感觉优雅呢?因为他们的代码,可以很简单的 ctrl c、ctrl v,然后就能跑了!

这种感觉对于我这个小白来说,真的很爽,就像是你 git clone 了一个仓库后,npm i 后直接点击运行,然后就完美的跑通了的那种感觉!!!

核心代码

Vue SFC Playground (vuejs.org)

// 这个是在 vue 中截取的,直接拷贝是跑不了了
calculateNodeStyling(targetElement) {
  const CONTEXT_STYLE = ['letter-spacing', 'line-height', 'padding-top', 'padding-bottom', 'font-family', 'font-weight', 'font-size', 'text-rendering', 'text-transform', 'width', 'text-indent', 'padding-left', 'padding-right', 'border-width', 'box-sizing']

  const style = window.getComputedStyle(targetElement)

  const boxSizing = style.getPropertyValue('box-sizing')

  const paddingSize = Number.parseFloat(style.getPropertyValue('padding-bottom')) + Number.parseFloat(style.getPropertyValue('padding-top'))

  const borderSize = Number.parseFloat(style.getPropertyValue('border-bottom-width')) + Number.parseFloat(style.getPropertyValue('border-top-width'))

  const contextStyle = CONTEXT_STYLE.map(name => `${name}:${style.getPropertyValue(name)}`).join(';')

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

calcTextareaHeight(targetElement, minRows = 1, maxRows) {
  const isNumber = v => {
    // return /^[0-9]+$/.test(v)
    return typeof v === 'number'
  }

  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;
  `

  let hiddenTextarea = document.createElement('textarea')
  document.body.appendChild(hiddenTextarea)

  const { paddingSize, borderSize, boxSizing, contextStyle } = this.calculateNodeStyling(targetElement)

  hiddenTextarea.setAttribute('style', `${contextStyle};${HIDDEN_STYLE}`)
  hiddenTextarea.value = targetElement.value || targetElement.placeholder || ''
  hiddenTextarea.value += '我爱开源'

  let height = hiddenTextarea.scrollHeight
  const result = {} // as TextAreaHeight

  if (boxSizing === 'border-box') {
    height = height + borderSize
  } else if (boxSizing === 'content-box') {
    height = height - paddingSize
  }

  hiddenTextarea.value = ''
  const singleRowHeight = hiddenTextarea.scrollHeight - paddingSize

  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.parentNode?.removeChild(hiddenTextarea)
  hiddenTextarea = undefined

  return { ...result, rows: parseInt(height / singleRowHeight) }
},

(
更新补充,上面的代码,有时候可能还是会出现有滚动条的情况,这是因为下面的代码中没有考虑到滚动条的宽度。

当设置了 box-sizingborder-box 的时候,滚动条的宽度会挤兑掉一些字到下一行,当行数过多时,挤兑掉的字数就足够凑成一行了,此时多出来的这一行, hiddenTextarea 是不知道的,所以会出现计算后的高度偏小,导致出现滚动条。

解决方法就是,配置 textarea 的 overflow: hidden 或者 box-sizing: content-box
)

总结

等我有时间了!一定要好好去看看一些大佬们的源码!!一直都感觉看源码是件很难的事情,因为很多源码“跳转”太多,一般都需要通过 debugger 去查看,对于我这种小白来说,为时尚早。但经过这一次扒源码,让我窥到了那些能够直接看懂大项目源码的大佬们的喜悦!如果能够看懂源码,真的是一件非常“爽”的事情!!!

最后简单说一下这次我是怎么找到我想要的源码的(过程简单到我自己都不敢相信)

  1. 进入组件页面 —— Input 输入框 | Element Plus (element-plus.org)
  2. 在目录中发现有源代码字样,点进去后跳转到github element-plus/packages/components/input at dev · element-plus/element-plus (github.com)

image.png

  1. 可以看到这样的目录结构,进去 src (不用我解释了吧,源代码一般都放在 src)
├─index.ts
├─src
├─style
└─_tests_

4. src 下有三个文件,为了方便查看,我们在浏览器中点击键盘上的 按键,可以在网页 vscode 中打开该仓库(如果快捷键无效,直接将网页链接中的 github.com 改为 github.dev 即可)

├─index.ts
├─input.vue
└─utils.ts

image.png

  1. 因为组件是在 vue 中的,所以我选择打开 input.vue,然后发现代码有点多,500 多行

  2. 此时回到 element-plus 文档中,找到我们要的组件的源代码,发现关键字 autosize

image.png

  1. 回到 index.vue 文件中,搜索 autosize

image.png

  1. 然后发现代码很容易理解,如果设置了 autosize,则计算 calcTextareaHeight!!!calcTextareaHeight 这个单词还需要解释吗?这就是我们想要的!!!

  2. 后面的就简单了,在 utils.ts 中找到了 calcTextareaHeight 后,一目了然!!!无需任何注释,你就能看明白代码是什么意思!原来这就是优秀的代码!!!
    utils.ts - element-plus [GitHub] - Visual Studio Code - GitHub

image.png