乐趣区

关于即时通讯:手把手教你实现网页端社交应用中的人功能技术原理代码示例等

本文由 ELab 团队技术团队分享,原题“Twitter 和微博都在用的 @ 人的性能是如何设计与实现的?”,有订正。

1、引言

第一次应用 @人性能到当初曾经有差不多 10 年了,首次应用是通过微博体验的。@人的性能当初遍布各种利用,基本上波及社交(IM、微博)、办公(钉钉、企业微信)等场景,就是一个必不可少的性能。

最近正好在调研 IM 各种性能的技术实现计划,所以也具体地理解了下 @人性能在 Web 网页前端的技术实现,正好借此机会给大家分享一下我所把握的技术原理和代码实现。

学习交换:

  • 即时通讯 / 推送技术开发交换 5 群:215477170 [举荐]
  • 挪动端 IM 开发入门文章:《新手入门一篇就够:从零开发挪动端 IM》
  • 开源 IM 框架源码:https://github.com/JackJiang2…

(本文已同步公布于:http://www.52im.net/thread-37…)

2、相干材料

本文分享的 @人性能是针对 Web 网页前端的,跟挪动端原生代码的实现,从技术原理和理论实现上,还是有很大差别,所以如果想理解挪动端 IM 这种社交利用中的 @人实现性能,能够读一下《Android 端 IM 利用中的 @人性能实现:仿微博、QQ、微信,零入侵、高可扩大[图文 + 源码]》这篇文章。

3、业内实现

3.1 微博的实现


微博的实现比较简单,就是通过正则匹配,最初用空格示意匹配完结,所以实现上是间接应用了 textarea 标签。

然而这个实现必须依赖的一个事件是:用户名必须惟一。

微博的用户名就是惟一的,所以正则所匹配到的 ID,个别的能够映射到惟一的一个用户上(除非 ID 不存在)。不过,微博中的这个性能整体输入比拟宽松,你能够结构任何不存在的 ID 进行 @操作。

3.2 Twitter 的实现


Twitter 的实现跟微博相似,也是以 @开始,空格结尾做匹配。然而应用的是 contenteditable 这个属性进行富文本操作。

相似之处在于 Twitter 的 ID 也是惟一,然而能够通过昵称进行搜寻,而后转化成 ID,这一点在体验上好了不少。

4、技术思路

通过剖析业内的支流实现,@人性能的技术实现思路大抵如下:

1)监听用户输出,匹配用户以 @结尾的文字;
2)调用搜寻弹窗,展现搜寻进去的用户列表;
3)监听上、下、回车键管制列表抉择,监听 ESC 键敞开搜寻弹窗;
4)抉择须要 @的用户,把对应的 HTML 文本替换到原文本上,在 HTML 文本上增加用户的元数据。

一般来说,如果像平时用的 Lark 搜寻(Lark 就是“飞书”),咱们是不会通过惟一的『工号』去进行搜寻,而是通过名字,然而名字会呈现反复,所以就不太适宜用 textarea 的形式,而是用 contenteditable,把 @文本替换成 HTML 标签特殊化标记。

5、代码实现第 1 步:取得用户的光标地位

想要取得用户输出的字符串,而后替换进去,第一步就是须要取得用户所在的光标。要获取光标信息,那就要先理解什么是『抉择(Selection)』和『范畴(Range)』。

5.1 范畴(Range)
Range 实质上是一对“边界点”:范畴终点和范畴起点。

每个点都被示意为一个带有绝对于终点的绝对偏移(offset)的父 DOM 节点。如果父节点是元素节点,则偏移量是子节点的编号,对于文本节点,则是文本中的地位。

例如:

let range = newRange();

而后应用 range.setStart(node, offset) 和 range.setEnd(node, offset) 来设置抉择边界。

假如 HTML 片段是这样的:

<pid=”p”>Example: italic and bold</p>

抉择 “Example: italic”,它是 <p> 的前两个子节点(文本节点也算在内):

<pid=”p”>Example: italic and bold</p>

<script>

let range = new Range();

range.setStart(p, 0);

range.setEnd(p, 2);

// 范畴的 toString 以文本模式返回其内容(不带标签)

alert(range); // Example: italic

document.getSelection().addRange(range);

</script>

解释一下:

1)range.setStart(p, 0):将终点设置为 <p> 的第 0 个子节点(即文本节点 “Example: “);
2)range.setEnd(p, 2):覆盖范围至(但不包含)<p> 的第 2 个子节点(即文本节点 ” and “,但因为不包含末节点,所以最初抉择的节点是)。
如果像这样操作:

这也是能够做到的,只须要将终点和起点设置为文本节点中的绝对偏移量即可。

咱们须要创立一个范畴:

1)从的第一个子节点的地位 2 开始(抉择 “Example: ” 中除前两个字母外的所有字母);
2)到 的第一个子节点的地位 3 完结(抉择“bold”的前三个字母,就这些),代码如下。
<pid=”p”>Example: italic and bold</p>

<script>

let range = new Range();

range.setStart(p.firstChild, 2);

range.setEnd(p.querySelector(‘b’).firstChild, 3);

alert(range); // ample: italic and bol

window.getSelection().addRange(range);

</script>

range 对象具备以下属性:

解释一下:

1)startContainer,startOffset —— 起始节点和偏移量:

  • 在上例中:别离是 <p> 中的第一个文本节点和 2。

2)endContainer,endOffset —— 完结节点和偏移量:

  • 在上例中:别离是 中的第一个文本节点和 3。

3)collapsed —— 布尔值,如果范畴在同一点上开始和完结(所以范畴内没有内容)则为 true:

  • 在上例中:false

4)commonAncestorContainer —— 在范畴内的所有节点中最近的独特先人节点:

  • 在上例中:<p>

5.2 抉择(Selection)
Range 是用于治理抉择范畴的通用对象。

文档抉择是由 Selection 对象示意的,可通过 window.getSelection() 或 document.getSelection() 来获取。

依据 Selection API 标准:一个抉择能够包含零个或多个范畴(不过实际上,只有 Firefox 容许应用 Ctrl+click (Mac 上用 Cmd+click) 在文档中抉择多个范畴)。

这是在 Firefox 中做的一个具备 3 个范畴的抉择的截图:

其余浏览器最多反对 1 个范畴。
正如咱们将看到的,某些 Selection 办法暗示可能有多个范畴,但同样,在除 Firefox 之外的所有浏览器中,范畴最多是 1。

与范畴类似,抉择的终点称为“锚点(anchor)”,起点称为“焦点(focus)”。

次要的抉择属性有:

1)anchorNode:抉择的起始节点;
2)anchorOffset:抉择开始的 anchorNode 中的偏移量;
3)focusNode:抉择的完结节点;
4)focusOffset:抉择开始处 focusNode 的偏移量;
5)isCollapsed:如果未抉择任何内容(空范畴)或不存在,则为 true;
6)rangeCount:抉择中的范畴数,除 Firefox 外,其余浏览器最多为 1。
看完下面,不晓得理解了没?没关系,咱们持续往下。

综上所述:个别咱们只有一个 Range,当咱们的光标在 contenteditable 的 div 上闪动的时候,其实就有了一个 Range,这个 Range 的开始和完结地位都是一样的。

另外:咱们还能够间接通过 Selection.focusNode 获取到对应的节点,通过 Selection.focusOffset 获取到对应的偏移量。

就像下图:

这样,咱们就获取到了光标的地位以及对应的 TextNode 对象。

6、代码实现第 2 步:获取须要 @的用户

在上一节咱们取得了光标在对应 Node 节点的偏移量,以及对应的 Node 节点。那么就能够通过 textContent 办法获取整个文本。

一般来说,通过一个简略的正则就能够获取 @的内容了:

// 获取光标地位
const getCursorIndex = () => {
const selection = window.getSelection();
return selection?.focusOffset;
};

// 获取节点
const getRangeNode = () => {
const selection = window.getSelection();
return selection?.focusNode;
};

// 获取 @ 用户
const getAtUser = () => {
const content = getRangeNode()?.textContent || “”;
const regx = /@(1*)$/;
const match = regx.exec(content.slice(0, getCursorIndex()));
if(match && match.length === 2) {

return match[1];

}
return undefined;
};

因为 @的插入可能是开端,可能是两头,所以咱们在判断前,还须要截取光标前的文本。

所以简略地 slice 一下就好了:

content.slice(0, getCursorIndex())

7、代码实现第 3 步:弹窗展现以及按键拦挡

弹窗是否展现的逻辑,跟判断 @用户相似,都是同一个正则。

// 是否展现 @

const showAt = () => {

const node = getRangeNode();

if(!node || node.nodeType !== Node.TEXT_NODE) returnfalse;

const content = node.textContent || “”;

const regx = /@(2*)$/;

const match = regx.exec(content.slice(0, getCursorIndex()));

return match && match.length === 2;

};

弹窗须要呈现在正确的地位,幸好古代浏览器有不少好用的 API。

const getRangeRect = () => {

const selection = window.getSelection();

const range = selection?.getRangeAt(0)!;

const rect = range.getClientRects()[0];

const LINE_HEIGHT = 30;

return {

x: rect.x,

y: rect.y + LINE_HEIGHT

};

};

当呈现弹窗之后,咱们还须要拦挡掉输入框的『上』、『下』、『回车』的操作,否则在输入框响应这些按键会让光标地位偏移到其余中央。

const handleKeyDown = (e: any) => {

if(showDialog) {

  if(

    e.code === "ArrowUp"||

    e.code === "ArrowDown"||

    e.code === "Enter"

  ) {e.preventDefault();

  }

}

};

而后在弹窗外面监听这些按键,实现高低抉择、回车确定、敞开弹窗的性能。

const keyDownHandler = (e: any) => {
if(visibleRef.current) {

if(e.code === "Escape") {props.onHide();
  return;
}
if(e.code === "ArrowDown") {setIndex((oldIndex) => {return Math.min(oldIndex + 1, (usersRef.current?.length || 0) - 1);
  });
  return;
}
if(e.code === "ArrowUp") {setIndex((oldIndex) => Math.max(0, oldIndex - 1));
  return;
}
if(e.code === "Enter") {
  if(
    indexRef.current !== undefined &&
    usersRef.current?.[indexRef.current]
  ) {props.onPickUser(usersRef.current?.[indexRef.current]);
    setIndex(-1);
  }
  return;
}

}
};

8、代码实现第 3 步:替换 @文本为定制标签

大抵的原理图:

具体咱们具体分步来看看。
8.1 把原来的 TextNode 进行切块
如果文本是:“请帮我泡一杯咖啡 @ABC,这是前面的内容”。

那么咱们须要依据光标的地位,替换掉 @ABC 文本,而后分成前后两块:『请帮我泡一杯咖啡』、『这是前面的内容』。

8.2 创立 At 标签
为了能实现删除键能把删除全副删除,须要把 at 标签的内容包裹起来。

这是第一版写的一个标签,然而如果间接用会有点小问题,留着后续再探讨:

const createAtButton = (user: User) => {
const btn = document.createElement(“span”);
btn.style.display = “inline-block”;
btn.dataset.user = JSON.stringify(user);
btn.className = “at-button”;
btn.contentEditable = “false”;
btn.textContent = @${user.name};
return btn;
};

8.3 把标签插进去
首先:咱们能够获取 focusNode 节点,而后就能够获取它的父节点以及兄弟节点。

当初须要做的是:把旧的文本节点删除,而后在原来的地位上顺次插入『请帮我泡一杯咖啡』、【@ABC】、『这是前面的内容』。

具体来看看代码:

parentNode.removeChild(oldTextNode);
// 插在文本框中
if(nextNode) {
parentNode.insertBefore(previousTextNode, nextNode);
parentNode.insertBefore(atButton, nextNode);
parentNode.insertBefore(nextTextNode, nextNode);
} else{
parentNode.appendChild(previousTextNode);
parentNode.appendChild(atButton);
parentNode.appendChild(nextTextNode);
}

8.4 重置光标的地位
咱们这一顿操作之前,因为原来的文本节点失落,所以咱们的光标也失去了。这时候就须要从新把光标定位到 at 标签之后。

简略来说就是把光标定位到 nextTextNode 节点之前即可:

// 创立一个 Range,并调整光标
const range = newRange();
range.setStart(nextTextNode, 0);
range.setEnd(nextTextNode, 0);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);

8.5 优化 at 标签
第 2 步中,咱们创立了 at 标签,然而会有点小问题。

这时候光标就定位到了『按钮边框内』,但光标的地位实际上是正确的。

为了优化这个问题,首先想到的是在 nextTextNode 中增加一个『0 宽字符』——\u200b。

// 增加 0 宽字符
const nextTextNode = newText(“\u200b”+ restSlice);
// 定位光标时,挪动一位
const range = newRange();
range.setStart(nextTextNode, 1);
range.setEnd(nextTextNode, 1);

然而,事件没那么简略。因为我发现如果往前可能也会这样……

最初一想:把内容区弄宽一点不就行了?比方左右加个空格?而后就把标签包裹了一层……

const createAtButton = (user: User) => {
const btn = document.createElement(“span”);
btn.style.display = “inline-block”;
btn.dataset.user = JSON.stringify(user);
btn.className = “at-button”;
btn.contentEditable = “false”;
btn.textContent = @${user.name};
const wrapper = document.createElement(“span”);
wrapper.style.display = “inline-block”;
wrapper.contentEditable = “false”;
const spaceElem = document.createElement(“span”);
spaceElem.style.whiteSpace = “pre”;
spaceElem.textContent = “\u200b”;
spaceElem.contentEditable = “false”;
const clonedSpaceElem = spaceElem.cloneNode(true);
wrapper.appendChild(spaceElem);
wrapper.appendChild(btn);
wrapper.appendChild(clonedSpaceElem);
return wrapper;
};

富人毛糙版 at 人,最终完结~

9、小结一下

Web 前端富文本的坑的确比拟多,之前没怎么理解过这部分的常识。尽管整个过程看起来很毛糙,然而技术原理就是这样。

不欠缺的中央很多,有更好的形式能够独特探讨下。

如果有趣味,也能够到 Playground 玩一玩(点此进入)。

下面链接关上后是这样的,能够在线试试本文代码的运行成果:

10、参考资料

[1] Selection 的 W3C 官网 API 手册
[2] 古代 JavaScript 教程
[3] Range 的 MDN 在线 API 手册
[4] Android 端 IM 利用中的 @人性能实现:仿微博、QQ、微信,零入侵、高可扩大

附录:更多 IM 入门实际文章

《跟着源码学 IM(一):手把手教你用 Netty 实现心跳机制、断线重连机制》
《跟着源码学 IM(二):自已开发 IM 很难?手把手教你撸一个 Andriod 版 IM》
《跟着源码学 IM(三):基于 Netty,从零开发一个 IM 服务端》
《跟着源码学 IM(四):拿起键盘就是干,教你徒手开发一套分布式 IM 零碎》
《跟着源码学 IM(五):正确理解 IM 长连贯、心跳及重连机制,并入手实现》
《跟着源码学 IM(六):手把手教你用 Go 疾速搭建高性能、可扩大的 IM 零碎》
《跟着源码学 IM(七):手把手教你用 WebSocket 打造 Web 端 IM 聊天》
《跟着源码学 IM(八):万字长文,手把手教你用 Netty 打造 IM 聊天》

本文已同步公布于“即时通讯技术圈”公众号。
同步公布链接是:http://www.52im.net/thread-37…


  1. @\s ↩
  2. @\s ↩
退出移动版