Vue3 + TypeScript 实现高性能@标签富文本编辑器组件

49 阅读8分钟

前言

在现代Web应用中,@标签功能已经成为社交、协作类应用的标配。从微信@群成员、钉钉@同事,到各种评论系统的@用户功能,@标签输入体验直接影响用户的使用感受。本文将详细介绍如何基于Vue3 + TypeScript实现一个高性能、功能完善的@标签富文本编辑器组件。

目录

  1. 需求分析与技术选型
  2. 核心架构设计
  3. 关键实现细节
  4. 性能优化策略
  5. 遇到的坑与解决方案
  6. 完整实现与使用指南
  7. 总结与展望

需求分析与技术选型

功能需求

通过对主流产品的分析,我们确定了以下核心需求:

  • 基础@功能:输入@符号触发用户选择
  • 多行支持:支持Enter换行,@符号仍然有效
  • 智能定位:下拉框自动避开屏幕边界
  • 键盘导航:上下键选择,Enter确认,Esc取消
  • 中文输入:完美支持中文输入法
  • 长度限制:可选的文本长度限制
  • TypeScript:完整的类型支持

技术选型

基于Vue3生态,我们选择了以下技术栈:

// 核心依赖
{
  "vue": "^3.3.4",
  "typescript": "^5.0.2",
  "vite": "^4.4.5"
}

// 为什么选择contenteditable?
// 1. 原生支持,无需额外依赖
// 2. 性能优秀,适合复杂文本编辑
// 3. 与DOM原生交互,控制力强

核心架构设计

组件结构

AtInputSelect/
├── index.vue          # 主组件
├── AtDialog.vue       # 下拉选择框
├── AtUtil.ts          # 类型定义
└── README.md          # 使用文档

数据模型设计

// 核心数据结构
interface IIndicatorNode {
  type: 'text' | 'at'     // 节点类型
  value: string           // 文本内容或@标签key
  entity: string | IIndicatorItem  // 关联实体
}

interface IIndicatorItem {
  key: string | number   // 唯一标识
  name: string          // 显示名称
  demoValue: string     // 演示值
}

状态管理

// 核心状态
const showAtDialog = ref<boolean>(false)     // 下拉框显示状态
const position = ref<IPosition>({ x: 0, y: 0 }) // 下拉框位置
const queryString = ref<string>('')          // 搜索字符串
const isComposited = ref<boolean>(false)     // 中文输入状态

关键实现细节

1. @符号检测算法

这是整个组件的核心,需要准确检测用户输入的@符号:

const showAt = () => {
  const selection = window.getSelection()
  if (!selection || !selection.focusNode) return false
  
  let content = ''
  let cursorIndex = 0
  
  // 处理不同类型的节点
  if (selection.focusNode.nodeType === Node.TEXT_NODE) {
    content = selection.focusNode.textContent || ''
    cursorIndex = selection.focusOffset
  } else if (selection.focusNode.nodeType === Node.ELEMENT_NODE) {
    const range = selection.getRangeAt(0)
    const preCaretRange = range.cloneRange()
    preCaretRange.selectNodeContents(selection.focusNode)
    preCaretRange.setEnd(range.endContainer, range.endOffset)
    content = preCaretRange.toString()
    cursorIndex = content.length
  }
  
  const beforeCursor = content.slice(0, cursorIndex)
  const regx = /@([^@\s]*)$/
  const match = regx.exec(beforeCursor)
  
  return match && match.length === 2
}

技术要点:

  • 使用Range.cloneRange()获取光标前的所有文本
  • 正则表达式/@([^@\s]*)$/匹配@符号后的非空格字符
  • 兼容文本节点和元素节点两种情况

2. 智能定位算法

下拉框位置计算是用户体验的关键:

const getRangeRect = () => {
  const selection = window.getSelection()
  const range = selection?.getRangeAt(0)
  const rect = range?.getClientRects()[0]
  const clientWidth = window.innerWidth
  const clientHeight = window.innerHeight
  const DIALOG_WIDTH = 224
  const DIALOG_MAX_HEIGHT = 296
  const MARGIN = 16
  
  // 获取页面滚动位置
  const scrollX = window.pageXOffset || document.documentElement.scrollLeft
  const scrollY = window.pageYOffset || document.documentElement.scrollTop
  
  // 计算光标在页面中的绝对位置(加上滚动偏移)
  let x = (rect?.x || 0) + scrollX
  let y = (rect?.y || 0) + scrollY + 30 // LINE_HEIGHT
  
  // 防止超出右边界
  if (x + DIALOG_WIDTH > clientWidth + scrollX) {
    x = clientWidth + scrollX - DIALOG_WIDTH - MARGIN
  }
  
  // 防止超出左边界
  if (x < MARGIN + scrollX) {
    x = MARGIN + scrollX
  }
  
  // 智能上下定位
  if (y + DIALOG_MAX_HEIGHT > clientHeight + scrollY) {
    const aboveY = (rect?.y || 0) + scrollY - DIALOG_MAX_HEIGHT
    if (aboveY >= MARGIN + scrollY) {
      y = aboveY // 显示在上方
    } else {
      y = MARGIN + scrollY // 显示在顶部
    }
  }
  
  return { x, y }
}

技术亮点:

  • 使用getClientRects()获取光标准确位置
  • 四向边界检测,确保下拉框始终可见
  • 智能上下定位,优先显示在下方,空间不足时显示在上方
  • 新增:页面滚动偏移计算,确保下拉框位置跟随滚动

2.1 滚动跟随机制

为了实现下拉框跟随页面滚动,我们添加了滚动事件监听:

// 滚动事件处理函数
let scrollHandler: ((e: Event) => void) | null = null

// 显示下拉框时添加滚动监听
const handleShow = () => {
  showAtDialog.value = true
  if (!scrollHandler) {
    scrollHandler = () => {
      if (showAtDialog.value) {
        position.value = getRangeRect()
      }
    }
    window.addEventListener('scroll', scrollHandler, true)
  }
}

// 隐藏下拉框时移除滚动监听
const handleHide = () => {
  showAtDialog.value = false
  if (scrollHandler) {
    window.removeEventListener('scroll', scrollHandler, true)
    scrollHandler = null
  }
}

// 组件卸载时清理
onUnmounted(() => {
  if (scrollHandler) {
    window.removeEventListener('scroll', scrollHandler, true)
    scrollHandler = null
  }
})

关键技术点:

  • 使用绝对定位(position: absolute)替代固定定位
  • 实时监听滚动事件,动态更新下拉框位置
  • 组件卸载时正确清理事件监听器,避免内存泄漏

3. Teleport渲染优化与定位策略

使用Vue3的Teleport避免下拉框被遮挡,配合绝对定位实现滚动跟随:

<template>
  <teleport to="body">
    <div class="wrapper" :style="{
      position: 'absolute',
      top: position?.y + 'px',
      left: position?.x + 'px',
      zIndex: 9999
    }">
      <!-- 下拉框内容 -->
    </div>
  </teleport>
</template>

优势:

  • 避免父元素overflow: hidden的影响
  • 脱离文档流,不影响原有布局
  • 高层级z-index确保始终在最上层
  • 新增:绝对定位配合滚动监听,实现完美的滚动跟随效果

定位策略对比:

  • position: fixed:相对于视口定位,滚动时位置固定不变
  • position: absolute:相对于文档定位,滚动时跟随页面内容移动

为了实现滚动跟随,我们选择了position: absolute,这样下拉框会随着页面滚动而移动,始终保持与光标的相对位置不变。

4. 多行输入支持

通过CSS属性控制单行/多行模式:

<template>
  <div 
    class="editor" 
    :style="{
      whiteSpace: multiline ? 'pre-wrap' : 'nowrap',
      overflowY: multiline ? 'auto' : 'hidden',
      minHeight: multiline ? minHeight + 'px' : '32px',
      maxHeight: multiline ? maxHeight + 'px' : '32px'
    }"
  ></div>
</template>

Enter键处理:

const handleKeyDown = (e: any) => {
  // 多行模式下允许换行,单行模式下阻止
  if (e.code === 'Enter' && !showAtDialog.value) {
    if (!props.multiline || props.isInput) {
      e.preventDefault()
      return false
    }
  }
}

5. 中文输入法支持

解决中文输入法与@符号检测的冲突:

const handleCompositionStart = (e: any) => {
  isComposited.value = true
}

const handleCompositionEnd = (e: any) => {
  isComposited.value = false
  if (showAtDialog.value) {
    queryString.value = getAtUser() || ''
  }
}

const handleKeyUp = (e: any) => {
  // 中文输入期间不处理@符号检测
  if (isComposited.value) return false
  // ... 其他逻辑
}

性能优化策略

1. 防抖搜索

let timer: any = null
watch(() => props.queryString, (searchName: string) => {
  if (timer) clearTimeout(timer)
  timer = setTimeout(() => {
    refleshData(searchName)
  }, 100)
})

2. 延迟数据保存

const handleKeyUp = (e: any) => {
  // ... @符号检测逻辑
  
  // 延迟调用onSaveingData,避免干扰@符号检测
  setTimeout(() => {
    onSaveingData()
  }, 0)
}

3. 智能事件监听

onMounted(() => {
  document.addEventListener('keyup', keyDownhandler, true)
  document.addEventListener('click', checkClickHandler, true)
})

onUnmounted(() => {
  document.removeEventListener('keyup', keyDownhandler, true)
  document.removeEventListener('click', checkClickHandler, true)
})

遇到的坑与解决方案

坑1:输入@符号后无法继续输入

问题现象: 输入@符号后,整个输入框被锁定,无法继续输入。

根本原因:

// 错误的逻辑
onSaveingData()
if (maxlength.value >= props.limit) {
  e.preventDefault() // 总是被阻止!
}

解决方案:

// 修复后的逻辑
if (isNeedLimit.value) {
  onSaveingData()
  if (maxlength.value >= props.limit) {
    e.preventDefault()
  }
}

经验总结:

  • 边界条件要仔细测试,特别是0值的情况
  • 限制逻辑应该只在设置了限制时才生效
  • 防御性编程,避免意外的副作用

坑2:下拉框不跟随页面滚动

问题现象: 在页面滚动时,下拉框位置保持不变,与光标位置脱节。

根本原因: 使用position: fixed定位,下拉框相对于视口定位,不会跟随页面滚动。

解决方案:

// 1. 修改定位方式为absolute
position: 'absolute'  // 替代 'fixed'

// 2. 计算滚动偏移
const scrollX = window.pageXOffset || document.documentElement.scrollLeft
const scrollY = window.pageYOffset || document.documentElement.scrollTop

// 3. 更新位置计算
let x = (rect?.x || 0) + scrollX
let y = (rect?.y || 0) + scrollY + LINE_HEIGHT

// 4. 添加滚动监听
scrollHandler = () => {
  if (showAtDialog.value) {
    position.value = getRangeRect()
  }
}
window.addEventListener('scroll', scrollHandler, true)

坑3:中文输入法冲突

问题现象: 使用中文输入法输入@符号时,触发多次选择框。

解决方案: 使用compositionstartcompositionend事件跟踪输入法状态。

完整实现与使用指南

基础使用

<template>
  <AtInputSelect
    v-model="content"
    place-holder="请输入内容,使用@提及他人"
    :comp-width="500"
  />
</template>

<script setup>
import { ref } from 'vue'
import AtInputSelect from './components/AtInputSelect/index.vue'

const content = ref([])
</script>

高级配置

<template>
  <AtInputSelect
    v-model="content"
    place-holder="支持多行输入,按Enter换行"
    :multiline="true"
    :min-height="100"
    :max-height="200"
    :limit="100"
    :comp-width="600"
    @update:modelValue="handleChange"
  />
</template>

API文档

属性类型默认值说明
modelValueArray[]绑定值
placeHolderString''占位符
multilineBooleanfalse多行模式
limitNumber0长度限制
minHeightNumber32最小高度
maxHeightNumber120最大高度

总结与展望

技术收获

  1. 深入理解contenteditable:掌握了DOM操作和Selection API
  2. 性能优化实践:防抖、延迟、智能事件监听
  3. 边界条件处理:中文输入、屏幕边界、长度限制
  4. 组件设计思维:可配置、可扩展、易维护

未来优化方向

  1. 功能增强

    • 支持#话题标签
    • 表情符号支持
    • 富文本格式化
  2. 性能优化

    • 虚拟滚动(大量用户时)
    • Web Worker处理搜索
    • 懒加载用户数据
  3. 体验提升

    • 移动端适配
    • 无障碍支持
    • 主题定制
  4. 滚动跟随优化

    • 支持容器内滚动(如iframe、modal等)
    • 平滑滚动动画
    • 智能滚动边界检测

开源贡献

这个组件已经开源,欢迎贡献代码和提出建议:

# 项目地址
https://github.com/weisXX/AtSelect

# 安装使用
npm install @weisxx/at-select

结语

通过这个@标签富文本编辑器的实现,我们深入了解了Vue3组件开发的各种技巧和最佳实践。从需求分析到架构设计,从核心算法到性能优化,每一步都体现了前端工程师的专业素养。

特别值得一提的是滚动跟随功能的实现,这个看似简单的功能背后涉及到了DOM定位、事件监听、内存管理等多个技术点。通过将position: fixed改为position: absolute,配合滚动偏移计算和实时位置更新,我们完美解决了下拉框不跟随页面滚动的问题。

希望这篇文章能帮助到正在开发类似功能的开发者,也期待大家在使用过程中提出宝贵的意见和建议。让我们一起构建更好的用户体验!