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

[复制链接]
发表于 2025-9-1 11:57:56 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

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

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

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

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

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

  • quill.js - 功能强盛,但在小步伐环境中存在兼容性问题
  • wangeditor - 针对Web端优化,小步伐支持不佳
  • mp-html - 专注于小步伐,但编辑功能有限
UniApp官方提供的rich-text组件只具备富文本展示本事,不支持编辑。以是最终决定基于原生本事本身封装一个轻量级的富文本编辑器组件。
焦点技术点



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

1. 创建根本组件结构

首先,我们必要创建一个根本的编辑器组件结构:
  1. <template>
  2.   <view class="rich-editor">
  3.     <view class="toolbar">
  4.       <view
  5.         v-for="(item, index) in tools"
  6.         :key="index"
  7.         class="tool-item"
  8.         :class="{active: activeFormats[item.format]}"
  9.         @tap="handleFormat(item.format, item.value)"
  10.       >
  11.         <text class="iconfont" :class="item.icon"></text>
  12.       </view>
  13.     </view>
  14.    
  15.     <!-- 编辑区域 -->
  16.     <view
  17.       class="editor-container"
  18.       :style="{ height: editorHeight + 'px' }"
  19.     >
  20.       <view
  21.         class="editor-body"
  22.         contenteditable="true"
  23.         @input="onInput"
  24.         @blur="onBlur"
  25.         @focus="onFocus"
  26.         id="editor"
  27.         ref="editor"
  28.       ></view>
  29.     </view>
  30.    
  31.     <!-- 底部工具栏 -->
  32.     <view class="bottom-tools">
  33.       <view class="tool-item" @tap="insertImage">
  34.         <text class="iconfont icon-image"></text>
  35.       </view>
  36.       <view class="tool-item" @tap="insertLink">
  37.         <text class="iconfont icon-link"></text>
  38.       </view>
  39.     </view>
  40.   </view>
  41. </template>
  42. <script>
  43. export default {
  44.   name: 'RichEditor',
  45.   props: {
  46.     value: {
  47.       type: String,
  48.       default: ''
  49.     },
  50.     height: {
  51.       type: Number,
  52.       default: 300
  53.     },
  54.     placeholder: {
  55.       type: String,
  56.       default: '请输入内容...'
  57.     }
  58.   },
  59.   data() {
  60.     return {
  61.       editorHeight: 300,
  62.       editorContent: '',
  63.       selectionRange: null,
  64.       activeFormats: {
  65.         bold: false,
  66.         italic: false,
  67.         underline: false,
  68.         strikethrough: false,
  69.         alignLeft: true,
  70.         alignCenter: false,
  71.         alignRight: false
  72.       },
  73.       tools: [
  74.         { format: 'bold', icon: 'icon-bold', value: 'bold' },
  75.         { format: 'italic', icon: 'icon-italic', value: 'italic' },
  76.         { format: 'underline', icon: 'icon-underline', value: 'underline' },
  77.         { format: 'strikethrough', icon: 'icon-strikethrough', value: 'line-through' },
  78.         { format: 'alignLeft', icon: 'icon-align-left', value: 'left' },
  79.         { format: 'alignCenter', icon: 'icon-align-center', value: 'center' },
  80.         { format: 'alignRight', icon: 'icon-align-right', value: 'right' }
  81.       ]
  82.     }
  83.   },
  84.   created() {
  85.     this.editorHeight = this.height
  86.     this.editorContent = this.value
  87.   },
  88.   mounted() {
  89.     this.initEditor()
  90.   },
  91.   methods: {
  92.     initEditor() {
  93.       const editor = this.$refs.editor
  94.       if (editor) {
  95.         editor.innerHTML = this.value || `<p><br></p>`
  96.       }
  97.       
  98.       // 设置placeholder
  99.       if (!this.value && this.placeholder) {
  100.         this.$nextTick(() => {
  101.           editor.setAttribute('data-placeholder', this.placeholder)
  102.         })
  103.       }
  104.     },
  105.    
  106.     // 监听输入
  107.     onInput(e) {
  108.       // 获取当前内容
  109.       this.editorContent = e.target.innerHTML
  110.       this.$emit('input', this.editorContent)
  111.       this.saveSelection()
  112.     },
  113.    
  114.     // 保存当前选区
  115.     saveSelection() {
  116.       const selection = window.getSelection()
  117.       if (selection.rangeCount > 0) {
  118.         this.selectionRange = selection.getRangeAt(0)
  119.       }
  120.     },
  121.    
  122.     // 恢复选区
  123.     restoreSelection() {
  124.       if (this.selectionRange) {
  125.         const selection = window.getSelection()
  126.         selection.removeAllRanges()
  127.         selection.addRange(this.selectionRange)
  128.         return true
  129.       }
  130.       return false
  131.     },
  132.    
  133.     // 处理格式化
  134.     handleFormat(format, value) {
  135.       // 恢复选区
  136.       if (!this.restoreSelection()) {
  137.         console.log('No selection to format')
  138.         return
  139.       }
  140.       
  141.       // 根据不同格式执行不同操作
  142.       switch(format) {
  143.         case 'bold':
  144.         case 'italic':
  145.         case 'underline':
  146.         case 'strikethrough':
  147.           document.execCommand(format, false, null)
  148.           break
  149.         case 'alignLeft':
  150.         case 'alignCenter':
  151.         case 'alignRight':
  152.           document.execCommand('justify' + format.replace('align', ''), false, null)
  153.           break
  154.         default:
  155.           console.log('未知格式:', format)
  156.       }
  157.       
  158.       // 更新激活状态
  159.       this.checkActiveFormats()
  160.       
  161.       // 触发内容变化
  162.       this.editorContent = this.$refs.editor.innerHTML
  163.       this.$emit('input', this.editorContent)
  164.     },
  165.    
  166.     // 检查当前激活的格式
  167.     checkActiveFormats() {
  168.       this.activeFormats.bold = document.queryCommandState('bold')
  169.       this.activeFormats.italic = document.queryCommandState('italic')
  170.       this.activeFormats.underline = document.queryCommandState('underline')
  171.       this.activeFormats.strikethrough = document.queryCommandState('strikethrough')
  172.       
  173.       const alignment = document.queryCommandValue('justifyLeft') ? 'alignLeft' :
  174.                        document.queryCommandValue('justifyCenter') ? 'alignCenter' :
  175.                        document.queryCommandValue('justifyRight') ? 'alignRight' : 'alignLeft'
  176.       
  177.       this.activeFormats.alignLeft = alignment === 'alignLeft'
  178.       this.activeFormats.alignCenter = alignment === 'alignCenter'
  179.       this.activeFormats.alignRight = alignment === 'alignRight'
  180.     },
  181.    
  182.     // 焦点事件
  183.     onFocus() {
  184.       this.saveSelection()
  185.       this.checkActiveFormats()
  186.     },
  187.    
  188.     onBlur() {
  189.       this.saveSelection()
  190.     },
  191.    
  192.     // 插入图片
  193.     insertImage() {
  194.       uni.chooseImage({
  195.         count: 1,
  196.         success: (res) => {
  197.           const tempFilePath = res.tempFilePaths[0]
  198.           // 上传图片
  199.           this.uploadImage(tempFilePath)
  200.         }
  201.       })
  202.     },
  203.    
  204.     // 上传图片
  205.     uploadImage(filePath) {
  206.       // 这里应该是实际的上传逻辑
  207.       uni.showLoading({ title: '上传中...' })
  208.       
  209.       // 模拟上传过程
  210.       setTimeout(() => {
  211.         // 假设这是上传后的图片URL
  212.         const imageUrl = filePath
  213.         
  214.         // 恢复选区并插入图片
  215.         this.restoreSelection()
  216.         document.execCommand('insertHTML', false, `<img src="${imageUrl}" style="max-width:100%;" />`)
  217.         
  218.         // 更新内容
  219.         this.editorContent = this.$refs.editor.innerHTML
  220.         this.$emit('input', this.editorContent)
  221.         
  222.         uni.hideLoading()
  223.       }, 500)
  224.     },
  225.    
  226.     // 插入链接
  227.     insertLink() {
  228.       uni.showModal({
  229.         title: '插入链接',
  230.         editable: true,
  231.         placeholderText: 'https://',
  232.         success: (res) => {
  233.           if (res.confirm && res.content) {
  234.             const url = res.content
  235.             // 恢复选区
  236.             this.restoreSelection()
  237.             
  238.             // 获取选中的文本
  239.             const selection = window.getSelection()
  240.             const selectedText = selection.toString()
  241.             
  242.             // 如果有选中文本,将其设为链接文本;否则使用URL作为文本
  243.             const linkText = selectedText || url
  244.             
  245.             // 插入链接
  246.             document.execCommand('insertHTML', false,
  247.               `<a href="${url}" target="_blank">${linkText}</a>`)
  248.             
  249.             // 更新内容
  250.             this.editorContent = this.$refs.editor.innerHTML
  251.             this.$emit('input', this.editorContent)
  252.           }
  253.         }
  254.       })
  255.     },
  256.    
  257.     // 获取编辑器内容
  258.     getContent() {
  259.       return this.editorContent
  260.     },
  261.    
  262.     // 设置编辑器内容
  263.     setContent(html) {
  264.       this.editorContent = html
  265.       if (this.$refs.editor) {
  266.         this.$refs.editor.innerHTML = html
  267.       }
  268.       this.$emit('input', html)
  269.     }
  270.   }
  271. }
  272. </script>
  273. <style>
  274. .rich-editor {
  275.   width: 100%;
  276.   border: 1rpx solid #eee;
  277.   border-radius: 10rpx;
  278.   overflow: hidden;
  279. }
  280. .toolbar {
  281.   display: flex;
  282.   flex-wrap: wrap;
  283.   padding: 10rpx;
  284.   border-bottom: 1rpx solid #eee;
  285.   background-color: #f8f8f8;
  286. }
  287. .tool-item {
  288.   width: 80rpx;
  289.   height: 80rpx;
  290.   display: flex;
  291.   justify-content: center;
  292.   align-items: center;
  293.   font-size: 40rpx;
  294.   color: #333;
  295. }
  296. .tool-item.active {
  297.   color: #007AFF;
  298.   background-color: rgba(0, 122, 255, 0.1);
  299.   border-radius: 8rpx;
  300. }
  301. .editor-container {
  302.   width: 100%;
  303.   overflow-y: auto;
  304. }
  305. .editor-body {
  306.   min-height: 100%;
  307.   padding: 20rpx;
  308.   font-size: 28rpx;
  309.   line-height: 1.5;
  310.   outline: none;
  311. }
  312. .editor-body[data-placeholder]:empty:before {
  313.   content: attr(data-placeholder);
  314.   color: #999;
  315.   font-style: italic;
  316. }
  317. .bottom-tools {
  318.   display: flex;
  319.   padding: 10rpx;
  320.   border-top: 1rpx solid #eee;
  321.   background-color: #f8f8f8;
  322. }
  323. /* 引入字体图标库 (需要自行配置) */
  324. @font-face {
  325.   font-family: 'iconfont';
  326.   src: url('data:font/woff2;charset=utf-8;base64,...') format('woff2');
  327. }
  328. .iconfont {
  329.   font-family: "iconfont" !important;
  330.   font-style: normal;
  331. }
  332. </style>
复制代码
2. 处理惩罚平台差别

UniApp支持多个平台,但在富文本编辑方面存在平台差别,特别是小步伐限制较多。下面是一些关键的跨平台适配处理惩罚:
  1. // 跨平台选区处理
  2. saveSelection() {
  3.   // #ifdef H5
  4.   const selection = window.getSelection()
  5.   if (selection.rangeCount > 0) {
  6.     this.selectionRange = selection.getRangeAt(0)
  7.   }
  8.   // #endif
  9.   
  10.   // #ifdef MP-WEIXIN
  11.   // 微信小程序不支持DOM选区,需使用特殊方法
  12.   this.getEditContext().getSelectionRange({
  13.     success: (res) => {
  14.       this.selectionRange = res
  15.     }
  16.   })
  17.   // #endif
  18. },
  19. // 获取编辑器上下文(微信小程序)
  20. getEditContext() {
  21.   // #ifdef MP-WEIXIN
  22.   return this.editorCtx || wx.createSelectorQuery()
  23.     .in(this)
  24.     .select('#editor')
  25.     .context(res => {
  26.       this.editorCtx = res.context
  27.     })
  28.     .exec()
  29.   // #endif
  30.   
  31.   return null
  32. }
复制代码
3. 加强图片处理惩罚本事

富文本编辑器的一个关键功能是图片处理惩罚,我们必要加强这方面的本事:
  1. // 增强版图片上传处理
  2. uploadImage(filePath) {
  3.   uni.showLoading({ title: '上传中...' })
  4.   
  5.   // 压缩图片
  6.   uni.compressImage({
  7.     src: filePath,
  8.     quality: 80,
  9.     success: res => {
  10.       const compressedPath = res.tempFilePath
  11.       
  12.       // 上传到服务器
  13.       uni.uploadFile({
  14.         url: 'https://your-upload-endpoint.com/upload',
  15.         filePath: compressedPath,
  16.         name: 'file',
  17.         success: uploadRes => {
  18.           try {
  19.             const data = JSON.parse(uploadRes.data)
  20.             const imageUrl = data.url
  21.             
  22.             // 插入图片
  23.             this.insertImageToEditor(imageUrl)
  24.           } catch (e) {
  25.             uni.showToast({
  26.               title: '上传失败',
  27.               icon: 'none'
  28.             })
  29.           }
  30.         },
  31.         fail: () => {
  32.           uni.showToast({
  33.             title: '上传失败',
  34.             icon: 'none'
  35.           })
  36.         },
  37.         complete: () => {
  38.           uni.hideLoading()
  39.         }
  40.       })
  41.     },
  42.     fail: () => {
  43.       // 压缩失败,使用原图
  44.       this.doUploadFile(filePath)
  45.     }
  46.   })
  47. },
  48. // 插入图片到编辑器
  49. insertImageToEditor(imageUrl) {
  50.   // #ifdef H5
  51.   this.restoreSelection()
  52.   document.execCommand('insertHTML', false, `<img src="${imageUrl}" style="max-width:100%;" />`)
  53.   // #endif
  54.   
  55.   // #ifdef MP-WEIXIN
  56.   this.getEditContext().insertImage({
  57.     src: imageUrl,
  58.     width: '100%',
  59.     success: () => {
  60.       console.log('插入图片成功')
  61.     }
  62.   })
  63.   // #endif
  64.   
  65.   // 更新内容
  66.   this.$nextTick(() => {
  67.     // #ifdef H5
  68.     this.editorContent = this.$refs.editor.innerHTML
  69.     // #endif
  70.    
  71.     // #ifdef MP-WEIXIN
  72.     this.getEditContext().getContents({
  73.       success: res => {
  74.         this.editorContent = res.html
  75.       }
  76.     })
  77.     // #endif
  78.    
  79.     this.$emit('input', this.editorContent)
  80.   })
  81. }
复制代码
4. 实现HTML与富文本互转

编辑器必要支持HTML格式的导入导出,以便存储和展示:
  1. // HTML转富文本对象
  2. htmlToJson(html) {
  3.   const tempDiv = document.createElement('div')
  4.   tempDiv.innerHTML = html
  5.   
  6.   const parseNode = (node) => {
  7.     if (node.nodeType === 3) { // 文本节点
  8.       return {
  9.         type: 'text',
  10.         text: node.textContent
  11.       }
  12.     }
  13.    
  14.     if (node.nodeType === 1) { // 元素节点
  15.       const result = {
  16.         type: node.nodeName.toLowerCase(),
  17.         children: []
  18.       }
  19.       
  20.       // 处理元素属性
  21.       if (node.attributes && node.attributes.length > 0) {
  22.         result.attrs = {}
  23.         for (let i = 0; i < node.attributes.length; i++) {
  24.           const attr = node.attributes[i]
  25.           result.attrs[attr.name] = attr.value
  26.         }
  27.       }
  28.       
  29.       // 处理样式
  30.       if (node.style && node.style.cssText) {
  31.         result.styles = {}
  32.         const styles = node.style.cssText.split(';')
  33.         styles.forEach(style => {
  34.           if (style.trim()) {
  35.             const [key, value] = style.split(':')
  36.             if (key && value) {
  37.               result.styles[key.trim()] = value.trim()
  38.             }
  39.           }
  40.         })
  41.       }
  42.       
  43.       // 递归处理子节点
  44.       for (let i = 0; i < node.childNodes.length; i++) {
  45.         const childResult = parseNode(node.childNodes[i])
  46.         if (childResult) {
  47.           result.children.push(childResult)
  48.         }
  49.       }
  50.       
  51.       return result
  52.     }
  53.    
  54.     return null
  55.   }
  56.   
  57.   const result = []
  58.   for (let i = 0; i < tempDiv.childNodes.length; i++) {
  59.     const nodeResult = parseNode(tempDiv.childNodes[i])
  60.     if (nodeResult) {
  61.       result.push(nodeResult)
  62.     }
  63.   }
  64.   
  65.   return result
  66. },
  67. // 富文本对象转HTML
  68. jsonToHtml(json) {
  69.   if (!json || !Array.isArray(json)) return ''
  70.   
  71.   const renderNode = (node) => {
  72.     if (node.type === 'text') {
  73.       return node.text
  74.     }
  75.    
  76.     // 处理元素节点
  77.     let html = `<${node.type}`
  78.    
  79.     // 添加属性
  80.     if (node.attrs) {
  81.       Object.keys(node.attrs).forEach(key => {
  82.         html += ` ${key}="${node.attrs[key]}"`
  83.       })
  84.     }
  85.    
  86.     // 添加样式
  87.     if (node.styles) {
  88.       let styleStr = ''
  89.       Object.keys(node.styles).forEach(key => {
  90.         styleStr += `${key}: ${node.styles[key]};`
  91.       })
  92.       if (styleStr) {
  93.         html += ` style="${styleStr}"`
  94.       }
  95.     }
  96.    
  97.     html += '>'
  98.    
  99.     // 处理子节点
  100.     if (node.children && node.children.length > 0) {
  101.       node.children.forEach(child => {
  102.         html += renderNode(child)
  103.       })
  104.     }
  105.    
  106.     // 关闭标签
  107.     html += `</${node.type}>`
  108.    
  109.     return html
  110.   }
  111.   
  112.   let result = ''
  113.   json.forEach(node => {
  114.     result += renderNode(node)
  115.   })
  116.   
  117.   return result
  118. }
复制代码
实战案例:品评编辑器

下面是一个简化版的品评编辑器实现,可以在社区或博客应用中使用:
  1. <template>
  2.   <view class="comment-editor">
  3.     <view class="editor-title">
  4.       <text>发表评论</text>
  5.     </view>
  6.    
  7.     <rich-editor
  8.       v-model="commentContent"
  9.       :height="200"
  10.       placeholder="说点什么吧..."
  11.       ref="editor"
  12.     ></rich-editor>
  13.    
  14.     <view class="action-bar">
  15.       <view class="action-btn cancel" @tap="cancel">取消</view>
  16.       <view class="action-btn submit" @tap="submitComment">发布</view>
  17.     </view>
  18.   </view>
  19. </template>
  20. <script>
  21. import RichEditor from '@/components/rich-editor/rich-editor.vue'
  22. export default {
  23.   components: {
  24.     RichEditor
  25.   },
  26.   data() {
  27.     return {
  28.       commentContent: '',
  29.       replyTo: null
  30.     }
  31.   },
  32.   props: {
  33.     articleId: {
  34.       type: [String, Number],
  35.       required: true
  36.     }
  37.   },
  38.   methods: {
  39.     cancel() {
  40.       this.commentContent = ''
  41.       this.$refs.editor.setContent('')
  42.       this.$emit('cancel')
  43.     },
  44.    
  45.     submitComment() {
  46.       if (!this.commentContent.trim()) {
  47.         uni.showToast({
  48.           title: '评论内容不能为空',
  49.           icon: 'none'
  50.         })
  51.         return
  52.       }
  53.       
  54.       uni.showLoading({ title: '发布中...' })
  55.       
  56.       // 提交评论
  57.       this.$api.comment.add({
  58.         article_id: this.articleId,
  59.         content: this.commentContent,
  60.         reply_to: this.replyTo
  61.       }).then(res => {
  62.         uni.hideLoading()
  63.         
  64.         if (res.code === 0) {
  65.           uni.showToast({
  66.             title: '评论发布成功',
  67.             icon: 'success'
  68.           })
  69.          
  70.           // 清空编辑器
  71.           this.commentContent = ''
  72.           this.$refs.editor.setContent('')
  73.          
  74.           // 通知父组件刷新评论列表
  75.           this.$emit('submit-success', res.data)
  76.         } else {
  77.           uni.showToast({
  78.             title: res.msg || '评论发布失败',
  79.             icon: 'none'
  80.           })
  81.         }
  82.       }).catch(() => {
  83.         uni.hideLoading()
  84.         uni.showToast({
  85.           title: '网络错误,请重试',
  86.           icon: 'none'
  87.         })
  88.       })
  89.     },
  90.    
  91.     // 回复某条评论
  92.     replyComment(comment) {
  93.       this.replyTo = comment.id
  94.       this.$refs.editor.setContent(`<p>回复 @${comment.user.nickname}:</p>`)
  95.       this.$refs.editor.focus()
  96.     }
  97.   }
  98. }
  99. </script>
  100. <style>
  101. .comment-editor {
  102.   padding: 20rpx;
  103.   background-color: #fff;
  104.   border-radius: 10rpx;
  105. }
  106. .editor-title {
  107.   margin-bottom: 20rpx;
  108.   font-size: 32rpx;
  109.   font-weight: bold;
  110. }
  111. .action-bar {
  112.   display: flex;
  113.   justify-content: flex-end;
  114.   margin-top: 20rpx;
  115. }
  116. .action-btn {
  117.   padding: 10rpx 30rpx;
  118.   border-radius: 30rpx;
  119.   font-size: 28rpx;
  120.   margin-left: 20rpx;
  121. }
  122. .cancel {
  123.   color: #666;
  124.   background-color: #f3f3f3;
  125. }
  126. .submit {
  127.   color: #fff;
  128.   background-color: #007AFF;
  129. }
  130. </style>
复制代码
踩坑记载

开发过程中碰到了不少坑,这里分享几个关键问题及办理方案:
1. 小步伐富文本本事受限

小步伐不支持通过contenteditable实现的富文本编辑,必要使用平台提供的editor组件。办理方案是使用条件编译,H5使用contenteditable,小步伐使用官方editor组件。
  1. <!-- H5编辑器 -->
  2. <!-- #ifdef H5 -->
  3. <div
  4.   class="editor-body"
  5.   contenteditable="true"
  6.   @input="onInput"
  7.   id="editor"
  8.   ref="editor"
  9. ></div>
  10. <!-- #endif -->
  11. <!-- 小程序编辑器 -->
  12. <!-- #ifdef MP-WEIXIN -->
  13. <editor
  14.   id="editor"
  15.   class="editor-body"
  16.   :placeholder="placeholder"
  17.   @ready="onEditorReady"
  18.   @input="onInput"
  19. ></editor>
  20. <!-- #endif -->
复制代码
2. 选区处理惩罚差别

差别平台的选区API差别很大,必要分别处理惩罚:
  1. // 处理选区问题
  2. getSelectionRange() {
  3.   return new Promise((resolve) => {
  4.     // #ifdef H5
  5.     const selection = window.getSelection()
  6.     if (selection.rangeCount > 0) {
  7.       resolve(selection.getRangeAt(0))
  8.     } else {
  9.       resolve(null)
  10.     }
  11.     // #endif
  12.    
  13.     // #ifdef MP-WEIXIN
  14.     this.editorCtx.getSelectionRange({
  15.       success: (res) => {
  16.         resolve(res)
  17.       },
  18.       fail: () => {
  19.         resolve(null)
  20.       }
  21.     })
  22.     // #endif
  23.   })
  24. }
复制代码
3. 图片上传巨细限制

多端应用中,图片上传和展示必要考虑差别平台的限制:
  1. // 处理图片大小限制
  2. async handleImageUpload(file) {
  3.   // 检查文件大小
  4.   if (file.size > 5 * 1024 * 1024) { // 5MB
  5.     uni.showToast({
  6.       title: '图片不能超过5MB',
  7.       icon: 'none'
  8.     })
  9.     return null
  10.   }
  11.   
  12.   // 压缩图片
  13.   try {
  14.     // H5与小程序压缩方式不同
  15.     // #ifdef H5
  16.     const compressedFile = await this.compressImageH5(file)
  17.     return compressedFile
  18.     // #endif
  19.    
  20.     // #ifdef MP
  21.     const compressedPath = await this.compressImageMP(file.path)
  22.     return { path: compressedPath }
  23.     // #endif
  24.   } catch (e) {
  25.     console.error('图片压缩失败', e)
  26.     return file // 失败时使用原图
  27.   }
  28. }
复制代码
性能优化

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

  • 输入防抖 - 镌汰频繁更新导致的性能问题
  • 延迟加载图片 - 使用懒加载机制
  • 镌汰DOM操作 - 只管批量更新DOM
  • 使用假造DOM - 在复杂场景下考虑使用Vue的假造DOM机制
  1. // 输入防抖处理
  2. onInput(e) {
  3.   if (this.inputTimer) {
  4.     clearTimeout(this.inputTimer)
  5.   }
  6.   
  7.   this.inputTimer = setTimeout(() => {
  8.     // #ifdef H5
  9.     this.editorContent = this.$refs.editor.innerHTML
  10.     // #endif
  11.    
  12.     // #ifdef MP-WEIXIN
  13.     this.editorContent = e.detail.html
  14.     // #endif
  15.    
  16.     this.$emit('input', this.editorContent)
  17.   }, 300)
  18. }
复制代码
总结

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

  • 平台差别是最大挑战,必要利用条件编译提供各平台最佳实现
  • 功能要适中,不是全部Web富文本功能都得当移动端
  • 性能优化很重要,尤其是在低端设备上
  • 良好的用户体验必要细节打磨,如适当的反馈、容错处理惩罚等
富文本编辑是一个复杂的课题,即使是成熟的Web编辑器也有各种问题。在移动端和小步伐环境中,受限更多。我们的方案固然不完善,但通过合理的弃取宁静台适配,已经能满意大部门应用场景的需求。
后续还可以继承完善这个组件,比如添加表格支持、代码高亮、Markdown转换等高级功能。希望本文对你有所启发,欢迎在品评区交流讨论!
参考资料


  • UniApp官方文档
  • execCommand API参考
  • ContentEditable详解

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
继续阅读请点击广告
回复

使用道具 举报

×
登录参与点评抽奖,加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表