马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
×
使用UniApp开发富文本编辑器组件
富文本编辑在各类应用中非常常见,无论是内容创作平台还是社交软件,都必要提供良好的富文本编辑体验。本文记载了我使用UniApp开发一个跨平台富文本编辑器组件的过程,希望对有雷同需求的开发者有所启发。
背景
前段时间接到一个需求,要求在我们的跨平台应用中加入富文本编辑功能,支持根本的文本格式化、插入图片、链接等功能。考虑到项目使用UniApp开发,必要兼容多个平台,市面上现成的富文本编辑器要么不支持跨平台,要么功能过于复杂。于是我决定本身动手,开发一个功能适中、性能良好的富文本编辑器组件。
技术选型
为何不直接使用现有组件?
首先,我调研了几个流行的富文本编辑器:
- quill.js - 功能强盛,但在小步伐环境中存在兼容性问题
- wangeditor - 针对Web端优化,小步伐支持不佳
- 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格式的导入导出,以便存储和展示:
实战案例:品评编辑器
下面是一个简化版的品评编辑器实现,可以在社区或博客应用中使用:
- <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 // 失败时使用原图
- }
- }
复制代码 性能优化
为了让编辑器运行更流畅,我做了以下优化:
- 输入防抖 - 镌汰频繁更新导致的性能问题
- 延迟加载图片 - 使用懒加载机制
- 镌汰DOM操作 - 只管批量更新DOM
- 使用假造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)
- }
复制代码 总结
通过这次开发实践,我实现了一个跨平台的富文本编辑器组件,总结几点经验:
- 平台差别是最大挑战,必要利用条件编译提供各平台最佳实现
- 功能要适中,不是全部Web富文本功能都得当移动端
- 性能优化很重要,尤其是在低端设备上
- 良好的用户体验必要细节打磨,如适当的反馈、容错处理惩罚等
富文本编辑是一个复杂的课题,即使是成熟的Web编辑器也有各种问题。在移动端和小步伐环境中,受限更多。我们的方案固然不完善,但通过合理的弃取宁静台适配,已经能满意大部门应用场景的需求。
后续还可以继承完善这个组件,比如添加表格支持、代码高亮、Markdown转换等高级功能。希望本文对你有所启发,欢迎在品评区交流讨论!
参考资料
- UniApp官方文档
- execCommand API参考
- ContentEditable详解
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
|