本文由 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…
- @\s ↩
- @\s ↩