🎭《ChatInput:当键盘成为魔杖》

172 阅读3分钟

🌌 序章:为什么聊天框比火箭还难造

表面看,ChatInput 只是一片白框。底层看:

  • 光标在 Blink TreeLayout Engine 之间来回蹦迪;
  • 每敲一个键,触发 合成事件浏览器 IPCReact Reconciler调度器LayoutPaintCommit
  • 还要防 XSS、做防抖、撑 Emoji、读麦克风、听快捷键、兼容屏幕阅读器……

所以,造 ChatInput ≈ 让一只猫同时弹钢琴、调酒、做 PPT。


🧰 目录

  1. 需求拆解:从 0 到 1 的咒语清单
  2. 技术选型:textarea vs contentEditable,圣杯之战
  3. 核心实现:30 行代码的“键盘魔杖”
  4. 高级魔法:防抖、粘贴、拖拽、语音转文字
  5. 性能炼金:如何避免每次 keydown 都炸掉主线程
  6. 彩蛋:一键“发送火箭”动画 & 可访问性彩蛋

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 光标闪烁。