React Textarea 动态高度实现:自动伸缩与高度限制
2025-04-09 07:17:08
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 高度的互动方式。
scrollHeight
是啥? 它表示一个元素内容的实际总高度,包括那些由于溢出而在屏幕上看不见的部分。如果元素没有垂直滚动条,scrollHeight
大致等于clientHeight
。- 为啥它不缩小? 当你输入内容,
textarea
的内容撑开了容器,scrollHeight
自然会增加。我们的代码读取这个增加的scrollHeight
,然后通过setState
更新style.height
,让输入框看起来“长高”了。这没问题。 - 关键的来了: 当输入框的高度被
style.height
固定 在最大值(比如 75px)后,即使你开始删除文字,只要内容的总高度(即使变少了)仍然 大于或等于 这个固定的 75px,scrollHeight
就不会变得小于 75px。因为textarea
元素的可见高度已经被 CSS 定死了,浏览器计算scrollHeight
时,会认为“哦,这个容器就这么高,内容超出的部分就滚动吧”,它反映的是“如果解除高度限制,内容需要多高”,而不是“当前可见区域应该多高”。只有当你删除足够多的文字,使得内容所需的实际高度 真的小于 75px 了,scrollHeight
才会开始减小。 - 死循环了: 我们的代码在
handleKeyUp
里,读取scrollHeight
(它还停留在比较大的值),计算出的newHeight
依然是最大值 75px,于是state
不更新,style.height
也就一直保持在 75px。输入框自然就不会缩小了,除非你删到内容连 75px 都撑不满为止。
简单说,就是 style.height
这个“显式命令”干扰了 scrollHeight
对“实际内容所需高度”的准确反映,尤其是在需要缩小的场景下。
解决方案:让 Textarea 乖乖伸缩
要解决这个问题,关键在于获取 不受当前 CSS height
限制 的内容实际所需高度。有几种常见的思路:
方案一:先重置,再测量(推荐)
这是最常用也比较稳妥的方法。核心思想是在计算新高度 之前,临时把 textarea
的 height
设置成一个很小的值或者 auto
,强制浏览器重新计算布局,这时候读取 scrollHeight
就能得到内容真正需要的高度(不受之前设定的最大高度影响)。然后,再用这个真实的 scrollHeight
来计算最终应该设置的 height
(当然,还是要限制在最小值和最大值之间)。
原理:
- 用户输入或删除文字,触发事件(比如
onInput
或onChange
,通常比onKeyUp
更适合实时响应内容变化)。 - 在事件处理函数里,先拿到
textarea
的 DOM 元素引用(用useRef
Hook)。 - 关键一步: 立刻把
textarea
的style.height
设置为'auto'
或者一个很小的值(比如'1px'
或'inherit'
)。这相当于告诉浏览器:“先别管我之前多高,按内容重新算一下”。 - 紧接着: 立即 读取此刻的
textarea.scrollHeight
。由于上一步重置了高度限制,这个scrollHeight
现在就能准确反映内容的实际高度了。 - 用这个新的
scrollHeight
计算最终的高度,确保它在你的minHeight
和maxHeight
范围内 (Math.max(minHeight, Math.min(scrollHeight, maxHeight))
)。 - 把计算出的最终高度再设置回
textarea
的style.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;
关键点和建议:
- 事件选择: 使用
onChange
或onInput
通常比onKeyUp
更好,因为它们能捕捉到粘贴、拖拽等非键盘输入导致的内容变化。 useRef
: 必须用useRef
来直接访问和操作 DOM 元素的style
和scrollHeight
。useCallback
: 包裹事件处理函数,避免在父组件重渲染时不必要地创建新函数。- CSS
box-sizing: border-box;
: 强烈建议给textarea
设置这个样式。这样height
和scrollHeight
的计算就都包含了padding
和border
,逻辑更统一简单,不容易出错。否则,你可能需要手动在计算时加减padding
值。 - CSS
overflowY: 'hidden'/'auto'
: 在 JS 中根据是否达到maxHeight
动态切换overflowY
样式,可以更平滑地控制滚动条的出现与隐藏。设置 CSSmax-height
也能辅助限制,但 JS 控制overflow
更精确。 - CSS
resize: none;
: 禁用浏览器默认的右下角拖拽调整大小功能,因为我们是自动调整。 - 初始高度: 如果
textarea
有默认值 (initial value),可能需要在组件首次挂载时 (useEffect
或useLayoutEffect
) 就执行一次高度调整逻辑。 - 性能: 对于极快速的输入,如果觉得高度调整有点卡顿(虽然通常不会),可以考虑用
debounce
或throttle
来包装handleChange
函数,减少触发频率。但要注意别延迟太厉害影响用户体验。 useLayoutEffect
vsuseEffect
:handleChange
里的 DOM 操作(设置height
)是同步发生的。如果遇到极其罕见的闪烁问题(理论上,因为是同步操作 DOM,应该还好),可以考虑将逻辑移到useLayoutEffect
中,它会在浏览器绘制前同步执行,确保 DOM 更新和绘制是一致的。但在本场景下,useCallback
里的同步操作通常足够。
方案二:使用“镜像” Textarea 测量
这个方法稍微“曲线救国”一点,但也能有效解决问题。思路是创建一个隐藏的、跟可见 textarea
样式完全一样的“镜像” textarea
。这个镜像 textarea
没有高度限制。我们把可见 textarea
的内容实时同步到镜像 textarea
里,然后读取镜像 textarea
的 scrollHeight
,用这个值来设置可见 textarea
的高度。
原理:
- 在你的组件
render
(或 JSX) 中,创建两个textarea
元素。 - 可见 Textarea: 用户实际交互的那个,应用我们最终计算出的
height
(带min/max
限制) 和overflow
样式。 - 隐藏 Textarea(镜像):
- 通过 CSS 将其彻底隐藏(比如
position: absolute; left: -9999px; top: -9999px; visibility: hidden; height: auto;
)。不能用display: none
,因为那样无法获取scrollHeight
。 - 关键是,它的样式(字体、字号、
padding
、border
、宽度width
等影响布局的属性)必须和可见textarea
完全一致。宽度尤其重要! - 它的
height
设置为auto
,并且没有max-height
限制。
- 通过 CSS 将其彻底隐藏(比如
- 当用户在可见
textarea
中输入时 (onChange
/onInput
):- 获取输入的值。
- 将这个值 同时 设置给可见
textarea
(如果是受控组件) 和隐藏的镜像textarea
。 - 读取 隐藏
textarea
的scrollHeight
。因为镜像没有高度限制,它的scrollHeight
能准确反映内容所需的完整高度。 - 使用镜像的
scrollHeight
来计算可见textarea
的最终height
(应用min/max
限制)。 - 将计算出的高度设置给可见
textarea
的style.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
了。