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

本文由ELab團隊技術團隊分享,原題“Twitter和微博都在用的 @ 人的功能是如何設計與實現的?”,有修訂。

1、引言

第一次使用@人功能到現在已經有差不多10年了,初次使用是通過微博體驗的。@人的功能現在遍佈各種應用,基本上涉及社交(IM、微博)、辦公(釘釘、企業微信)等場景,就是一個必不可少的功能。

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

學習交流:

- 移動端IM開發入門文章:《新手入門一篇就夠:從零開發移動端IM

- 開源IM框架源碼:https://github.com/JackJiang2011/MobileIMSDK 

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: <i>italic</i> and <b>bold</b></p>

選擇 "Example: <i>italic</i>",它是 <p> 的前兩個子節點(文本節點也算在內):

<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 個子節點(即文本節點 "Example: ");
  • 2)range.setEnd(p, 2) : 覆蓋範圍至(但不包括)<p> 的第 2 個子節點(即文本節點 " and ",但由於不包括末節點,所以最後選擇的節點是 <i>)。

如果像這樣操作:

 

這也是可以做到的,只需要將起點和終點設置爲文本節點中的相對偏移量即可。

我們需要創建一個範圍:

  • 1)從的第一個子節點的位置 2 開始(選擇 "Example: " 中除前兩個字母外的所有字母);
  • 2) 的第一個子節點的位置 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 —— 起始節點和偏移量:
  •   - 在上例中:分別是 <p> 中的第一個文本節點和 2。
  • 2)endContainer,endOffset —— 結束節點和偏移量:
  •   - 在上例中:分別是 <b> 中的第一個文本節點和 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 = /@([^@\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、代碼實現第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;

};

彈窗需要出現在正確的位置,幸好現代瀏覽器有不少好用的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-3767-1-1.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章