返回

React Textarea 动态高度实现:自动伸缩与高度限制

javascript

React 实现动态高度 Textarea:能伸也能缩,还能设上限

写 React 应用时,常遇到一个需求:做一个能自动调整高度的输入框 (textarea),它得像个听话的小家伙,开始时只有一行高,输入内容多了就自动“长高”,但也不能无限长,比如最多长到 4 行的高度,再多内容就该出现滚动条了。更棒的是,如果用户删除了内容,它还能自动“缩回去”。

听起来挺美好,对吧?但实际操作起来,可能会遇到点小麻烦。就像下面这段代码,它能实现“长高”并在达到最大高度时停止,可一旦用户开始删除文字,输入框的高度却赖着不走,不会跟着缩短。

// 这个是有点问题的初始尝试代码
export class Foo extends React.Component {
  constructor(props) {
    super(props);
    // 初始高度,比如单行的高度
    this.state = {
      textareaHeight: 38 // 假设 38px 是一行的高度
    };
  }

  handleKeyUp(evt) {
    // 目标:最小 38px,最大 75px (假设约 4 行)
    // scrollHeight 包含了 padding,offsetHeight 不包含滚动条但包含 padding 和 border
    // +2 是为了稍微缓冲一下,避免滚动条过早出现(这个值可能需要微调)
    let newHeight = Math.max(Math.min(evt.target.scrollHeight + 2, 75), 38);
    // 只有高度需要变化时才更新 state,避免不必要的重渲染
    if (newHeight !== this.state.textareaHeight) {
      this.setState({
        textareaHeight: newHeight
      });
    }
  }

  render() {
    // 把 state 里的高度应用到 style 上
    let textareaStyle = { height: `${this.state.textareaHeight}px` };
    return (
      <div>
        {/* 注意这里用了 bind(this),在 class 组件里是必要的 */}
        <textarea onKeyUp={this.handleKeyUp.bind(this)} style={textareaStyle}/>
      </div>
    );
  }
}

这段代码的问题出在哪?为啥删内容的时候,高度不跟着变小呢?

问题根源分析:scrollHeight 的“倔脾气”

核心问题在于 scrollHeight 这个属性的行为,以及它和我们用 style.height 设置的 CSS 高度的互动方式。

  1. scrollHeight 是啥? 它表示一个元素内容的实际总高度,包括那些由于溢出而在屏幕上看不见的部分。如果元素没有垂直滚动条,scrollHeight 大致等于 clientHeight
  2. 为啥它不缩小? 当你输入内容,textarea 的内容撑开了容器,scrollHeight 自然会增加。我们的代码读取这个增加的 scrollHeight,然后通过 setState 更新 style.height,让输入框看起来“长高”了。这没问题。
  3. 关键的来了: 当输入框的高度被 style.height 固定 在最大值(比如 75px)后,即使你开始删除文字,只要内容的总高度(即使变少了)仍然 大于或等于 这个固定的 75px,scrollHeight 就不会变得小于 75px。因为 textarea 元素的可见高度已经被 CSS 定死了,浏览器计算 scrollHeight 时,会认为“哦,这个容器就这么高,内容超出的部分就滚动吧”,它反映的是“如果解除高度限制,内容需要多高”,而不是“当前可见区域应该多高”。只有当你删除足够多的文字,使得内容所需的实际高度 真的小于 75px 了,scrollHeight 才会开始减小。
  4. 死循环了: 我们的代码在 handleKeyUp 里,读取 scrollHeight(它还停留在比较大的值),计算出的 newHeight 依然是最大值 75px,于是 state 不更新,style.height 也就一直保持在 75px。输入框自然就不会缩小了,除非你删到内容连 75px 都撑不满为止。

简单说,就是 style.height 这个“显式命令”干扰了 scrollHeight 对“实际内容所需高度”的准确反映,尤其是在需要缩小的场景下。

解决方案:让 Textarea 乖乖伸缩

要解决这个问题,关键在于获取 不受当前 CSS height 限制 的内容实际所需高度。有几种常见的思路:

方案一:先重置,再测量(推荐)

这是最常用也比较稳妥的方法。核心思想是在计算新高度 之前,临时把 textareaheight 设置成一个很小的值或者 auto,强制浏览器重新计算布局,这时候读取 scrollHeight 就能得到内容真正需要的高度(不受之前设定的最大高度影响)。然后,再用这个真实的 scrollHeight 来计算最终应该设置的 height(当然,还是要限制在最小值和最大值之间)。

原理:

  1. 用户输入或删除文字,触发事件(比如 onInputonChange,通常比 onKeyUp 更适合实时响应内容变化)。
  2. 在事件处理函数里,先拿到 textarea 的 DOM 元素引用(用 useRef Hook)。
  3. 关键一步: 立刻把 textareastyle.height 设置为 'auto' 或者一个很小的值(比如 '1px''inherit')。这相当于告诉浏览器:“先别管我之前多高,按内容重新算一下”。
  4. 紧接着: 立即 读取此刻的 textarea.scrollHeight。由于上一步重置了高度限制,这个 scrollHeight 现在就能准确反映内容的实际高度了。
  5. 用这个新的 scrollHeight 计算最终的高度,确保它在你的 minHeightmaxHeight 范围内 (Math.max(minHeight, Math.min(scrollHeight, maxHeight)))。
  6. 把计算出的最终高度再设置回 textareastyle.height

代码示例 (使用 React Hooks):

import React, { useState, useRef, useCallback } from 'react';

function DynamicTextarea({ minHeight = 38, maxHeight = 75 }) {
  const [textareaHeight, setTextareaHeight] = useState(minHeight);
  const textareaRef = useRef(null);

  const handleChange = useCallback((event) => {
    const textarea = textareaRef.current;
    if (!textarea) return;

    // 1. 先重置高度,让 scrollHeight 能反映真实内容高度
    textarea.style.height = 'auto'; 
    // 也可以用 'inherit' 或一个足够小的值如 '1px','auto' 通常更直接

    // 2. 获取真实的 scrollHeight
    const scrollH = textarea.scrollHeight;

    // 3. 计算新高度,并限制在 min/max 范围内
    // 注意:如果 textarea 的 box-sizing 是 border-box,scrollHeight 可能包含了 padding 和 border
    // 如果是 content-box,可能需要加上 padding。通常建议设置 box-sizing: border-box;
    const newHeight = Math.max(minHeight, Math.min(scrollH, maxHeight));

    // 4. 设置最终的高度
    textarea.style.height = `${newHeight}px`;

    // (可选)如果需要将高度值存入 state (比如其他地方要用)
    // setTextareaHeight(newHeight); 
    // 注意:如果直接操作 style,上面这行可以省略,除非其他组件依赖这个 state
    // 直接操作 DOM style 比频繁更新 state 性能可能更好一点点,避免不必要的重渲染

    // 处理 overflow 样式,当达到最大高度时显示滚动条
    if (newHeight >= maxHeight) {
      textarea.style.overflowY = 'auto'; 
    } else {
      textarea.style.overflowY = 'hidden'; // 低于最大高度时隐藏滚动条
    }

    // (如果用了受控组件,还需要更新 value state)
    // setValue(event.target.value);

  }, [minHeight, maxHeight]);

  // 初始渲染时可能也需要调整一次高度,特别是如果有初始值
  // 可以用 useEffect 实现,依赖于初始值 prop

  return (
    <textarea
      ref={textareaRef}
      style={{
        height: `${textareaHeight}px`, // 初始高度或由 state 控制
        minHeight: `${minHeight}px`, // CSS 最小值约束
        maxHeight: `${maxHeight}px`, // CSS 最大值约束(可选,JS会处理)
        overflowY: 'hidden',        // 默认隐藏滚动条
        resize: 'none',             // 禁止用户手动拖拽调整大小
        boxSizing: 'border-box',    // 推荐!让高度计算包含 padding 和 border
      }}
      onChange={handleChange} // 使用 onChange 或 onInput
      // placeholder="输入内容..." 
      // defaultValue="初始内容..." // 如果有初始内容
    />
  );
}

export default DynamicTextarea;

关键点和建议:

  • 事件选择: 使用 onChangeonInput 通常比 onKeyUp 更好,因为它们能捕捉到粘贴、拖拽等非键盘输入导致的内容变化。
  • useRef 必须用 useRef 来直接访问和操作 DOM 元素的 stylescrollHeight
  • useCallback 包裹事件处理函数,避免在父组件重渲染时不必要地创建新函数。
  • CSS box-sizing: border-box; 强烈建议给 textarea 设置这个样式。这样 heightscrollHeight 的计算就都包含了 paddingborder,逻辑更统一简单,不容易出错。否则,你可能需要手动在计算时加减 padding 值。
  • CSS overflowY: 'hidden'/'auto' 在 JS 中根据是否达到 maxHeight 动态切换 overflowY 样式,可以更平滑地控制滚动条的出现与隐藏。设置 CSS max-height 也能辅助限制,但 JS 控制 overflow 更精确。
  • CSS resize: none; 禁用浏览器默认的右下角拖拽调整大小功能,因为我们是自动调整。
  • 初始高度: 如果 textarea 有默认值 (initial value),可能需要在组件首次挂载时 (useEffectuseLayoutEffect) 就执行一次高度调整逻辑。
  • 性能: 对于极快速的输入,如果觉得高度调整有点卡顿(虽然通常不会),可以考虑用 debouncethrottle 来包装 handleChange 函数,减少触发频率。但要注意别延迟太厉害影响用户体验。
  • useLayoutEffect vs useEffect handleChange 里的 DOM 操作(设置 height)是同步发生的。如果遇到极其罕见的闪烁问题(理论上,因为是同步操作 DOM,应该还好),可以考虑将逻辑移到 useLayoutEffect 中,它会在浏览器绘制前同步执行,确保 DOM 更新和绘制是一致的。但在本场景下,useCallback 里的同步操作通常足够。

方案二:使用“镜像” Textarea 测量

这个方法稍微“曲线救国”一点,但也能有效解决问题。思路是创建一个隐藏的、跟可见 textarea 样式完全一样的“镜像” textarea。这个镜像 textarea 没有高度限制。我们把可见 textarea 的内容实时同步到镜像 textarea 里,然后读取镜像 textareascrollHeight,用这个值来设置可见 textarea 的高度。

原理:

  1. 在你的组件 render (或 JSX) 中,创建两个 textarea 元素。
  2. 可见 Textarea: 用户实际交互的那个,应用我们最终计算出的 height (带 min/max 限制) 和 overflow 样式。
  3. 隐藏 Textarea(镜像):
    • 通过 CSS 将其彻底隐藏(比如 position: absolute; left: -9999px; top: -9999px; visibility: hidden; height: auto;)。不能用 display: none,因为那样无法获取 scrollHeight
    • 关键是,它的样式(字体、字号、paddingborder、宽度 width 等影响布局的属性)必须和可见 textarea 完全一致。宽度尤其重要!
    • 它的 height 设置为 auto,并且没有 max-height 限制。
  4. 当用户在可见 textarea 中输入时 (onChange/onInput):
    • 获取输入的值。
    • 将这个值 同时 设置给可见 textarea (如果是受控组件) 和隐藏的镜像 textarea
    • 读取 隐藏 textareascrollHeight。因为镜像没有高度限制,它的 scrollHeight 能准确反映内容所需的完整高度。
    • 使用镜像的 scrollHeight 来计算可见 textarea 的最终 height(应用 min/max 限制)。
    • 将计算出的高度设置给可见 textareastyle.height

代码示例 (概念性):

import React, { useState, useRef, useCallback, useLayoutEffect } from 'react';

function DynamicTextareaMirror({ minHeight = 38, maxHeight = 75, initialValue = '' }) {
  const [value, setValue] = useState(initialValue);
  const [visibleHeight, setVisibleHeight] = useState(minHeight);
  const visibleTextareaRef = useRef(null);
  const mirrorTextareaRef = useRef(null);

  const updateHeight = useCallback(() => {
    const mirror = mirrorTextareaRef.current;
    const visible = visibleTextareaRef.current;
    if (!mirror || !visible) return;

    // 确保镜像宽度与可见 textarea 一致 (如果可见的宽度是动态的,这里要同步)
    // mirror.style.width = `${visible.offsetWidth}px`; // 如果宽度可能变化,需要这样做

    // 读取镜像的 scrollHeight
    const scrollH = mirror.scrollHeight;

    // 计算并应用高度到可见 textarea
    const newHeight = Math.max(minHeight, Math.min(scrollH, maxHeight));
    setVisibleHeight(newHeight);

    // 根据是否达到最大高度设置 overflow
    visible.style.overflowY = (newHeight >= maxHeight) ? 'auto' : 'hidden';

  }, [minHeight, maxHeight]);

  const handleChange = (event) => {
    const newValue = event.target.value;
    setValue(newValue);

    // 将新值也设置到镜像 textarea (触发其 scrollHeight 更新)
    if (mirrorTextareaRef.current) {
      mirrorTextareaRef.current.value = newValue;
    }

    // 请求高度更新 (可以立即调用,或者在 useLayoutEffect/useEffect 中依赖 value)
    updateHeight(); 
  };

  // 在值变化后同步执行高度计算 (用 useLayoutEffect 避免闪烁)
  useLayoutEffect(() => {
    updateHeight();
  }, [value, updateHeight]); // 依赖 value 和 updateHeight 函数

  // 确保初始渲染时也设置正确的宽度给镜像 (如果需要) 和计算初始高度
  useLayoutEffect(() => {
    const visible = visibleTextareaRef.current;
    const mirror = mirrorTextareaRef.current;
    if(visible && mirror){
        // 同步初始宽度 (如果可见宽度非固定)
        // mirror.style.width = `${visible.offsetWidth}px`; 
    }
    updateHeight(); 
    // eslint-disable-next-line react-hooks/exhaustive-deps 
  }, [minHeight, maxHeight]); // 首次挂载时


  const commonStyles = {
    // 必须保持一致的样式:
    fontFamily: 'inherit', // 或者指定字体
    fontSize: 'inherit',   // 或者指定字号
    fontWeight: 'inherit',
    lineHeight: 'inherit', // 很重要!
    padding: '10px',       // 示例 padding,保持一致
    border: '1px solid #ccc', // 示例 border,保持一致
    boxSizing: 'border-box', // 强烈推荐!
    width: '300px',         // 示例固定宽度,保持一致
  };

  const mirrorStyle = {
    ...commonStyles,
    position: 'absolute',
    left: '-9999px',
    top: '-9999px',
    visibility: 'hidden',
    height: 'auto',       // 关键:高度自适应
    overflow: 'hidden',   // 镜像不需要滚动条
  };

  const visibleStyle = {
    ...commonStyles,
    height: `${visibleHeight}px`,
    minHeight: `${minHeight}px`, // CSS 约束
    maxHeight: `${maxHeight}px`, // CSS 约束 (可选,JS 控制更精确)
    resize: 'none',
    // overflowY 由 JS 控制 (在 updateHeight 或直接 style 里设置)
  };


  return (
    <div>
      <textarea
        ref={visibleTextareaRef}
        style={visibleStyle}
        value={value}
        onChange={handleChange}
        placeholder="输入内容..."
      />
      {/* 镜像 Textarea */}
      <textarea
        ref={mirrorTextareaRef}
        style={mirrorStyle}
        value={value} // 同步 value
        readOnly      // 不需要交互
        tabIndex={-1} // 不参与 tab 顺序
        aria-hidden="true" // 辅助技术忽略
      />
    </div>
  );
}

export default DynamicTextareaMirror;

优缺点:

  • 优点: 原理清晰,逻辑相对直接,scrollHeight 的读取很“干净”。
  • 缺点:
    • 需要额外渲染一个 DOM 元素,理论上轻微增加内存和渲染开销(虽然现代浏览器优化得很好,影响通常可忽略)。
    • 维护两个 textarea 样式一致性是关键,忘了同步某个影响布局的样式(如 padding, border, line-height, font-size, width)就会导致计算错误。
    • 代码结构稍微复杂一点。

选择哪个方案?

方案一(重置再测量)通常是首选 ,因为它更简洁,直接操作目标元素,性能也很好。大多数情况下它都能完美工作。

只有在极少数情况下,比如遇到某些奇怪的浏览器 bug 或与其他 JS 库冲突,导致方案一的临时重置高度行为出现问题时,才可能考虑方案二作为备选。

无论选择哪种方案,记得处理好 minHeight, maxHeight 的边界条件,以及适时显示/隐藏滚动条,还有推荐使用 box-sizing: border-box。这样就能得到一个既能自动长高,又能适时缩回,还能优雅处理内容溢出的动态 textarea 了。