什么是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 事件:
组件脚本中:
{
data: {
inputContent: '',
},
attached() {
this.myCommentRichMessage = new RichMessage();
},
methods: {
eventInput(e) {
const res = this.myCommentRichMessage.doInput(event.detail);
// 输入完之后,重新渲染input内容
res.then(str => {
if (typeof str === 'string') {
this.setData({
inputContent: str
});
}
});
}
}
}
当要把数据提交给后台时,可以调用 toProto 方法,将消息转成具体的数据结构:
const pbdata = myCommentRichMessage.transformToProto()
实现
RichMessage类
负责根据输入的光标和value,计算出是在什么位置新增或删除了字符。并负责维护虚拟层的消息盒子---msgbox。
- 当用户新增字符,则修改或新增消息盒子中具体位置的消息数据结构。
- 当用户删除字符,则删除消息盒子中对应位置的字符数据
const MessageBox = require('./MessageBox');
class RichMessage {
constructor(options) {
options = options || {};
this._msgBox = new MessageBox();
}
doInput(inputInfo) {
const { keyCode } = inputInfo;
// 做下判断,防止鼠标或手机键盘移开时触发的input事件(keyCode是undefined)
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消息体删除
- 每次消息改动后,要像整理'内存'碎片一样,对消息进行合理的合并处理(例如两个相邻的文本消息要合并)
const TextMessage = require('./TextMessage');
const AtMessage = require('./AtMessage');
class MessageBox {
constructor() {
this._msgs = [];
}
// 向pos位置增加普通文本字符
addCharactor(pos, char) {
// 准备要add的消息
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) {
// 找到了消息msg,则把新msg塞到该msg结构里
this._mergeMsg(findedMsgIndex, newMsg, pos - countPos);
}
else {
// 没找到消息块,那就是放到box末尾新增
this._msgs.push(newMsg);
}
// 消息碎片整理--即把同类型消息合并(例如两个挨着的textmessage则用一个表示即可)
this._defragmentation();
return this.print();
});
}
// 删除start到end间的字符(包含end自身)
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);
// 找出此msg里的交集坐标
const msgPosStart = Math.max(countPosStart, start);
const msgPosEnd = Math.min(countPosEnd, end);
findedMsgPos.push({
startPos: msgPosStart - countPosStart,
endPos: msgPosEnd - countPosStart
});
}
countPosStart += msgRenderLen;
}
// 对找到的msg依次进行删除的操作 (若是at信息,则整个都删掉;若是普通字符,则只删对应坐标的字符;若删除后整个msg变空了,则在碎片整理时移除)
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();
}
// 输出当前所有 msg 结构转为可视字符串后的完整字符串
print() {
let str = '';
str = this._msgs.reduce((last, cur) => {
return last += cur.render();
}, '');
return str;
}
}
module.exports = MessageBox;
完整代码
完整代码看下github吧: