🌌 序章:为什么聊天框比火箭还难造
表面看,ChatInput 只是一片白框。底层看:
- 光标在 Blink Tree 与 Layout Engine 之间来回蹦迪;
- 每敲一个键,触发 合成事件 → 浏览器 IPC → React Reconciler → 调度器 → Layout → Paint → Commit;
- 还要防 XSS、做防抖、撑 Emoji、读麦克风、听快捷键、兼容屏幕阅读器……
所以,造 ChatInput ≈ 让一只猫同时弹钢琴、调酒、做 PPT。
🧰 目录
- 需求拆解:从 0 到 1 的咒语清单
- 技术选型:textarea vs contentEditable,圣杯之战
- 核心实现:30 行代码的“键盘魔杖”
- 高级魔法:防抖、粘贴、拖拽、语音转文字
- 性能炼金:如何避免每次 keydown 都炸掉主线程
- 彩蛋:一键“发送火箭”动画 & 可访问性彩蛋
1️⃣ 需求拆解:咒语清单
| 需求 | 例子 | 技术点 |
|---|---|---|
| 多行输入 | Shift+Enter 换行 | white-space: pre-wrap |
| 自动增高 | 微信那种 | scrollHeight 动态计算 |
| 防抖发送 | 500 ms 没新输入才发 | useDebounce |
| 粘贴图片 | Ctrl+V 直接上图 | DataTransfer.files |
| 键盘快捷键 | Ctrl+Enter 发送 | e.key === 'Enter' && e.ctrlKey |
| 语音输入 | 🎤 按钮 | Web Speech API |
| 可访问性 | 读屏器能朗读 | aria-label, role="textbox" |
2️⃣ 技术选型:圣杯之战
🗡️ 方案 A:<textarea>
- 优点:原生多行、可滚动、无障碍天生好。
- 缺点:想插图片?抱歉,这是 1998 年的 HTML。
🧙 方案 B:contentEditable
- 优点:想插图片、@人、彩色字体随便玩。
- 缺点:浏览器给你一团 innerHTML 的混沌,净化 XSS 像拔狮子鬃毛。
🏆 最终抉择
“聊天窗口”用 textarea,富媒体预览区用 contentEditable 的兄弟组件。
鱼和熊掌兼得,且键盘导航无障碍。
3️⃣ 核心实现:30 行魔杖
以下代码用 TypeScript 风味 JSDoc,直接粘进
.jsx就能跑。
import { useState, useRef, useEffect, forwardRef } from 'react';
const ChatInput = forwardRef((props, ref) => {
const { onSend, rows = 1, maxRows = 5 } = props;
const [text, setText] = useState('');
const taRef = useRef(null);
// 自动长高术
useEffect(() => {
const ta = taRef.current;
if (!ta) return;
ta.style.height = 'auto';
const lineHeight = 20; // 一行 px
const newRows = Math.min(maxRows, Math.max(rows, ta.scrollHeight / lineHeight));
ta.style.height = `${newRows * lineHeight}px`;
}, [text, rows, maxRows]);
// 键盘快捷键:Ctrl+Enter 发送
const handleKeyDown = (e) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
handleSend();
}
};
const handleSend = () => {
const trimmed = text.trim();
if (!trimmed) return;
onSend(trimmed);
setText('');
};
return (
<textarea
ref={(node) => {
taRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
}}
rows={rows}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Shift+Enter 换行,Ctrl+Enter 发送"
className="chat-input"
aria-label="聊天输入框"
/>
);
});
export default ChatInput;
🖼️ 视觉示意
graph TD
A[键盘事件] -->|keydown| B{Ctrl+Enter?}
B -->|是| C[handleSend]
B -->|否| D[继续输入]
C --> E[清空输入框]
C --> F[回调 onSend]
style C fill:#f9f,stroke:#333
4️⃣ 高级魔法:粘贴图片 + 语音转文字
🥞 粘贴板嗅探
const handlePaste = (e) => {
const files = Array.from(e.clipboardData.files);
files.forEach((file) => {
if (file.type.startsWith('image/')) {
const url = URL.createObjectURL(file);
onImagePaste(url); // 父组件负责预览/上传
}
});
};
🎤 语音转文字
const startSpeech = () => {
if (!('webkitSpeechRecognition' in window)) return alert('浏览器不支持语音识别');
const rec = new webkitSpeechRecognition();
rec.lang = 'zh-CN';
rec.onresult = (e) => setText(e.results[0][0].transcript);
rec.start();
};
5️⃣ 性能炼金:别让 keydown 炸掉主线程
| 技巧 | 代码片段 | 原理 |
|---|---|---|
| 防抖 | const debounced = useDebounce(text, 300); | 减少 onChange 回调次数 |
| 节流 | const throttled = useThrottle(handleResize, 100); | 窗口 resize 时减少重算 |
| 合成事件池 | React 已自动复用事件对象 | 避免额外垃圾回收 |
6️⃣ 彩蛋:发送火箭动画
.chat-input {
transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.chat-input:focus {
transform: scale(1.02);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.4);
}
按下 Ctrl+Enter 时,父组件可瞬间给输入框加 className="send-rocket",让它像火箭一样“嗖”地缩小 → 清空 → 恢复原状。
🏁 尾声:键盘是魔杖,可访问性是守护神
- 加
aria-label,读屏器才会朗诵“聊天输入框”。 - 保持
tabIndex={0},键盘党能一路 Tab 进来。 - 用
role="textbox"+aria-multiline="true",让辅助技术知道这是多行文本。
“当你按下最后一个 Enter,光标闪灭,消息飞上云端——那一刻,键盘真的变成了魔杖。”
祝编码愉快,愿你永远不必 debug 光标闪烁。