手把手教你實現(xiàn)網(wǎng)頁端社交應用中的@人功能:技術原理、代碼示例等

本文由ELab團隊技術團隊分享,原題“Twitter和微博都在用的 @ 人的功能是如何設計與實現(xiàn)的?”,有修訂。
1、引言
第一次使用@人功能到現(xiàn)在已經(jīng)有差不多10年了,初次使用是通過微博體驗的。@人的功能現(xiàn)在遍布各種應用,基本上涉及社交(IM、微博)、辦公(釘釘、企業(yè)微信)等場景,就是一個必不可少的功能。

最近正好在調研 IM 各種功能的技術實現(xiàn)方案,所以也詳細地了解了下@人功能在Web網(wǎng)頁前端的技術實現(xiàn),正好借此機會給大家分享一下我所掌握的技術原理和代碼實現(xiàn)。

學習交流:
- 即時通訊/推送技術開發(fā)交流5群:215477170?[推薦]
- 移動端IM開發(fā)入門文章:《新手入門一篇就夠:從零開發(fā)移動端IM》
- 開源IM框架源碼:https://github.com/JackJiang2011/MobileIMSDK?
(本文已同步發(fā)布于:http://www.52im.net/thread-3767-1-1.html)
2、相關資料
本文分享的@人功能是針對Web網(wǎng)頁前端的,跟移動端原生代碼的實現(xiàn),從技術原理和實際實現(xiàn)上,還是有很大差異,所以如果想了解移動端IM這種社交應用中的@人實現(xiàn)功能,可以讀一下《Android端IM應用中的@人功能實現(xiàn):仿微博、QQ、微信,零入侵、高可擴展[圖文+源碼]》這篇文章。
3、業(yè)內實現(xiàn)
3.1 微博的實現(xiàn)



微博的實現(xiàn)比較簡單,就是通過正則匹配,最后用空格表示匹配結束,所以實現(xiàn)上是直接使用了textarea標簽。
但是這個實現(xiàn)必須依賴的一個事情是:用戶名必須唯一。
微博的用戶名就是唯一的,所以正則所匹配到的ID,一般的可以映射到唯一的一個用戶上(除非ID不存在)。不過,微博中的這個功能整體輸出比較寬松,你可以構造任何不存在的ID進行@操作。
3.2 Twitter的實現(xiàn)


Twitter 的實現(xiàn)跟微博類似,也是以@開始,空格結尾做匹配。但是使用的是 contenteditable 這個屬性進行富文本操作。
相似之處在于 Twitter 的 ID 也是唯一,但是可以通過昵稱進行搜索,然后轉化成 ID,這一點在體驗上好了不少。
4、技術思路
通過分析業(yè)內的主流實現(xiàn),@人功能的技術實現(xiàn)思路大致如下:
1)監(jiān)聽用戶輸入,匹配用戶以@開頭的文字;
2)調用搜索彈窗,展示搜索出來的用戶列表;
3)監(jiān)聽上、下、回車鍵控制列表選擇,監(jiān)聽ESC鍵關閉搜索彈窗;
4)選擇需要@的用戶,把對應的HTML文本替換到原文本上,在HTML文本上添加用戶的元數(shù)據(jù)。
一般來說,如果像平常用的Lark搜索(Lark就是“飛書”),我們是不會通過唯一的『工號』去進行搜索,而是通過名字,但是名字會出現(xiàn)重復,所以就不太適合用textarea的方式,而是用contenteditable,把@文本替換成HTML標簽特殊化標記。
5、代碼實現(xiàn)第1步:獲得用戶的光標位置
想要獲得用戶輸入的字符串,然后替換進去,第一步就是需要獲得用戶所在的光標。要獲取光標信息,那就要先了解什么是『選擇(Selection)?』和『范圍(Range)?』。
5.1 范圍(Range)
Range本質上是一對“邊界點”:范圍起點和范圍終點。
每個點都被表示為一個帶有相對于起點的相對偏移(offset)的父 DOM 節(jié)點。如果父節(jié)點是元素節(jié)點,則偏移量是子節(jié)點的編號,對于文本節(jié)點,則是文本中的位置。
例如:
let range = newRange();
然后使用?range.setStart(node, offset)?和?range.setEnd(node, offset)?來設置選擇邊界。
假設 HTML 片段是這樣的:
<pid="p">Example: <i>italic</i> and <b>bold</b></p>
選擇 "Example: <i>italic</i>",它是?<p>?的前兩個子節(jié)點(文本節(jié)點也算在內):

<pid="p">Example: <i>italic</i> and <b>bold</b></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 個子節(jié)點(即文本節(jié)點 "Example: ");
2)range.setEnd(p, 2) : 覆蓋范圍至(但不包括)<p> 的第 2 個子節(jié)點(即文本節(jié)點 " and ",但由于不包括末節(jié)點,所以最后選擇的節(jié)點是 <i>)。
如果像這樣操作:

?
這也是可以做到的,只需要將起點和終點設置為文本節(jié)點中的相對偏移量即可。
我們需要創(chuàng)建一個范圍:
1)從的第一個子節(jié)點的位置 2 開始(選擇 "Example: " 中除前兩個字母外的所有字母);
2)到?的第一個子節(jié)點的位置 3 結束(選擇 “bold” 的前三個字母,就這些),代碼如下。
<pid="p">Example: <i>italic</i>? and <b>bold</b></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 —— 起始節(jié)點和偏移量:
??- 在上例中:分別是 <p> 中的第一個文本節(jié)點和 2。
2)endContainer,endOffset —— 結束節(jié)點和偏移量:
??- 在上例中:分別是 <b> 中的第一個文本節(jié)點和 3。
3)collapsed —— 布爾值,如果范圍在同一點上開始和結束(所以范圍內沒有內容)則為 true:
??- 在上例中:false
4)commonAncestorContainer —— 在范圍內的所有節(jié)點中最近的共同祖先節(jié)點:
??- 在上例中:<p>
5.2 選擇(Selection)
Range 是用于管理選擇范圍的通用對象。
文檔選擇是由 Selection 對象表示的,可通過?window.getSelection()?或?document.getSelection()?來獲取。
根據(jù)?Selection API 規(guī)范:一個選擇可以包括零個或多個范圍(不過實際上,只有 Firefox 允許使用 Ctrl+click (Mac 上用 Cmd+click) 在文檔中選擇多個范圍)。
這是在 Firefox 中做的一個具有 3 個范圍的選擇的截圖:

其他瀏覽器最多支持 1 個范圍。
正如我們將看到的,某些 Selection 方法暗示可能有多個范圍,但同樣,在除 Firefox 之外的所有瀏覽器中,范圍最多是 1。
與范圍相似,選擇的起點稱為“錨點(anchor)”,終點稱為“焦點(focus)”。
主要的選擇屬性有:
1)anchorNode:選擇的起始節(jié)點;
2)anchorOffset:選擇開始的 anchorNode 中的偏移量;
3)focusNode:選擇的結束節(jié)點;
4)focusOffset:選擇開始處 focusNode 的偏移量;
5)isCollapsed:如果未選擇任何內容(空范圍)或不存在,則為 true ;
6)rangeCount:選擇中的范圍數(shù),除 Firefox 外,其他瀏覽器最多為 1。
看完上面,不知道了解了沒?沒關系,我們繼續(xù)往下。
綜上所述:一般我們只有一個 Range,當我們的光標在 contenteditable 的 div 上閃動的時候,其實就有了一個 Range,這個 Range 的開始和結束位置都是一樣的。
另外:我們還可以直接通過 Selection.focusNode獲取到對應的節(jié)點,通過 Selection.focusOffset 獲取到對應的偏移量。
就像下圖:

這樣,我們就獲取到了光標的位置以及對應的TextNode對象。
6、代碼實現(xiàn)第2步:獲取需要@的用戶
在上一節(jié)我們獲得了光標在對應Node節(jié)點的偏移量,以及對應的Node節(jié)點。那么就可以通過textContent方法獲取整個文本。
一般來說,通過一個簡單的正則就可以獲取@的內容了:
// 獲取光標位置
const getCursorIndex = () => {
??const selection = window.getSelection();
??return selection?.focusOffset;
};
?
?// 獲取節(jié)點
const getRangeNode = () => {
??const selection = window.getSelection();
??return selection?.focusNode;
};
?
?// 獲取 @ 用戶
const getAtUser = () => {
??const content = getRangeNode()?.textContent || "";
??const regx = /@([^@\s]*)$/;
??const match = regx.exec(content.slice(0, getCursorIndex()));
??if(match && match.length === 2) {
????return match[1];
??}
??return undefined;
};
因為@的插入可能是末尾,可能是中間,所以我們在判斷前,還需要截取光標前的文本。

所以簡單地slice一下就好了:
content.slice(0, getCursorIndex())
7、代碼實現(xiàn)第3步:彈窗展示以及按鍵攔截
彈窗是否展示的邏輯,跟判斷@用戶類似,都是同一個正則。
// 是否展示 @
const showAt = () => {
??const node = getRangeNode();
??if(!node || node.nodeType !== Node.TEXT_NODE) returnfalse;
??const content = node.textContent || "";
??const regx = /@([^@\s]*)$/;
??const match = regx.exec(content.slice(0, getCursorIndex()));
??return match && match.length === 2;
};
彈窗需要出現(xiàn)在正確的位置,幸好現(xiàn)代瀏覽器有不少好用的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
??};
};

當出現(xiàn)彈窗之后,我們還需要攔截掉輸入框的『上』、『下』、『回車』的操作,否則在輸入框響應這些按鍵會讓光標位置偏移到其他地方。
const handleKeyDown = (e: any) => {
????if(showDialog) {
??????if(
????????e.code === "ArrowUp"||
????????e.code === "ArrowDown"||
????????e.code === "Enter"
??????) {
????????e.preventDefault();
??????}
????}
??};
然后在彈窗里面監(jiān)聽這些按鍵,實現(xiàn)上下選擇、回車確定、關閉彈窗的功能。
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、代碼實現(xiàn)第3步:替換@文本為定制標簽
大致的原理圖:

具體我們詳細分步來看看。
8.1 把原來的 TextNode 進行切塊
假如文本是:“請幫我泡一杯咖啡@ABC,這是后面的內容”。
那么我們需要根據(jù)光標的位置,替換掉@ABC文本,然后分成前后兩塊:『請幫我泡一杯咖啡』、『這是后面的內容』。
8.2 創(chuàng)建 At 標簽
為了能實現(xiàn)刪除鍵能把刪除全部刪除,需要把 at 標簽的內容包裹起來。
這是第一版寫的一個標簽,但是如果直接用會有點小問題,留著后續(xù)再討論:
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 節(jié)點,然后就可以獲取它的父節(jié)點以及兄弟節(jié)點。
現(xiàn)在需要做的是:把舊的文本節(jié)點刪除,然后在原來的位置上依次插入『請幫我泡一杯咖啡』、【@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 重置光標的位置
我們這一頓操作之前,因為原來的文本節(jié)點丟失,所以我們的光標也失去了。這時候就需要重新把光標定位到 at 標簽之后。
簡單來說就是把光標定位到 nextTextNode 節(jié)點之前即可:
// 創(chuàng)建一個 Range,并調整光標
const range = newRange();
range.setStart(nextTextNode, 0);
range.setEnd(nextTextNode, 0);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
8.5 優(yōu)化 at 標簽
第2步中,我們創(chuàng)建了 at 標簽,但是會有點小問題。

這時候光標就定位到了『按鈕邊框內』,但光標的位置實際上是正確的。
為了優(yōu)化這個問題,首先想到的是在nextTextNode中添加一個『0寬字符』——\u200b。
// 添加 0 寬字符
const nextTextNode = newText("\u200b"+ restSlice);
// 定位光標時,移動一位
const range = newRange();
range.setStart(nextTextNode, 1);
range.setEnd(nextTextNode, 1);
但是,事情沒那么簡單。因為我發(fā)現(xiàn)如果往前可能也會這樣……

最后一想:把內容區(qū)弄寬一點不就行了?比如左右加個空格?然后就把標簽包裹了一層……
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]?現(xiàn)代JavaScript 教程
[3]?Range的MDN在線API手冊
[4]?Android端IM應用中的@人功能實現(xiàn):仿微博、QQ、微信,零入侵、高可擴展
附錄:更多IM入門實踐文章
《跟著源碼學IM(一):手把手教你用Netty實現(xiàn)心跳機制、斷線重連機制》
《跟著源碼學IM(二):自已開發(fā)IM很難?手把手教你擼一個Andriod版IM》
《跟著源碼學IM(三):基于Netty,從零開發(fā)一個IM服務端》
《跟著源碼學IM(四):拿起鍵盤就是干,教你徒手開發(fā)一套分布式IM系統(tǒng)》
《跟著源碼學IM(五):正確理解IM長連接、心跳及重連機制,并動手實現(xiàn)》
《跟著源碼學IM(六):手把手教你用Go快速搭建高性能、可擴展的IM系統(tǒng)》
《跟著源碼學IM(七):手把手教你用WebSocket打造Web端IM聊天》
《跟著源碼學IM(八):萬字長文,手把手教你用Netty打造IM聊天》
本文已同步發(fā)布于“即時通訊技術圈”公眾號。
同步發(fā)布鏈接是:http://www.52im.net/thread-3767-1-1.html