手把手教你實現網頁端社交應用中的@人功能:技術原理、程式碼示例等

JackJiang發表於2021-12-08

本文由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)監聽上、下、Enter鍵控制列表選擇,監聽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

相關文章