Twitter 和微博都在用的 - 人的功能是如何設計與實現的?

背景

第一次使用 @人功能到現在已經有差不多 10 年了,初次使用是通過微博體驗的。@人的功能現在遍佈各種應用,只要是涉及社交、辦公等場景,就是一個必不可少的功能。最近也在調研 IM 的各種功能的實現方案,所以也稍微地瞭解了下 @人功能的前端實現。

業內實現

微博

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

Twitter

Twitter 的實現跟微博類似,也是以 @開始,空格結尾做匹配。但是使用的是 contenteditable 這個屬性進行富文本操作,相似之處在於 Twitter 的 ID 也是唯一,但是可以通過暱稱進行搜索,然後轉化成 ID,這一點在體驗上好了不少。

基本思路

  1. 監聽用戶輸入,匹配用戶以 @開頭的文字。

  2. 調用搜索彈窗,展示搜索出來的用戶列表。

  3. 監聽上、下、回車鍵控制列表選擇,監聽 ESC 鍵關閉搜索彈窗。

  4. 選擇需要 @的用戶,把對應的 HTML 文本替換到原文本上。在 HTML 文本上添加用戶的元數據。

一般來說,如果像平常用的 Lark 搜索,我們是不會通過唯一的『工號』去進行搜索,而是通過名字,但是名字會出現重複,所以就不太適合用 textarea 的方式,而是用 contenteditable,把 @文本替換成 HTML 標籤特殊化標記。

關鍵步驟

  1. 獲得用戶的光標位置

想要獲得用戶輸入的字符串,然後替換進去,第一步就是需要獲得用戶所在的光標。要獲取光標信息,那就要先了解什麼是『選擇(Selection) 』和『範圍(Range) 』。

範圍(Range)

Range本質上是一對 “邊界點”:範圍起點和範圍終點。

每個點都被表示爲一個帶有相對於起點的相對偏移(offset)的父 DOM 節點。如果父節點是元素節點,則偏移量是子節點的編號,對於文本節點,則是文本中的位置。

例如:

let range = new Range();

然後使用 range.setStart(node, offset)range.setEnd(node, offset) 來設置選擇邊界。

假設 HTML 片段是這樣的:

<p id="p">Example: <i>italic</i> and <b>bold</b></p>

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

<p id="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>

如果像這樣操作:

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

我們需要創建一個範圍:

<p id="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 對象具有以下屬性:

選擇(Selection)

Range 是用於管理選擇範圍的通用對象。

文檔選擇是由 Selection 對象表示的,可通過 window.getSelection()document.getSelection() 來獲取。

根據 Selection API 規範 [1] 一個選擇可以包括零個或多個範圍。不過實際上,只有 Firefox 允許使用 Ctrl+click (Mac 上用 Cmd+click) 在文檔中選擇多個範圍。

這是在 Firefox 中做的一個具有 3 個範圍的選擇的截圖:

其他瀏覽器最多支持 1 個範圍。正如我們將看到的,某些 Selection 方法暗示可能有多個範圍,但同樣,在除 Firefox 之外的所有瀏覽器中,範圍最多是 1。

與範圍相似,選擇的起點稱爲 “錨點(anchor)”,終點稱爲 “焦點(focus)”。

主要的選擇屬性有:

看完上面,不知道了解了沒?沒關係,我們繼續往下。綜上所述,一般我們只有一個 Range,當我們的光標在 contenteditable 的 div 上閃動的時候,其實就有了一個 Range,這個 Range 的開始和結束位置都是一樣的。另外,我們還可以直接通過 Selection.focusNode獲取到對應的節點,通過 Selection.focusOffset 獲取到對應的偏移量。就像下圖:

這樣,我們就獲取到了光標的位置以及對應的 TextNode 對象。

  1. 獲取需要 @的用戶

從步驟一我們獲得了光標在對應 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())
  1. 彈窗展示以及按鍵攔截

彈窗是否展示的邏輯,跟判斷 @用戶類似,都是同一個正則。

 // 是否展示 @

const showAt = () ={

  const node = getRangeNode();

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

  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;

        }

      }

    };
  1. 替換 @文本爲定製標籤

  1. 把原來的 TextNode 進行切塊

假如文本是:『請幫我泡一杯咖啡 @ABC,這是後面的內容』

那麼我們需要根據光標的位置,替換掉 @ABC 文本,然後分成前後兩塊:『請幫我泡一杯咖啡』、『這是後面的內容』。

  1. 創建 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;

};
  1. 把標籤插進去

首先,我們可以獲取 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);

    }
  1. 重置光標的位置

我們這一頓操作之前,因爲原來的文本節點丟失,所以我們的光標也失去了。這時候就需要重新把光標定位到 at 標籤之後。簡單來說就是把光標定位到 nextTextNode 節點之前即可。

    // 創建一個 Range,並調整光標

    const range = new Range();

    range.setStart(nextTextNode, 0);

    range.setEnd(nextTextNode, 0);

    const selection = window.getSelection();

    selection?.removeAllRanges();

    selection?.addRange(range);
  1. 優化 at 標籤

第 2 步中,我們創建了 at 標籤,但是會有點小問題。

這時候光標就定位到了『按鈕邊框內』,但光標的位置實際上是正確的。

爲了優化這個問題,首先想到的是在nextTextNode中添加一個『0 寬字符』:\u200b

// 添加 0 寬字符

const nextTextNode = new Text("\u200b" + restSlice);

// 定位光標時,移動一位

const range = new Range();

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 人,最終完結~

總結

前端富文本的坑確實比較多,之前沒怎麼了解過這部分的知識。

雖然整個過程很粗糙,但是道理是這麼個道理。

如果有興趣,也可以到 Playground 玩一玩。

不完善的地方很多,有更好的方式可以共同討論下。

Playground

https://codesandbox.io/s/gallant-euclid-4bxsi?file=/src/Editor.tsx:1247-1985

站在巨人的肩膀

現代 JavaScript 教程 [2]

MDN[3]

參考資料

[1]

Selection API 規範: https://www.w3.org/TR/selection-api/

[2]

現代 JavaScript 教程: https://zh.javascript.info/selection-range

[3]

MDN: https://developer.mozilla.org/en-US/docs/Web/API/Range/getClientRects

歡迎關注公衆號 ELab 團隊 收貨大廠一手好文章~

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/nrJ-TO7M8wSOsEYS0xuWPA