前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >手把手教你实现网页端社交应用中的@人功能:技术原理、代码示例等

手把手教你实现网页端社交应用中的@人功能:技术原理、代码示例等

作者头像
JackJiang
发布2021-12-08 18:53:58
1.1K0
发布2021-12-08 18:53:58
举报
文章被收录于专栏:即时通讯技术即时通讯技术

本文由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、微信,零入侵、高可扩展

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

本文系转载,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文系转载前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1、引言
  • 2、相关资料
  • 3、业内实现
    • 3.1 微博的实现
      • 3.2 Twitter的实现
      • 4、技术思路
      • 5、代码实现第1步:获得用户的光标位置
        • 5.1 范围(Range)
          • 5.2 选择(Selection)
          • 6、代码实现第2步:获取需要@的用户
          • 7、代码实现第3步:弹窗展示以及按键拦截
          • 8、代码实现第3步:替换@文本为定制标签
            • 8.1 把原来的 TextNode 进行切块
              • 8.2 创建 At 标签
                • 8.3 把标签插进去
                  • 8.4 重置光标的位置
                    • 8.5 优化 at 标签
                    • 9、小结一下
                    • 10、参考资料
                    相关产品与服务
                    即时通信 IM
                    即时通信 IM(Instant Messaging)基于腾讯二十余年的 IM 技术积累,支持Android、iOS、Mac、Windows、Web、H5、小程序平台且跨终端互通,低代码 UI 组件助您30分钟集成单聊、群聊、关系链、消息漫游、群组管理、资料管理、直播弹幕和内容审核等能力。适用于直播互动、电商带货、客服咨询、社交沟通、在线课程、企业办公、互动游戏、医疗健康等场景。
                    领券
                    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档