什么是 at 功能
所谓的 at 功能,就是指的在聊天框中输入人的姓名等信息时,允许用户在输入”@”字符之后,可以调起一个选人控件,方便用户快速输入人名。
例如:微博输入框,QQ 空间的说说输入框。我们可以在一个输入框内输入 “@” 字符,然后会调起一个选人浮层或全屏选人控件(在桌面端通常是个浮层,在移动端通常是一个全屏控件)。
本文是讲述在小程序中实现一个@功能的过程。另外一篇 基于 contenteditable 技术实现@选人功能
中讲述了 Web 版本的实现。
at 功能的需求差异
在接到 at 功能的需求时,我们需要首先确定一个问题:即我们 at 出来的人名,是否有重名的现象。即:在输入框中同时出现 “@abc” 和 “@abc” 两个人名时,这俩人是否必然代表同一个人。
像新浪微博的场景下,其 at 出来的微博账户,必然是唯一的,因此其技术实现方案便可简化为:只需将用户从选人控件中选择的人渲染到输入框即可
。
而像 QQ 空间等需求场景下,我们 at 选出来的一个用户昵称,实际上是可以重名的,这时,我们的技术方案必须考虑到:如何将一个输入框中的人名要跟他对应的账户信息一一映射起来
。只有这样,当我们把用户输入的消息保存给后台时,才能清晰的还原出两个”@abc”分别是哪个人。
如下是 qq 空间输入框,我可以输入 2 个同名的人,他分别可以给我两个不同的好友发送 at 消息:
本文,我所讨论的是 QQ 空间这种可重名的场景。因此,我们的技术方案需要考虑如何将 at 人名与账户信息记录映射起来。
小程序: 难为无米之炊
在 web 端,通常我们使用 div 配合 contentediable,另外再配合 Range 和 Selection 的光标控制 api 来实现类似聊天框里的 at 功能。
其中:
- contenteditable api 允许我们将 html 标签插入到编辑器当中,这样我们便可以将账户信息“塞到”标签里,从而在提交后台时,从标签里把账户信息还原出来
- range api 提供了控制光标选取和设置选取内容的能力,这允许我们在用户选完控件人名后,我们将 at 字符删除,并把新选择的人名标签渲染到输入框里。
可是 小程序的输入框 input 和 textarea 并没有 web 那么多强大的 API (比如 Range 和 Selection),也无法在小程序内使用 contenteditable 实现富文本。小程序仅有一个 bindinput 事件:
其事件返回 3 个参数:
- value: 当前输入框中最新的值。
- cursor: 当前光标的位置
- keyCode:当前输入事件的键盘按键
思路
我们要在一个如此 朴素
的输入框,利用仅有的 value\cursor\keyCode
3 个参数实现 “at 检测”、”人名渲染”、”删除检测”、”重名支持(即账户信息还原)”。 其最大的难点主要还是如何记录账户信息从而支持重名。
能想到的方案有 3 种:
- 每当输入 at 字符并选择了人名后,我们在另外一个数据结构中记录该账户信息。例如维持一个
persons: []
数组。 但是我们要在用户对输入框内容进行 增删改查
的同时对我们的 persons 结构进行同步增删改查的更新,其修改难度会很复杂。
- 我们想,能否在 at 人名填入到输入框的时候,把账户信息塞到输入框里。就像 contenteditable 一样。因此,我们可以尝试使用一些不可见的字符,用这些字符来表达某个账户信息的标识。 但这种方案,想想就复杂,例如我们删除一个人名时是否能同步把不可见字符也删掉,是否会有光标问题都不好说。
- 采用了一种虚拟层的思路。当用户输入任何字符时,我们都对用户输入进行拦截,拦截到输入后,首先按照需求更新我们内部
虚拟层
的数据结构,在虚拟层中我们按一定的结构保存好用户账户信息数据,然后将其渲染成文本再填到输入框中。
最终,我选择了采用第三种方案实现,方案如图:
调用方法
虚拟层内部具体的计算逻辑封装到了 RichMessage 类中。小程序组件中,首先给 input 输入框绑定 input 事件:
1
| <input placeholder="请输入" bindinput="eventInput" />
|
组件脚本中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| { data: { inputContent: '', }, attached() { this.myCommentRichMessage = new RichMessage(); }, methods: { eventInput(e) { const res = this.myCommentRichMessage.doInput(event.detail); res.then(str => { if (typeof str === 'string') { this.setData({ inputContent: str }); } }); } } }
|
当要把数据提交给后台时,可以调用 toProto 方法,将消息转成具体的数据结构:
1
| const pbdata = myCommentRichMessage.transformToProto();
|
实现
RichMessage 类
负责根据输入的光标和 value,计算出是在什么位置新增或删除了字符。并负责维护虚拟层的消息盒子—msgbox。
- 当用户新增字符,则修改或新增消息盒子中具体位置的消息数据结构。
- 当用户删除字符,则删除消息盒子中对应位置的字符数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| const MessageBox = require("./MessageBox");
class RichMessage { constructor(options) { options = options || {}; this._msgBox = new MessageBox(); }
doInput(inputInfo) { const { keyCode } = inputInfo; if (isNaN(keyCode)) return Promise.resolve(inputInfo); if (keyCode == 8) { return this.removeOneCharactor(inputInfo); } else { return this.typeOneCharactor(inputInfo); } } }
module.exports = RichMessage;
|
消息盒子实现
消息盒子负责具体实现两种类型消息的管理:纯文本消息和 at 消息。其必须实现以下 3 个 api:
- addCharactor 方法。在 pos 位置新增一个字符,并重构当前虚拟层数据结构
- deleteCharactor 方法。在 pos 位置删除一个字符,并重构当前虚拟层数据结构
- print 方法。将整个虚拟层各个消息全部渲染,得到一份完整的纯文本
内部实现会有些复杂,更多代码请查看 github。例如:
- 添加字符时会涉及到当
确定pos位置是新增还是修改现有消息
、 pos位置插入at消息,是否要将某个文本消息切分成两半
等情况的处理。
- 删除字符时,若是删除的 at 消息则要将整个 at 消息体删除
- 每次消息改动后,要像整理’内存’碎片一样,对消息进行合理的合并处理(例如两个相邻的文本消息要合并)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91
| const TextMessage = require("./TextMessage"); const AtMessage = require("./AtMessage");
class MessageBox { constructor() { this._msgs = []; }
addCharactor(pos, char) { const getNewMsg = this._getNewMsg(char); return getNewMsg.then((newMsg) => { let countPos = 0; let findedMsg = null; let findedMsgIndex = -1; for (let i = 0, len = this._msgs.length; i < len; i++) { const msg = this._msgs[i]; const msgRenderLen = msg.render().length; if (pos >= countPos && pos <= countPos + msgRenderLen - 1) { findedMsg = msg; findedMsgIndex = i; break; } countPos += msgRenderLen; }
if (findedMsg) { this._mergeMsg(findedMsgIndex, newMsg, pos - countPos); } else { this._msgs.push(newMsg); } this._defragmentation(); return this.print(); }); }
deleteCharactor(start, end) { const findedMsgIndex = []; const findedMsgPos = [];
let countPosStart = 0; for (let i = 0, len = this._msgs.length; i < len; i++) { const msg = this._msgs[i]; const msgRenderLen = msg.render().length; const countPosEnd = countPosStart + msgRenderLen - 1; if (end >= countPosStart && start <= countPosEnd) { findedMsgIndex.push(i); const msgPosStart = Math.max(countPosStart, start); const msgPosEnd = Math.min(countPosEnd, end); findedMsgPos.push({ startPos: msgPosStart - countPosStart, endPos: msgPosEnd - countPosStart, }); } countPosStart += msgRenderLen; } if (findedMsgIndex && findedMsgIndex.length > 0) { findedMsgIndex.forEach((findedIndex, index) => { const msg = this._msgs[findedIndex]; if (msg.type === "text") { const deletePos = findedMsgPos[index]; msg.removeChars(deletePos.startPos, deletePos.endPos); } if (msg.type === "at") { this._msgs.splice(findedIndex, 1); } }); } this._defragmentation(); }
print() { let str = ""; str = this._msgs.reduce((last, cur) => { return (last += cur.render()); }, ""); return str; } }
module.exports = MessageBox;
|
完整代码
完整代码看下 github 吧:
https://github.com/cuiyongjian/minprogram-at-editor