鸿蒙OS&UniApp开发富文本编辑器组件#三方框架 #Uniapp

发布于:2025-05-14 ⋅ 阅读:(9) ⋅ 点赞:(0)

使用UniApp开发富文本编辑器组件

富文本编辑在各类应用中非常常见,无论是内容创作平台还是社交软件,都需要提供良好的富文本编辑体验。本文记录了我使用UniApp开发一个跨平台富文本编辑器组件的过程,希望对有类似需求的开发者有所启发。

背景

前段时间接到一个需求,要求在我们的跨平台应用中加入富文本编辑功能,支持基础的文本格式化、插入图片、链接等功能。考虑到项目使用UniApp开发,需要兼容多个平台,市面上现成的富文本编辑器要么不支持跨平台,要么功能过于复杂。于是我决定自己动手,开发一个功能适中、性能良好的富文本编辑器组件。

技术选型

为何不直接使用现有组件?

首先,我调研了几个流行的富文本编辑器:

  1. quill.js - 功能强大,但在小程序环境中存在兼容性问题
  2. wangeditor - 针对Web端优化,小程序支持不佳
  3. mp-html - 专注于小程序,但编辑功能有限

UniApp官方提供的rich-text组件只具备富文本展示能力,不支持编辑。所以最终决定基于原生能力自己封装一个轻量级的富文本编辑器组件。

核心技术点

  • 使用uni.createSelectorQuery获取DOM节点
  • 基于contenteditable特性实现编辑功能
  • 自定义文本选区和格式化操作
  • 跨平台样式处理
  • 图片上传和展示

开发实现

1. 创建基础组件结构

首先,我们需要创建一个基础的编辑器组件结构:

<template>
  <view class="rich-editor">
    <view class="toolbar">
      <view 
        v-for="(item, index) in tools" 
        :key="index"
        class="tool-item"
        :class="{active: activeFormats[item.format]}"
        @tap="handleFormat(item.format, item.value)"
      >
        <text class="iconfont" :class="item.icon"></text>
      </view>
    </view>
    
    <!-- 编辑区域 -->
    <view 
      class="editor-container"
      :style="{ height: editorHeight + 'px' }"
    >
      <view
        class="editor-body"
        contenteditable="true"
        @input="onInput"
        @blur="onBlur"
        @focus="onFocus"
        id="editor"
        ref="editor"
      ></view>
    </view>
    
    <!-- 底部工具栏 -->
    <view class="bottom-tools">
      <view class="tool-item" @tap="insertImage">
        <text class="iconfont icon-image"></text>
      </view>
      <view class="tool-item" @tap="insertLink">
        <text class="iconfont icon-link"></text>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  name: 'RichEditor',
  props: {
    value: {
      type: String,
      default: ''
    },
    height: {
      type: Number,
      default: 300
    },
    placeholder: {
      type: String,
      default: '请输入内容...'
    }
  },
  data() {
    return {
      editorHeight: 300,
      editorContent: '',
      selectionRange: null,
      activeFormats: {
        bold: false,
        italic: false,
        underline: false,
        strikethrough: false,
        alignLeft: true,
        alignCenter: false,
        alignRight: false
      },
      tools: [
        { format: 'bold', icon: 'icon-bold', value: 'bold' },
        { format: 'italic', icon: 'icon-italic', value: 'italic' },
        { format: 'underline', icon: 'icon-underline', value: 'underline' },
        { format: 'strikethrough', icon: 'icon-strikethrough', value: 'line-through' },
        { format: 'alignLeft', icon: 'icon-align-left', value: 'left' },
        { format: 'alignCenter', icon: 'icon-align-center', value: 'center' },
        { format: 'alignRight', icon: 'icon-align-right', value: 'right' }
      ]
    }
  },
  created() {
    this.editorHeight = this.height
    this.editorContent = this.value
  },
  mounted() {
    this.initEditor()
  },
  methods: {
    initEditor() {
      const editor = this.$refs.editor
      if (editor) {
        editor.innerHTML = this.value || `<p><br></p>`
      }
      
      // 设置placeholder
      if (!this.value && this.placeholder) {
        this.$nextTick(() => {
          editor.setAttribute('data-placeholder', this.placeholder)
        })
      }
    },
    
    // 监听输入
    onInput(e) {
      // 获取当前内容
      this.editorContent = e.target.innerHTML
      this.$emit('input', this.editorContent)
      this.saveSelection()
    },
    
    // 保存当前选区
    saveSelection() {
      const selection = window.getSelection()
      if (selection.rangeCount > 0) {
        this.selectionRange = selection.getRangeAt(0)
      }
    },
    
    // 恢复选区
    restoreSelection() {
      if (this.selectionRange) {
        const selection = window.getSelection()
        selection.removeAllRanges()
        selection.addRange(this.selectionRange)
        return true
      }
      return false
    },
    
    // 处理格式化
    handleFormat(format, value) {
      // 恢复选区
      if (!this.restoreSelection()) {
        console.log('No selection to format')
        return
      }
      
      // 根据不同格式执行不同操作
      switch(format) {
        case 'bold':
        case 'italic':
        case 'underline':
        case 'strikethrough':
          document.execCommand(format, false, null)
          break
        case 'alignLeft':
        case 'alignCenter':
        case 'alignRight':
          document.execCommand('justify' + format.replace('align', ''), false, null)
          break
        default:
          console.log('未知格式:', format)
      }
      
      // 更新激活状态
      this.checkActiveFormats()
      
      // 触发内容变化
      this.editorContent = this.$refs.editor.innerHTML
      this.$emit('input', this.editorContent)
    },
    
    // 检查当前激活的格式
    checkActiveFormats() {
      this.activeFormats.bold = document.queryCommandState('bold')
      this.activeFormats.italic = document.queryCommandState('italic')
      this.activeFormats.underline = document.queryCommandState('underline')
      this.activeFormats.strikethrough = document.queryCommandState('strikethrough')
      
      const alignment = document.queryCommandValue('justifyLeft') ? 'alignLeft' :
                       document.queryCommandValue('justifyCenter') ? 'alignCenter' :
                       document.queryCommandValue('justifyRight') ? 'alignRight' : 'alignLeft'
      
      this.activeFormats.alignLeft = alignment === 'alignLeft'
      this.activeFormats.alignCenter = alignment === 'alignCenter'
      this.activeFormats.alignRight = alignment === 'alignRight'
    },
    
    // 焦点事件
    onFocus() {
      this.saveSelection()
      this.checkActiveFormats()
    },
    
    onBlur() {
      this.saveSelection()
    },
    
    // 插入图片
    insertImage() {
      uni.chooseImage({
        count: 1,
        success: (res) => {
          const tempFilePath = res.tempFilePaths[0]
          // 上传图片
          this.uploadImage(tempFilePath)
        }
      })
    },
    
    // 上传图片
    uploadImage(filePath) {
      // 这里应该是实际的上传逻辑
      uni.showLoading({ title: '上传中...' })
      
      // 模拟上传过程
      setTimeout(() => {
        // 假设这是上传后的图片URL
        const imageUrl = filePath
        
        // 恢复选区并插入图片
        this.restoreSelection()
        document.execCommand('insertHTML', false, `<img src="${imageUrl}" style="max-width:100%;" />`)
        
        // 更新内容
        this.editorContent = this.$refs.editor.innerHTML
        this.$emit('input', this.editorContent)
        
        uni.hideLoading()
      }, 500)
    },
    
    // 插入链接
    insertLink() {
      uni.showModal({
        title: '插入链接',
        editable: true,
        placeholderText: 'https://',
        success: (res) => {
          if (res.confirm && res.content) {
            const url = res.content
            // 恢复选区
            this.restoreSelection()
            
            // 获取选中的文本
            const selection = window.getSelection()
            const selectedText = selection.toString()
            
            // 如果有选中文本,将其设为链接文本;否则使用URL作为文本
            const linkText = selectedText || url
            
            // 插入链接
            document.execCommand('insertHTML', false, 
              `<a href="${url}" target="_blank">${linkText}</a>`)
            
            // 更新内容
            this.editorContent = this.$refs.editor.innerHTML
            this.$emit('input', this.editorContent)
          }
        }
      })
    },
    
    // 获取编辑器内容
    getContent() {
      return this.editorContent
    },
    
    // 设置编辑器内容
    setContent(html) {
      this.editorContent = html
      if (this.$refs.editor) {
        this.$refs.editor.innerHTML = html
      }
      this.$emit('input', html)
    }
  }
}
</script>

<style>
.rich-editor {
  width: 100%;
  border: 1rpx solid #eee;
  border-radius: 10rpx;
  overflow: hidden;
}

.toolbar {
  display: flex;
  flex-wrap: wrap;
  padding: 10rpx;
  border-bottom: 1rpx solid #eee;
  background-color: #f8f8f8;
}

.tool-item {
  width: 80rpx;
  height: 80rpx;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 40rpx;
  color: #333;
}

.tool-item.active {
  color: #007AFF;
  background-color: rgba(0, 122, 255, 0.1);
  border-radius: 8rpx;
}

.editor-container {
  width: 100%;
  overflow-y: auto;
}

.editor-body {
  min-height: 100%;
  padding: 20rpx;
  font-size: 28rpx;
  line-height: 1.5;
  outline: none;
}

.editor-body[data-placeholder]:empty:before {
  content: attr(data-placeholder);
  color: #999;
  font-style: italic;
}

.bottom-tools {
  display: flex;
  padding: 10rpx;
  border-top: 1rpx solid #eee;
  background-color: #f8f8f8;
}

/* 引入字体图标库 (需要自行配置) */
@font-face {
  font-family: 'iconfont';
  src: url('data:font/woff2;charset=utf-8;base64,...') format('woff2');
}
.iconfont {
  font-family: "iconfont" !important;
  font-style: normal;
}
</style>

2. 处理平台差异

UniApp支持多个平台,但在富文本编辑方面存在平台差异,特别是小程序限制较多。下面是一些关键的跨平台适配处理:

// 跨平台选区处理
saveSelection() {
  // #ifdef H5
  const selection = window.getSelection()
  if (selection.rangeCount > 0) {
    this.selectionRange = selection.getRangeAt(0)
  }
  // #endif
  
  // #ifdef MP-WEIXIN
  // 微信小程序不支持DOM选区,需使用特殊方法
  this.getEditContext().getSelectionRange({
    success: (res) => {
      this.selectionRange = res
    }
  })
  // #endif
},

// 获取编辑器上下文(微信小程序)
getEditContext() {
  // #ifdef MP-WEIXIN
  return this.editorCtx || wx.createSelectorQuery()
    .in(this)
    .select('#editor')
    .context(res => {
      this.editorCtx = res.context
    })
    .exec()
  // #endif
  
  return null
}

3. 增强图片处理能力

富文本编辑器的一个关键功能是图片处理,我们需要增强这方面的能力:

// 增强版图片上传处理
uploadImage(filePath) {
  uni.showLoading({ title: '上传中...' })
  
  // 压缩图片
  uni.compressImage({
    src: filePath,
    quality: 80,
    success: res => {
      const compressedPath = res.tempFilePath
      
      // 上传到服务器
      uni.uploadFile({
        url: 'https://your-upload-endpoint.com/upload',
        filePath: compressedPath,
        name: 'file',
        success: uploadRes => {
          try {
            const data = JSON.parse(uploadRes.data)
            const imageUrl = data.url
            
            // 插入图片
            this.insertImageToEditor(imageUrl)
          } catch (e) {
            uni.showToast({
              title: '上传失败',
              icon: 'none'
            })
          }
        },
        fail: () => {
          uni.showToast({
            title: '上传失败',
            icon: 'none'
          })
        },
        complete: () => {
          uni.hideLoading()
        }
      })
    },
    fail: () => {
      // 压缩失败,使用原图
      this.doUploadFile(filePath)
    }
  })
},

// 插入图片到编辑器
insertImageToEditor(imageUrl) {
  // #ifdef H5
  this.restoreSelection()
  document.execCommand('insertHTML', false, `<img src="${imageUrl}" style="max-width:100%;" />`)
  // #endif
  
  // #ifdef MP-WEIXIN
  this.getEditContext().insertImage({
    src: imageUrl,
    width: '100%',
    success: () => {
      console.log('插入图片成功')
    }
  })
  // #endif
  
  // 更新内容
  this.$nextTick(() => {
    // #ifdef H5
    this.editorContent = this.$refs.editor.innerHTML
    // #endif
    
    // #ifdef MP-WEIXIN
    this.getEditContext().getContents({
      success: res => {
        this.editorContent = res.html
      }
    })
    // #endif
    
    this.$emit('input', this.editorContent)
  })
}

4. 实现HTML与富文本互转

编辑器需要支持HTML格式的导入导出,以便存储和展示:

// HTML转富文本对象
htmlToJson(html) {
  const tempDiv = document.createElement('div')
  tempDiv.innerHTML = html
  
  const parseNode = (node) => {
    if (node.nodeType === 3) { // 文本节点
      return {
        type: 'text',
        text: node.textContent
      }
    }
    
    if (node.nodeType === 1) { // 元素节点
      const result = {
        type: node.nodeName.toLowerCase(),
        children: []
      }
      
      // 处理元素属性
      if (node.attributes && node.attributes.length > 0) {
        result.attrs = {}
        for (let i = 0; i < node.attributes.length; i++) {
          const attr = node.attributes[i]
          result.attrs[attr.name] = attr.value
        }
      }
      
      // 处理样式
      if (node.style && node.style.cssText) {
        result.styles = {}
        const styles = node.style.cssText.split(';')
        styles.forEach(style => {
          if (style.trim()) {
            const [key, value] = style.split(':')
            if (key && value) {
              result.styles[key.trim()] = value.trim()
            }
          }
        })
      }
      
      // 递归处理子节点
      for (let i = 0; i < node.childNodes.length; i++) {
        const childResult = parseNode(node.childNodes[i])
        if (childResult) {
          result.children.push(childResult)
        }
      }
      
      return result
    }
    
    return null
  }
  
  const result = []
  for (let i = 0; i < tempDiv.childNodes.length; i++) {
    const nodeResult = parseNode(tempDiv.childNodes[i])
    if (nodeResult) {
      result.push(nodeResult)
    }
  }
  
  return result
},

// 富文本对象转HTML
jsonToHtml(json) {
  if (!json || !Array.isArray(json)) return ''
  
  const renderNode = (node) => {
    if (node.type === 'text') {
      return node.text
    }
    
    // 处理元素节点
    let html = `<${node.type}`
    
    // 添加属性
    if (node.attrs) {
      Object.keys(node.attrs).forEach(key => {
        html += ` ${key}="${node.attrs[key]}"`
      })
    }
    
    // 添加样式
    if (node.styles) {
      let styleStr = ''
      Object.keys(node.styles).forEach(key => {
        styleStr += `${key}: ${node.styles[key]};`
      })
      if (styleStr) {
        html += ` style="${styleStr}"`
      }
    }
    
    html += '>'
    
    // 处理子节点
    if (node.children && node.children.length > 0) {
      node.children.forEach(child => {
        html += renderNode(child)
      })
    }
    
    // 关闭标签
    html += `</${node.type}>`
    
    return html
  }
  
  let result = ''
  json.forEach(node => {
    result += renderNode(node)
  })
  
  return result
}

实战案例:评论编辑器

下面是一个简化版的评论编辑器实现,可以在社区或博客应用中使用:

<template>
  <view class="comment-editor">
    <view class="editor-title">
      <text>发表评论</text>
    </view>
    
    <rich-editor
      v-model="commentContent"
      :height="200"
      placeholder="说点什么吧..."
      ref="editor"
    ></rich-editor>
    
    <view class="action-bar">
      <view class="action-btn cancel" @tap="cancel">取消</view>
      <view class="action-btn submit" @tap="submitComment">发布</view>
    </view>
  </view>
</template>

<script>
import RichEditor from '@/components/rich-editor/rich-editor.vue'

export default {
  components: {
    RichEditor
  },
  data() {
    return {
      commentContent: '',
      replyTo: null
    }
  },
  props: {
    articleId: {
      type: [String, Number],
      required: true
    }
  },
  methods: {
    cancel() {
      this.commentContent = ''
      this.$refs.editor.setContent('')
      this.$emit('cancel')
    },
    
    submitComment() {
      if (!this.commentContent.trim()) {
        uni.showToast({
          title: '评论内容不能为空',
          icon: 'none'
        })
        return
      }
      
      uni.showLoading({ title: '发布中...' })
      
      // 提交评论
      this.$api.comment.add({
        article_id: this.articleId,
        content: this.commentContent,
        reply_to: this.replyTo
      }).then(res => {
        uni.hideLoading()
        
        if (res.code === 0) {
          uni.showToast({
            title: '评论发布成功',
            icon: 'success'
          })
          
          // 清空编辑器
          this.commentContent = ''
          this.$refs.editor.setContent('')
          
          // 通知父组件刷新评论列表
          this.$emit('submit-success', res.data)
        } else {
          uni.showToast({
            title: res.msg || '评论发布失败',
            icon: 'none'
          })
        }
      }).catch(() => {
        uni.hideLoading()
        uni.showToast({
          title: '网络错误,请重试',
          icon: 'none'
        })
      })
    },
    
    // 回复某条评论
    replyComment(comment) {
      this.replyTo = comment.id
      this.$refs.editor.setContent(`<p>回复 @${comment.user.nickname}:</p>`)
      this.$refs.editor.focus()
    }
  }
}
</script>

<style>
.comment-editor {
  padding: 20rpx;
  background-color: #fff;
  border-radius: 10rpx;
}

.editor-title {
  margin-bottom: 20rpx;
  font-size: 32rpx;
  font-weight: bold;
}

.action-bar {
  display: flex;
  justify-content: flex-end;
  margin-top: 20rpx;
}

.action-btn {
  padding: 10rpx 30rpx;
  border-radius: 30rpx;
  font-size: 28rpx;
  margin-left: 20rpx;
}

.cancel {
  color: #666;
  background-color: #f3f3f3;
}

.submit {
  color: #fff;
  background-color: #007AFF;
}
</style>

踩坑记录

开发过程中遇到了不少坑,这里分享几个关键问题及解决方案:

1. 小程序富文本能力受限

小程序不支持通过contenteditable实现的富文本编辑,需要使用平台提供的editor组件。解决方案是使用条件编译,H5使用contenteditable,小程序使用官方editor组件。

<!-- H5编辑器 -->
<!-- #ifdef H5 -->
<div 
  class="editor-body"
  contenteditable="true"
  @input="onInput"
  id="editor"
  ref="editor"
></div>
<!-- #endif -->

<!-- 小程序编辑器 -->
<!-- #ifdef MP-WEIXIN -->
<editor 
  id="editor" 
  class="editor-body" 
  :placeholder="placeholder"
  @ready="onEditorReady"
  @input="onInput"
></editor>
<!-- #endif -->

2. 选区处理差异

不同平台的选区API差异很大,需要分别处理:

// 处理选区问题
getSelectionRange() {
  return new Promise((resolve) => {
    // #ifdef H5
    const selection = window.getSelection()
    if (selection.rangeCount > 0) {
      resolve(selection.getRangeAt(0))
    } else {
      resolve(null)
    }
    // #endif
    
    // #ifdef MP-WEIXIN
    this.editorCtx.getSelectionRange({
      success: (res) => {
        resolve(res)
      },
      fail: () => {
        resolve(null)
      }
    })
    // #endif
  })
}

3. 图片上传大小限制

多端应用中,图片上传和展示需要考虑不同平台的限制:

// 处理图片大小限制
async handleImageUpload(file) {
  // 检查文件大小
  if (file.size > 5 * 1024 * 1024) { // 5MB
    uni.showToast({
      title: '图片不能超过5MB',
      icon: 'none'
    })
    return null
  }
  
  // 压缩图片
  try {
    // H5与小程序压缩方式不同
    // #ifdef H5
    const compressedFile = await this.compressImageH5(file)
    return compressedFile
    // #endif
    
    // #ifdef MP
    const compressedPath = await this.compressImageMP(file.path)
    return { path: compressedPath }
    // #endif
  } catch (e) {
    console.error('图片压缩失败', e)
    return file // 失败时使用原图
  }
}

性能优化

为了让编辑器运行更流畅,我做了以下优化:

  1. 输入防抖 - 减少频繁更新导致的性能问题
  2. 延迟加载图片 - 使用懒加载机制
  3. 减少DOM操作 - 尽量批量更新DOM
  4. 使用虚拟DOM - 在复杂场景下考虑使用Vue的虚拟DOM机制
// 输入防抖处理
onInput(e) {
  if (this.inputTimer) {
    clearTimeout(this.inputTimer)
  }
  
  this.inputTimer = setTimeout(() => {
    // #ifdef H5
    this.editorContent = this.$refs.editor.innerHTML
    // #endif
    
    // #ifdef MP-WEIXIN
    this.editorContent = e.detail.html
    // #endif
    
    this.$emit('input', this.editorContent)
  }, 300)
}

总结

通过这次开发实践,我实现了一个跨平台的富文本编辑器组件,总结几点经验:

  1. 平台差异是最大挑战,需要利用条件编译提供各平台最佳实现
  2. 功能要适中,不是所有Web富文本功能都适合移动端
  3. 性能优化很重要,尤其是在低端设备上
  4. 良好的用户体验需要细节打磨,如适当的反馈、容错处理等

富文本编辑是一个复杂的课题,即使是成熟的Web编辑器也有各种问题。在移动端和小程序环境中,受限更多。我们的方案虽然不完美,但通过合理的取舍和平台适配,已经能满足大部分应用场景的需求。

后续还可以继续完善这个组件,比如添加表格支持、代码高亮、Markdown转换等高级功能。希望本文对你有所启发,欢迎在评论区交流讨论!

参考资料

  1. UniApp官方文档
  2. execCommand API参考
  3. ContentEditable详解