<rt id="bn8ez"></rt>
<label id="bn8ez"></label>

  • <span id="bn8ez"></span>

    <label id="bn8ez"><meter id="bn8ez"></meter></label>

    Jack Jiang

    我的最新工程MobileIMSDK:http://git.oschina.net/jackjiang/MobileIMSDK
    posts - 503, comments - 13, trackbacks - 0, articles - 1

    本文由ELab團(tuán)隊(duì)技術(shù)團(tuán)隊(duì)分享,原題“Twitter和微博都在用的 @ 人的功能是如何設(shè)計(jì)與實(shí)現(xiàn)的?”,有修訂。

    1、引言

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

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

    學(xué)習(xí)交流:

    - 即時(shí)通訊/推送技術(shù)開發(fā)交流5群:215477170 [推薦]

    - 移動(dòng)端IM開發(fā)入門文章:《新手入門一篇就夠:從零開發(fā)移動(dòng)端IM

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

    本文已同步發(fā)布于:http://www.52im.net/thread-3767-1-1.html

    2、相關(guān)資料

    本文分享的@人功能是針對(duì)Web網(wǎng)頁(yè)前端的,跟移動(dòng)端原生代碼的實(shí)現(xiàn),從技術(shù)原理和實(shí)際實(shí)現(xiàn)上,還是有很大差異,所以如果想了解移動(dòng)端IM這種社交應(yīng)用中的@人實(shí)現(xiàn)功能,可以讀一下《Android端IM應(yīng)用中的@人功能實(shí)現(xiàn):仿微博、QQ、微信,零入侵、高可擴(kuò)展[圖文+源碼]》這篇文章。

    3、業(yè)內(nèi)實(shí)現(xiàn)

    3.1 微博的實(shí)現(xiàn)

    微博的實(shí)現(xiàn)比較簡(jiǎn)單,就是通過正則匹配,最后用空格表示匹配結(jié)束,所以實(shí)現(xiàn)上是直接使用了textarea標(biāo)簽。

    但是這個(gè)實(shí)現(xiàn)必須依賴的一個(gè)事情是:用戶名必須唯一。

    微博的用戶名就是唯一的,所以正則所匹配到的ID,一般的可以映射到唯一的一個(gè)用戶上(除非ID不存在)。不過,微博中的這個(gè)功能整體輸出比較寬松,你可以構(gòu)造任何不存在的ID進(jìn)行@操作。

    3.2 Twitter的實(shí)現(xiàn)

    Twitter 的實(shí)現(xiàn)跟微博類似,也是以@開始,空格結(jié)尾做匹配。但是使用的是 contenteditable 這個(gè)屬性進(jìn)行富文本操作。

    相似之處在于 Twitter 的 ID 也是唯一,但是可以通過昵稱進(jìn)行搜索,然后轉(zhuǎn)化成 ID,這一點(diǎn)在體驗(yàn)上好了不少。

    4、技術(shù)思路

    通過分析業(yè)內(nèi)的主流實(shí)現(xiàn),@人功能的技術(shù)實(shí)現(xiàn)思路大致如下:

    • 1)監(jiān)聽用戶輸入,匹配用戶以@開頭的文字;
    • 2)調(diào)用搜索彈窗,展示搜索出來(lái)的用戶列表;
    • 3)監(jiān)聽上、下、回車鍵控制列表選擇,監(jiān)聽ESC鍵關(guān)閉搜索彈窗;
    • 4)選擇需要@的用戶,把對(duì)應(yīng)的HTML文本替換到原文本上,在HTML文本上添加用戶的元數(shù)據(jù)。

    一般來(lái)說(shuō),如果像平常用的Lark搜索(Lark就是“飛書”),我們是不會(huì)通過唯一的『工號(hào)』去進(jìn)行搜索,而是通過名字,但是名字會(huì)出現(xiàn)重復(fù),所以就不太適合用textarea的方式,而是用contenteditable,把@文本替換成HTML標(biāo)簽特殊化標(biāo)記。

    5、代碼實(shí)現(xiàn)第1步:獲得用戶的光標(biāo)位置

    想要獲得用戶輸入的字符串,然后替換進(jìn)去,第一步就是需要獲得用戶所在的光標(biāo)。要獲取光標(biāo)信息,那就要先了解什么是『選擇(Selection) 』和『范圍(Range) 』。

    5.1 范圍(Range)

    Range本質(zhì)上是一對(duì)“邊界點(diǎn)”:范圍起點(diǎn)和范圍終點(diǎn)。

    每個(gè)點(diǎn)都被表示為一個(gè)帶有相對(duì)于起點(diǎn)的相對(duì)偏移(offset)的父 DOM 節(jié)點(diǎn)。如果父節(jié)點(diǎn)是元素節(jié)點(diǎn),則偏移量是子節(jié)點(diǎn)的編號(hào),對(duì)于文本節(jié)點(diǎn),則是文本中的位置。

    例如:

    let range = newRange();

    然后使用 range.setStart(node, offset) 和 range.setEnd(node, offset) 來(lái)設(shè)置選擇邊界。

    假設(shè) HTML 片段是這樣的:

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

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

    <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 以文本形式返回其內(nèi)容(不帶標(biāo)簽)

      alert(range); // Example: italic

      document.getSelection().addRange(range);

    </script>

    解釋一下:

    • 1)range.setStart(p, 0) :將起點(diǎn)設(shè)置為 <p> 的第 0 個(gè)子節(jié)點(diǎn)(即文本節(jié)點(diǎn) "Example: ");
    • 2)range.setEnd(p, 2) : 覆蓋范圍至(但不包括)<p> 的第 2 個(gè)子節(jié)點(diǎn)(即文本節(jié)點(diǎn) " and ",但由于不包括末節(jié)點(diǎn),所以最后選擇的節(jié)點(diǎn)是 <i>)。

    如果像這樣操作:

     

    這也是可以做到的,只需要將起點(diǎn)和終點(diǎn)設(shè)置為文本節(jié)點(diǎn)中的相對(duì)偏移量即可。

    我們需要?jiǎng)?chuàng)建一個(gè)范圍:

    • 1)從的第一個(gè)子節(jié)點(diǎn)的位置 2 開始(選擇 "Example: " 中除前兩個(gè)字母外的所有字母);
    • 2)到 的第一個(gè)子節(jié)點(diǎn)的位置 3 結(jié)束(選擇 “bold” 的前三個(gè)字母,就這些),代碼如下。

    <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 對(duì)象具有以下屬性:

     

    解釋一下:

    • 1)startContainer,startOffset —— 起始節(jié)點(diǎn)和偏移量:
    •   - 在上例中:分別是 <p> 中的第一個(gè)文本節(jié)點(diǎn)和 2。
    • 2)endContainer,endOffset —— 結(jié)束節(jié)點(diǎn)和偏移量:
    •   - 在上例中:分別是 <b> 中的第一個(gè)文本節(jié)點(diǎn)和 3。
    • 3)collapsed —— 布爾值,如果范圍在同一點(diǎn)上開始和結(jié)束(所以范圍內(nèi)沒有內(nèi)容)則為 true:
    •   - 在上例中:false
    • 4)commonAncestorContainer —— 在范圍內(nèi)的所有節(jié)點(diǎn)中最近的共同祖先節(jié)點(diǎn):
    •   - 在上例中:<p>

    5.2 選擇(Selection)

    Range 是用于管理選擇范圍的通用對(duì)象。

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

    根據(jù) Selection API 規(guī)范:一個(gè)選擇可以包括零個(gè)或多個(gè)范圍(不過實(shí)際上,只有 Firefox 允許使用 Ctrl+click (Mac 上用 Cmd+click) 在文檔中選擇多個(gè)范圍)。

    這是在 Firefox 中做的一個(gè)具有 3 個(gè)范圍的選擇的截圖:

    其他瀏覽器最多支持 1 個(gè)范圍。

    正如我們將看到的,某些 Selection 方法暗示可能有多個(gè)范圍,但同樣,在除 Firefox 之外的所有瀏覽器中,范圍最多是 1。

    與范圍相似,選擇的起點(diǎn)稱為“錨點(diǎn)(anchor)”,終點(diǎn)稱為“焦點(diǎn)(focus)”。

    主要的選擇屬性有:

    • 1)anchorNode:選擇的起始節(jié)點(diǎn);
    • 2)anchorOffset:選擇開始的 anchorNode 中的偏移量;
    • 3)focusNode:選擇的結(jié)束節(jié)點(diǎn);
    • 4)focusOffset:選擇開始處 focusNode 的偏移量;
    • 5)isCollapsed:如果未選擇任何內(nèi)容(空范圍)或不存在,則為 true ;
    • 6)rangeCount:選擇中的范圍數(shù),除 Firefox 外,其他瀏覽器最多為 1。

    看完上面,不知道了解了沒?沒關(guān)系,我們繼續(xù)往下。

    綜上所述:一般我們只有一個(gè) Range,當(dāng)我們的光標(biāo)在 contenteditable 的 div 上閃動(dòng)的時(shí)候,其實(shí)就有了一個(gè) Range,這個(gè) Range 的開始和結(jié)束位置都是一樣的。

    另外:我們還可以直接通過 Selection.focusNode獲取到對(duì)應(yīng)的節(jié)點(diǎn),通過 Selection.focusOffset 獲取到對(duì)應(yīng)的偏移量。

    就像下圖:

    這樣,我們就獲取到了光標(biāo)的位置以及對(duì)應(yīng)的TextNode對(duì)象。

    6、代碼實(shí)現(xiàn)第2步:獲取需要@的用戶

    在上一節(jié)我們獲得了光標(biāo)在對(duì)應(yīng)Node節(jié)點(diǎn)的偏移量,以及對(duì)應(yīng)的Node節(jié)點(diǎn)。那么就可以通過textContent方法獲取整個(gè)文本。

    一般來(lái)說(shuō),通過一個(gè)簡(jiǎn)單的正則就可以獲取@的內(nèi)容了:

    // 獲取光標(biāo)位置

    const getCursorIndex = () => {

      const selection = window.getSelection();

      return selection?.focusOffset;

    };

     

     // 獲取節(jié)點(diǎn)

    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;

    };

    因?yàn)?#64;的插入可能是末尾,可能是中間,所以我們?cè)谂袛嗲埃€需要截取光標(biāo)前的文本。

    所以簡(jiǎn)單地slice一下就好了:

    content.slice(0, getCursorIndex())

    7、代碼實(shí)現(xiàn)第3步:彈窗展示以及按鍵攔截

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

    // 是否展示 @

    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

      };

    };

    當(dāng)出現(xiàn)彈窗之后,我們還需要攔截掉輸入框的『上』、『下』、『回車』的操作,否則在輸入框響應(yīng)這些按鍵會(huì)讓光標(biāo)位置偏移到其他地方。

    const handleKeyDown = (e: any) => {

        if(showDialog) {

          if(

            e.code === "ArrowUp"||

            e.code === "ArrowDown"||

            e.code === "Enter"

          ) {

            e.preventDefault();

          }

        }

      };

    然后在彈窗里面監(jiān)聽這些按鍵,實(shí)現(xiàn)上下選擇、回車確定、關(guā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、代碼實(shí)現(xiàn)第3步:替換@文本為定制標(biāo)簽

    大致的原理圖:

    具體我們?cè)敿?xì)分步來(lái)看看。

    8.1 把原來(lái)的 TextNode 進(jìn)行切塊

    假如文本是:請(qǐng)幫我泡一杯咖啡@ABC,這是后面的內(nèi)容”。

    那么我們需要根據(jù)光標(biāo)的位置,替換掉@ABC文本,然后分成前后兩塊:『請(qǐng)幫我泡一杯咖啡』、『這是后面的內(nèi)容』。

    8.2 創(chuàng)建 At 標(biāo)簽

    為了能實(shí)現(xiàn)刪除鍵能把刪除全部刪除,需要把 at 標(biāo)簽的內(nèi)容包裹起來(lái)。

    這是第一版寫的一個(gè)標(biāo)簽,但是如果直接用會(huì)有點(diǎn)小問題,留著后續(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 把標(biāo)簽插進(jìn)去

    首先:我們可以獲取 focusNode 節(jié)點(diǎn),然后就可以獲取它的父節(jié)點(diǎn)以及兄弟節(jié)點(diǎn)。

    現(xiàn)在需要做的是:把舊的文本節(jié)點(diǎn)刪除,然后在原來(lái)的位置上依次插入『請(qǐng)幫我泡一杯咖啡』、【@ABC】、『這是后面的內(nèi)容』。

    具體來(lái)看看代碼:

    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 重置光標(biāo)的位置

    我們這一頓操作之前,因?yàn)樵瓉?lái)的文本節(jié)點(diǎn)丟失,所以我們的光標(biāo)也失去了。這時(shí)候就需要重新把光標(biāo)定位到 at 標(biāo)簽之后。

    簡(jiǎn)單來(lái)說(shuō)就是把光標(biāo)定位到 nextTextNode 節(jié)點(diǎn)之前即可:

    // 創(chuàng)建一個(gè) Range,并調(diào)整光標(biāo)

    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 標(biāo)簽

    第2步中,我們創(chuàng)建了 at 標(biāo)簽,但是會(huì)有點(diǎn)小問題。

    這時(shí)候光標(biāo)就定位到了『按鈕邊框內(nèi)』,但光標(biāo)的位置實(shí)際上是正確的。

    為了優(yōu)化這個(gè)問題,首先想到的是在nextTextNode中添加一個(gè)『0寬字符』——\u200b。

    // 添加 0 寬字符

    const nextTextNode = newText("\u200b"+ restSlice);

    // 定位光標(biāo)時(shí),移動(dòng)一位

    const range = newRange();

    range.setStart(nextTextNode, 1);

    range.setEnd(nextTextNode, 1);

    但是,事情沒那么簡(jiǎn)單。因?yàn)槲野l(fā)現(xiàn)如果往前可能也會(huì)這樣……

    最后一想:把內(nèi)容區(qū)弄寬一點(diǎn)不就行了?比如左右加個(gè)空格?然后就把標(biāo)簽包裹了一層……

    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 人,最終完結(jié)~

    9、小結(jié)一下

    Web前端富文本的坑確實(shí)比較多,之前沒怎么了解過這部分的知識(shí)。雖然整個(gè)過程看起來(lái)很粗糙,但是技術(shù)原理就是這樣。

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

    如果有興趣,也可以到 Playground 玩一玩(點(diǎn)此進(jìn)入)。

    上面鏈接打開后是這樣的,可以在線試試本文代碼的運(yùn)行效果:

    10、參考資料

    [1] Selection的W3C官方API手冊(cè)

    [2] 現(xiàn)代JavaScript 教程

    [3] Range的MDN在線API手冊(cè)

    [4] Android端IM應(yīng)用中的@人功能實(shí)現(xiàn):仿微博、QQ、微信,零入侵、高可擴(kuò)展

    附錄:更多IM入門實(shí)踐文章

    跟著源碼學(xué)IM(一):手把手教你用Netty實(shí)現(xiàn)心跳機(jī)制、斷線重連機(jī)制

    跟著源碼學(xué)IM(二):自已開發(fā)IM很難?手把手教你擼一個(gè)Andriod版IM

    跟著源碼學(xué)IM(三):基于Netty,從零開發(fā)一個(gè)IM服務(wù)端

    跟著源碼學(xué)IM(四):拿起鍵盤就是干,教你徒手開發(fā)一套分布式IM系統(tǒng)

    跟著源碼學(xué)IM(五):正確理解IM長(zhǎng)連接、心跳及重連機(jī)制,并動(dòng)手實(shí)現(xiàn)

    跟著源碼學(xué)IM(六):手把手教你用Go快速搭建高性能、可擴(kuò)展的IM系統(tǒng)

    跟著源碼學(xué)IM(七):手把手教你用WebSocket打造Web端IM聊天

    跟著源碼學(xué)IM(八):萬(wàn)字長(zhǎng)文,手把手教你用Netty打造IM聊天

    本文已同步發(fā)布于“即時(shí)通訊技術(shù)圈”公眾號(hào)。

    同步發(fā)布鏈接是:http://www.52im.net/thread-3767-1-1.html



    作者:Jack Jiang (點(diǎn)擊作者姓名進(jìn)入Github)
    出處:http://www.52im.net/space-uid-1.html
    交流:歡迎加入即時(shí)通訊開發(fā)交流群 215891622
    討論:http://www.52im.net/
    Jack Jiang同時(shí)是【原創(chuàng)Java Swing外觀工程BeautyEye】【輕量級(jí)移動(dòng)端即時(shí)通訊框架MobileIMSDK】的作者,可前往下載交流。
    本博文 歡迎轉(zhuǎn)載,轉(zhuǎn)載請(qǐng)注明出處(也可前往 我的52im.net 找到我)。


    只有注冊(cè)用戶登錄后才能發(fā)表評(píng)論。


    網(wǎng)站導(dǎo)航:
     
    Jack Jiang的 Mail: jb2011@163.com, 聯(lián)系QQ: 413980957, 微信: hellojackjiang
    主站蜘蛛池模板: 久久久久久亚洲精品无码| 亚洲av色福利天堂| 国产男女猛烈无遮挡免费视频网站| 免费99精品国产自在现线| 国产精品视频免费观看| 免费不卡视频一卡二卡| 99精品国产免费久久久久久下载| 精品香蕉在线观看免费| 在线v片免费观看视频| 最新免费jlzzjlzz在线播放| 国产精品美女午夜爽爽爽免费| 免费无码黄十八禁网站在线观看| 岛国av无码免费无禁网站| 成人a视频片在线观看免费| 精品国产免费观看一区| 国产免费看插插插视频| 亚洲精品综合久久| 亚洲精品无码国产| 亚洲av女电影网| 亚洲一级毛片免费看| 亚洲日韩国产欧美一区二区三区| 久久精品国产亚洲AV未满十八| 黄色a三级三级三级免费看| 美女巨胸喷奶水视频www免费| 秋霞人成在线观看免费视频| 中文字幕视频免费| 四虎影院免费在线播放| 亚洲AV无码成H人在线观看 | 一级毛片a免费播放王色 | 亚洲白色白色在线播放| 亚洲伦理中文字幕| 男男gvh肉在线观看免费| 91免费福利视频| 久草免费在线观看视频| 免费人成视频在线观看视频| 亚洲一区二区女搞男| 亚洲国产综合第一精品小说| 精品久久久久久亚洲中文字幕| 国产成人无码免费看片软件| 91精品国产免费网站| 免费观看美女裸体网站|