从零实现一个“类微信”表情输入组件

314 阅读5分钟

📋 背景

最近接到一个需求:在系统中添加表情输入功能。由于需要与腾讯某平台保持数据一致,表情包的数量和取值都要完全相同。

翻阅文档后发现并没有提供现成组件,只能自己实现。

先观察了实现方式:打开控制台面板,点击表情后会瞬间请求大量图片。

输入逻辑上,用户选择 😊 后值自动变成 [微笑],用户直接输入 [微笑] 也能映射成对应表情图片。

🚩 最终目标

  • ✅ 支持点击表情面板插入表情
  • ✅ 支持输入 [微笑] 自动转换为表情图片
  • ✅ 完成双向绑定,取值时图片转回 [微笑] 文本
  • ✅ 字符长度计算:中文 1 个字符,英文 0.5 个,表情 1 个
  • ✅ 光标定位准确,体验流畅
  • ✅ 输入法友好,不在拼音输入阶段转换

🧩 实现步骤

1、获取表情包数据

最初尝试在网上找表情包资源,但数量总是对不上。近百个表情包如果手动逐个校对太过折磨,于是尝试从页面爬取数据。 在浏览器控制台执行以下脚本:

// 获取所有表情元素
const emojiItems = document.querySelectorAll('.emoji-list li');

// 提取关键信息:图片地址、文本代码、文件名
const emojiArray = Array.from(emojiItems).map(li => {
  const img = li.querySelector('img');
  return img ? {
    src: img.src,
    alt: img.alt,
    dataImage: img.getAttribute('data-image')
  } : null;
}). filter(Boolean);

console.log(emojiArray);

执行后直接在控制台复制数组,保存为 JSON 文件。

src 只做下载使用,alt 需要用来做映射,dataImage 用于拼接读取表情图片路径。

[  {    "alt": "[微笑]",    "src": "https://xxx.qq.com/xxx/emojis/smiley_0.png"    "dataImage": "smiley_0"  }]

2、批量下载图片

使用 Node. js 脚本批量下载表情图片:

const fs = require('fs');
const path = require('path');

const outputDir = path. resolve(__dirname, 'emojis');
if (!fs.existsSync(outputDir)) {
  fs.mkdirSync(outputDir, { recursive: true });
}

async function downloadImage(item) {
  const fileName = `${item.dataImage || item.alt || 'emoji'}. png`;
  const filePath = path.join(outputDir, fileName);
  
  try {
    const res = await fetch(item.src);
    if (!res.ok) throw new Error(`Failed to fetch ${item.src}`);
    
    const buffer = await res.arrayBuffer();
    fs.writeFileSync(filePath, Buffer.from(buffer));
    console.log(`Downloaded: ${fileName}`);
  } catch (err) {
    console.error(`Error downloading ${item.src}:`, err.message);
  }
}

async function downloadAll() {
  for (const item of emojiArray) {
    await downloadImage(item);
  }
  console.log('All downloads completed!');
}

downloadAll();

表情下载完,后面就好办了——交给 AI 🤖。

3、组件实现

本质就是一个 contenteditablediv,组件实现完全让 AI 完成,但需要考虑以下特殊情况:

1️⃣ 光标位置管理
元素类型是否支持光标说明
<div> 普通不可编辑
<div contenteditable>可编辑,有光标
文本节点光标可以定位在字符之间
<img> 单独存在⚠️光标难以稳定定位到其后
<img> + 文本节点光标可以定位在文本节点中
<img> + \u200B零宽字符提供文本节点锚点
  • 在关键事件(input、mouseup、keyup)中保存光标位置,处理 DOM 变更后恢复到正确位置
  • 长度达到限制时裁剪字符,统一将光标设置到末尾(与 element-plus 行为一致)
  • 点击空白区域自动定位到末尾(通过计算内容宽度与点击位置判断)
  • 表情图片后如果没有文本节点,插入零宽字符 \u200B 作为光标定位锚点
2️⃣ 文本与图片双向转换

文本 → 图片

  • 使用 200ms 防抖避免频繁触发
  • 通过正则 /\[([^\]]+)\]/g 匹配文本格式
  • 转换条件:match.index < cursorOffset(光标在 [ 之后即可转换,无需等待额外空格)
  • 仅转换光标前的最后一个匹配项,光标后的内容保持不变
  • 转换后立即更新显示长度,保持同步

图片 → 文本

  • 遍历 DOM 树提取内容:文本节点提取 textContent,图片节点提取 data-alt 属性
  • 过滤零宽字符 \u200B
3️⃣ 字符长度计算

精确计算混合内容长度:中文 1 个字符,英文/数字 0.5 个,表情 1 个

  • 精确长度(不取整)用于限制检查,显示长度(向上取整)用于界面展示
  • 零宽字符完全过滤,不计入长度
  • 输入、粘贴、组合输入结束时都要检查长度
  • 超限时恢复到上次有效内容,避免内容丢失
4️⃣ 输入法兼容

处理中文输入法组合事件:compositionstartcompositionupdatecompositionend

  • 标记组合状态,在组合过程中不触发表情转换和长度检查
  • 组合结束后才处理长度限制和表情转换
  • 组合输入超限时裁剪到最大长度
5️⃣ 删除表情

确保一次删除完成,不需要多次按键

  • Backspace:光标在零宽字符后时,同时删除零宽字符和前面的表情图片
  • Delete:删除表情图片时,一并清理后面的零宽字符
  • 删除后立即更新显示长度和保存有效内容
6️⃣ 零宽字符使用
  • 仅在表情图片后没有其他节点时插入,作为光标定位锚点
  • 删除表情时一并清理关联的零宽字符,避免累积
  • 所有长度计算逻辑都要过滤零宽字符
7️⃣ 状态同步
  • 关键操作后同步:更新显示长度、保存有效内容、保存光标位置、防抖触发父组件更新
  • 使用处理中标志位避免循环触发
  • 外部值变化时判断是否与当前值相同,避免不必要的更新

最终效果:

动画.gif

💡总结

借助 AI 辅助开发,核心在于:告知 AI 需要处理的特殊情况,以及不断测试和调优。

只要明确需求规范和边界情况,就能高效实现功能。怎么有种从开发转测试的感觉。