上次提到过我接触了一个新项目,是校友们策划的一个类ChatGPT的项目,我负责前端部分,用的是React+TailwindCSS的组合。 我这个刚接触React一个月的小白肯定是搓手等着上手、跃跃欲试。

像是ChatGPT、Claude,甚至是Discord这样的聊天室App,输入框都是能够让用户换行、输入代码块的。我们的项目也不例外。 但是,textarea 组件就算是默认单行,换行时也会向下增加高度,导致脱离原本的父容器,甚至跑到屏幕外面去。

最终结果

result

环境

先说父容器的样式,一个带了flexdiv

1
2
3
4
5
6
<div className="flex flex-col h-full">
<div className="flex-1">{/* 信息内容 */}</div>

{/* 主角 */}
<Textbox />
</div>

Textbox 组件的样式差不多是这样的:

1
2
3
4
5
6
7
8
9
<div className="flex items-center w-full">
<textarea
name="message"
className="w-full resize-none"
placeholder="Type a message..."
rows="1"
/>
<button>发送</button>
</div>

正常来说,textarea 的大小是朝下无限增长的,但这不是我们想要的,我们希望它能够朝上增长、挤压信息内容的高度,直到达到一定高度后出现滚动条。

解决方案

为什么要特意说到父容器是一个带了 flexdiv 呢?因为这是解决问题的关键。

包含了信息内容的兄弟元素会铺满剩余没有被 Textbox 组件占用的空间,而 Textbox 组件的高度是可以动态修改的。

也就是说,我们可以通过监听 textareascrollHeight 属性,来动态修改 Textbox 整个组件的高度,最终保持让它一直待在父容器的里面。

先创建一个useRef来引用textarea

1
2
3
4
5
const textareaRef = useRef(null);

// ...

<textarea ref={textareaRef} />

我们还需要创建一个useState来保存我们想要的高度:

1
const [height, setHeight] = useState(40);

这里的 40 (像素)是我自己设定的一个最小高度。

创建useEffect来监听textareascrollHeight属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
useEffect(() => {
const handleResize = () => {
const textareaElement = textareaRef.current;
textareaElement.style.height = "auto";
textareaElement.style.height = `${textareaElement.scrollHeight}px`;
setHeight(Math.max(40, textareaElement.scrollHeight));
}

const textareaElement = textareaRef.current;
textareaElement.addEventListener("input", handleResize);

return () => textareaElement.removeEventListener("input", handleResize);
}, []);

handleResize 中,我们先将 textarea 的高度设置为 auto,这样就可以让它自己决定高度,然后用 setHeight 来保存 scrollHeight 的值。

Math.max(40, textareaElement.scrollHeight) 是为了保证 textarea 的高度不会小于 40 像素,也就是我刚才说的,我自己设定的一个最小高度。

handleResize 需要在用户输入时触发,所以还要用 addEventListener 来监听 input 事件。

最后别忘了卸载监听器。

那么我们得到的 height 要用在哪里呢?Textbox 组件的最外层 div 上:

1
2
3
4
5
6
7
8
9
10
<div className="flex items-center w-full" style={{ height: `${height}px` }}>
<textarea
name="message"
className="w-full resize-none"
placeholder="Type a message..."
rows="1"
ref={textareaRef}
/>
<button>发送</button>
</div>

每次用户输入,height 都会被更新,Textbox 组件的高度也会被更新。拥有着固定高度的 Textbox 组件能够在Flexbox布局中自由伸缩,装有信息内容的兄弟元素则会根据 Textbox 组件的高度自动调整。

不过呢还是有需求没能完成:Textbox 组件要在伸展到一定高度时出现滚动条。之后再说吧。

后话

其实之后测试时发现还是有问题的,原因很简单:

1
2
3
4
5
6
7
8
onKeyDown={e => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); // 我当时忘记加这一句了
handleSendMessage(e.target.value)
.then(r => console.log("Message sent"))
.catch(e => console.error("Error in sending message:", e));
}
}}

因为没有添加 e.preventDefault() 导致每次发送消息时都会多出一行,scrollHeight 也会多出一行,最终导致 Textbox 组件的高度一直在增加、甚至超出父容器。

切记不要忘了啊!血的教训!让我白白修了一天的bug!

handleSize 也可以更新为:

1
2
3
4
5
6
7
8
9
10
11
12
const handleResize = e => {
e.target.style.height = `inherit`;
e.target.style.height = `${e.target.scrollHeight}px`;

if (e.target.scrollHeight > 232) {
e.target.style.overflowY = "scroll";
e.target.style.height = "232px";
} else {
e.target.style.overflowY = "hidden";
e.target.style.height = Math.max(40, e.target.scrollHeight);
}
}

这样当 scrollHeight 超过 232 时(在我的案例中也就是9行),就会停止增加高度、出现滚动条。