☰
Electron 富文本编辑器图文混排实战:UEditor 图片存储与自定义协议方案
2026/10/24 9:10:08 网站建设 项目流程

最近在折腾一个本地笔记工具,技术栈选了 Electron + 老牌富文本编辑器 UEditor。跑通基础编辑功能很顺,真正卡住我的是图文混排:图片插进去之后要么不显示,要么保存完退出再打开就裂了,要么复制粘贴网络图片直接变成一串乱码字符。这篇文章就把我踩过的坑、最后采用的方案、以及一套可以直接抄走的实现思路完整梳理一遍,主要是给那些也想在 Electron 里用 UEditor 做图文混排的朋友一个参考。

先说明,UEditor 本身是很多年前开源出来的老牌富文本编辑器,生态成熟、配置简单,但已经很久没大版本更新了。Electron 又是一个非常特殊的运行环境——它没有传统意义上的“后端服务器”,页面加载走的是本地资源协议。把这两者硬凑在一起,图文混排的很多默认逻辑都会失效。你需要做的不是找现成插件,而是自己写一层适配:把编辑器的图片上传、图片引用、图片导出整个链路重新接一遍,让它适应桌面应用的文件系统环境。

1. 图文混排在Electron里的链路断点:img标签为什么死活不显示

1.1 浏览器里图文混排原本是这么工作的

在纯 Web 场景下,UEditor 处理图文混排的逻辑并不复杂。你在工具栏点“插入图片”,编辑器会弹出一个上传对话框,把文件通过 HTTP 表单提交给后端接口,后端返回一个可访问的图片 URL。UEditor 拿到这个 URL 后,在编辑区域插入一段 HTML:

<p>正文文字段落</p> <p><img src="https://example.com/uploads/2025/01/abc123.png" alt="配图"></p> <p>继续写后面的文字</p>

这张图片之所以能显示,是因为src指向一个标准的 HTTP URL,浏览器发请求、服务器返回图片二进制,编辑器里的 img 节点自然就渲染出来了。后面做对齐、缩放、拖动位置,都是基于这段纯文本 HTML 在contenteditable区域里进行的 DOM 操作。

这个链路成立的前提有两个:一是有 HTTP 后端可以接收文件并返回 URL,二是图片 URL 可以被页面直接访问。放到 Electron 里,这两个前提都变了。

1.2 换到Electron环境,原始链路断在哪

Electron 里没有后端服务器,至少大部分桌面应用都不会为了一个编辑器专门起一个本地 HTTP 服务。于是你会遇到三个非常典型的断点。

第一个断点,UEditor 的图片上传按钮默认要请求serverUrl。如果没有这个地址,点击上传要么报错,要么直接没反应。这就逼着你必须自己接管整个图片上传流程:自己选文件、自己处理文件存储、自己把图片路径拼成 HTML 插进编辑器。

第二个断点,即使你把一张本地图片的绝对路径塞给 img 标签,比如src="file:///C:/Users/xxx/AppData/Roaming/MyApp/assets/abc123.png",Electron 渲染进程在默认安全配置下也不会允许页面直接读取本地文件。出于安全考虑,Electron 对file://协议有严格限制,加载出的页面无法跨协议访问本地资源。你操作一遍就会发现:控制台报Not allowed to load local resource,图片始终是裂的。

第三个断点,图片存到哪个目录、保存文档时记的是相对路径还是绝对路径、下次打开时这个路径还有没有效,这串问题在网页时代根本不存在,但在桌面应用里直接决定你的图文混排能不能持久化。

所以核心思路就出来了。不能让图片引用真实文件路径,也不能依赖 HTTP URL,而是需要在 Electron 和 UEditor 之间加一个适配层:图片文件正常落盘,给编辑器的是一个由自定义协议生成的虚拟 URL,渲染时由 Electron 主进程拦下来返回真实文件内容。这套思路也就是我后面实现的完整插件方案。

2. 图片存储策略选型:内嵌、本地引用还是自定义协议

动手之前,我先把图片的存储方案过了一遍。这步非常关键,因为存储策略决定了你整个图文混排的“底子”是什么。做桌面笔记工具,图片有几个去向比较常见。

2.1 方案A:base64内嵌

把图片转成 base64 字符串,直接写进 img 标签的src里:

<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" alt="配图">

好处是彻底摆脱文件路径问题,文档自带图片,迁移到任何机器打开都还在。坏处也很明显:base64 会让图片体积膨胀约 33%,一张 500KB 的图变成约 660KB 的字符串;如果一篇文档插入三五张大图,编辑器会变得非常卡,文档文件本身也变得异常臃肿。

这个方案只适合偶尔插小图的轻量场景,或者作为编辑过程中的临时方案。做正经图文混排,不建议把它当主力。

2.2 方案B:相对路径引用

把图片存在文档同级的 assets 目录里,img 标签的src写成相对路径,比如:

<img src="assets/abc123.png" alt="配图">

这个方案在纯网页部署环境很常见。但 Electron 里直接用有一个隐患:你在应用内部打开一个 Markdown 或 HTML 文档时,编辑器的 HTML 并不是以文件方式加载的,而是先拉进内存再写进 DOM,此时相对路径没有可靠的基准目录。如果文档路径变了、或者从不同入口打开,图片就会裂。将来要导出成单个文件分享,还得再处理一遍图片路径,很麻烦。

2.3 方案C:本地落盘 + 自定义协议

图片持久化到应用自己的资源目录里,img 标签的src指向一个自定义协议 URL,比如:

<img src="note-image://asset/abc123.png" alt="配图">

渲染时,Electron 主进程注册的协议处理器会拦截note-image://请求,按文件名从资源目录读取真实文件并返回。这样图片既不内嵌进文档导致膨胀,也不依赖脆弱的本地绝对路径,文档里保存的只是一个稳定、可复现的逻辑地址。

2.4 我的选择依据

对比之后,我选了方案 C。原因是笔记类应用里图片的“资产管理”逻辑本来就该和文档文本分开:文本负责描述结构,图片负责提供视觉内容,两者通过稳定 ID 关联。方案 C 恰好符合这个模型。

另外,方案 C 的扩展性也好。文档导出成 Markdown 时,只需要把note-image://asset/abc123.png映射成真实路径,或者连同文档一起打包成一个文件夹;将来做云同步,也只需要把资源目录和文档目录分别同步就可以了。看到这里你已经明白了:图片存储策略不是“怎么存都行”的小事,它决定了后续几个月维护起来是轻松还是到处救火。这个选择我建议在项目起点就做对。

3. 实现适配层:自定义协议负责读图,改造上传逻辑负责写路径

3.1 主进程:注册note-image协议

自定义协议是 Electron 对外开放的一种能力,让你可以把某个特殊协议的 URL 映射到文件读取逻辑。注册有两个关键步骤。第一步是在 app ready 之前声明这个协议是有特权的,因为我还要让它支持标准流式响应:

const { app, protocol } = require('electron') app.whenReady().then(() => { protocol.registerSchemesAsPrivileged([ { scheme: 'note-image', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } } ]) })

第二步是在 app ready 之后真正实现协议处理函数,把虚拟 URL 映射到磁盘文件:

const fs = require('fs') const path = require('path') const { net, protocol } = require('electron') app.whenReady().then(() => { const assetRoot = path.join(app.getPath('userData'), 'assets') protocol.handle('note-image', (request) => { const url = new URL(request.url) const fileName = path.basename(url.pathname) // 只取文件名,防路径穿越 const filePath = path.join(assetRoot, fileName) if (!filePath.startsWith(assetRoot)) { return new Response('Forbidden', { status: 403 }) } if (!fs.existsSync(filePath)) { return new Response('Not Found', { status: 404 }) } return net.fetch(pathToFileURL(filePath)) }) })

这里有几个容易踩的细节。path.basename一定要用,否则攻击者可以通过/../../something之类的路径读到资源目录之外的文件。net.fetch(pathToFileURL(filePath))是 Electron 内置的文件读取方法,比手动fs.readFile性能好很多,也自动处理了 MIME 类型和响应头。

3.2 渲染进程:UEditor的上传与插入入口

主进程能读图了,接下来要让 UEditor 把图片写到那个资源目录里。传统做法是配置serverUrl指向后端,但 Electron 里没有后端,所以我干脆完全绕开 UEditor 自带的文件上传接口,自己做一套图片插入逻辑。

Electron 渲染进程里,我暴露一个桥接方法给编辑器调用:

// 通过 preload 暴露给渲染进程 contextBridge.exposeInMainWorld('noteImageApi', { pickAndSaveImage: async () => { // 弹文件选择框,复制图片到资源目录,返回 note-image:// 格式的 URL return await ipcRenderer.invoke('image:pick-and-save') } })

然后在编辑器初始化配置里,把图片上传按钮的点击行为替换掉。UEditor 初始化时有一堆按钮,用toolbars来控制显示哪些;图片上传按钮被显示出来后,监听它的动作,弹出文件选择框,拿到返回的 URL,再通过editor.execCommand('insertHtml')把图片插进文档:

let editor = UE.getEditor('editor', { initialFrameWidth: 800, initialFrameHeight: 500, toolbars: [[ 'bold', 'italic', 'underline', 'insertimage', // 保留图片按钮 'undo', 'redo' ]] }) UE.registerUI('localimage', (editor, uiName) => { const btn = new UE.ui.Button({ name: uiName, title: '插入本地图片', click: async () => { const src = await window.noteImageApi.pickAndSaveImage() if (!src) return editor.execCommand('insertHtml', `<p><img src="${src}" alt="配图"/></p>`) } }) return btn })

这段代码里有个选择:我没有去改 UEditor 内部的上传表单逻辑,而是注册了一个全新的按钮。这样避免跟老版本初始化逻辑打架,维护起来也清晰。你如果想要复用原有的上传弹窗 UI,也是可以的,但那需要对 UEditor 内部配置做更多了解,上手成本会高不少。

3.3 路径安全:不要直接把真实路径塞给img

我特别想强调一个设计细节:在编辑器的 HTML 里,永远不要存真实绝对路径。哪怕自定义协议已经解决了显示问题,也建议统一走note-image://asset/文件名这种虚拟 URL。

这样做最大的价值是防漂移。绝对路径和文档绑死,目录一变就失效;虚拟 URL 只关心文件名,资源目录由主进程统一管理。其次,将来做文档导出时,你只需要把虚拟 URL 映射成目标格式,比如导出 Markdown 时把图片复制一份放相对目录,改动面很小。最后,防路径穿越的校验逻辑集中在主进程处理一次,而不是散落在各个业务逻辑里,安全边界更清晰。

还有一个容易忽略的点:UEditor 编辑区如果开了异步渲染或者图片懒加载,某些版本对自定义协议的支持不稳定。我在实际测试中发现,插入note-image://这种 URL 后,编辑区偶尔不会立刻刷新出图片。解决办法是插入图片后主动触发一次重绘,或者延时执行一次editor.body.style.display = 'none'; void editor.body.offsetHeight; editor.body.style.display = ''。这种小技巧在实测中非常有用。

另外,如果你的渲染进程页面配置了严格的 Content Security Policy,要记得把自定义协议加进img-src,否则控制台会报 CSP 错误:

Content-Security-Policy: img-src 'self' note-image: data:;

这一步不做,前面注册的协议再对也白搭。我最初就卡在这里半天,页面和协议一切正常,但图片显示不出来,最后才发现是 CSP 把请求拦了。

4. 补齐操作链路:插入、拖拽、粘贴、缩放和导出

4.1 工具栏插入图片

完整实现插入图片的常规路径,我用的是“选择图片 → 保存到资源目录 → 插入 HTML”三步走。

第一步,调用 Electron 主进程弹系统文件选择框。为了让用户没那么容易插入超大文件,我在主进程里预先做了过滤,只允许图片扩展名,同时加了一个大小上限,比如 10MB:

ipcMain.handle('image:pick-and-save', async () => { const result = await dialog.showOpenDialog({ properties: ['openFile'], filters: [ { name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] } ] }) if (result.canceled) return null const sourcePath = result.filePaths[0] const randomName = `${Date.now()}_${path.basename(sourcePath)}` const targetPath = path.join(assetRoot, randomName) await fs.promises.copyFile(sourcePath, targetPath) return `note-image://asset/${randomName}` })

第二步和第三步在上面 3.2 里已经写过了,调用execCommand('insertHtml')插入图片。这一步最重要的一点是:插入时要保证代码在 UEditor 初始化完成之后执行。我遇到过多次在ready事件之前调用insertHtml导致图片丢失的情况,所以按钮事件里都要先判断:

if (!editor.isReady) { await new Promise(resolve => editor.addListener('ready', resolve)) }

4.2 拖拽与粘贴图片

工具栏按钮只是第一步,图文混排体验要顺,拖拽和粘贴这两个高频操作必须接上。

粘贴图片这种场景很容易出现,用户从网页截图后 Ctrl+V 粘贴到编辑器。默认情况下,UEditor 老版本对粘贴图片的处理很弱,经常把图片变成外链或者丢失。正确的做法是监听编辑区域的paste事件,拦截剪贴板里的图片文件,走和工具栏插入相同的落盘逻辑:

editor.body.addEventListener('paste', async (e) => { const items = e.clipboardData.items for (const item of items) { if (item.type.startsWith('image/')) { e.preventDefault() const file = item.getAsFile() const src = await window.noteImageApi.saveFile(file) if (src) { editor.execCommand('insertHtml', `<p><img src="${src}" alt="粘贴图片"/></p>`) } return } } })

拖拽插入类似,用户从文件管理器拖一张图片到编辑区,浏览器默认行为是直接打开文件,必须拦截drop事件,手动处理。这里要注意:UEditor 编辑区本身也处理 drop,优先级和拦截时机不对会导致拖进去的还是乱码路径。建议在最外层容器上先拦截,处理完再决定要不要放行。

4.3 缩放与对齐

UEditor 本身保留了大部分浏览器原生的图片缩放能力,但 Electron 里这些能力时好时坏。我用了一个更可控的方案:选中图片后,在旁边浮现一个小工具条,提供“宽度调整”“居中”“左对齐”“右对齐”四个操作。

宽度调整的实现逻辑是取得选中节点,判断它是不是 img,然后设置width属性同时按原始比例算高度:

function resizeImage(editor, img, newWidth) { if (!img || img.tagName !== 'IMG') return const naturalWidth = img.naturalWidth || 600 const ratio = img.naturalHeight ? (img.naturalHeight / img.naturalWidth) : 0.75 img.setAttribute('width', Math.round(newWidth)) img.setAttribute('height', Math.round(newWidth * ratio)) }

如果图片没有宽高属性,后端返回的原始尺寸还在,也可以直接用naturalWidth读。这个函数在图片选中事件触发时调用,比 UEditor 自带的拖拽手柄稳定得多。对齐操作更简单,设置 img 的style.display = 'block'; style.margin = '0 auto'实现居中,或者用style.float = 'left'实现左浮动。

这块经验是:功能不需要做得多花哨,稳定压倒一切。在桌面应用里,用户更在意的是“我调整了宽度,保存、退出、再打开,宽度还是那个宽度”。

4.4 保存、导出与再次打开

最后一个核心点是把内容完整保存下来,二次打开时图片还能正常显示。

如果笔记的文档格式是 HTML,直接保存editor.getContent(),里面 img 的src都是note-image://asset/文件名。下次打开文档时,只要资源目录里对应文件还在,图片会自动恢复显示。这就体现了自定义协议的好处——路径和具体磁盘位置解耦,只要资源目录归属明确即可。

如果导出成 Markdown,则需要做一次 URL 翻译。遍历 HTML 里的 img 节点,把虚拟 URL 转成导出文件旁边的相对路径:

function exportMarkdown(html, exportDir) { const tempDiv = document.createElement('div') tempDiv.innerHTML = html tempDiv.querySelectorAll('img').forEach(img => { const src = img.getAttribute('src') if (src && src.startsWith('note-image://')) { const fileName = src.split('/').pop() // 把图片从资源目录复制到导出目录,再重写src为相对路径 const newSrc = `./images/${fileName}` img.setAttribute('src', newSrc) } }) return tempDiv.innerHTML }

如果保存的是纯文本格式,图片引用会丢失。这个我在项目初期就明确了,所以从第一天起就选了 HTML 作为存储格式。核心决策是:图文混排至少以 HTML 格式存储,才能在再次打开时完整还原图片位置和布局。

5. 高频踩坑记录与修复验证

5.1 图片加载失败,控制台显示ERR_BLOCKED_BY_RESPONSE

这个错误我排查了很久,最后定位到是响应头问题。net.fetch(pathToFileURL(filePath))在某些 Electron 版本里默认不带 Access-Control-Allow-Origin 头,导致渲染进程认为跨域访问被阻断。

解决办法是在协议处理里手动加响应头:

const response = await net.fetch(pathToFileURL(filePath)) const headers = new Headers(response.headers) headers.set('Access-Control-Allow-Origin', '*') return new Response(response.body, { headers })

加上之后图片立刻正常显示了。这个坑不常见,但一旦遇到症状很骗人,容易让人误以为是路径写错或文件不存在。

5.2 粘贴网络图片反而变成了乱码

在编辑器里粘贴一张网络图片时,老版本 UEditor 默认会把远程图片转成本地图片,也就是catchRemoteImageEnable功能。这个功能在有后端服务器时是自动实现的,Electron 里没有后端,于是它可能把网络图片的 src 改成一个空字符串或相对地址,图片就花了。

我的处理是关闭远程抓取:

UE.getEditor('editor', { catchRemoteImageEnable: false })

对于需要粘贴网络图片的场景,要么依赖它本身的外链图片能力(网络可用时能正常显示),要么实现自定义的远程图片抓取。最省事的就是关闭默认重置行为,让它保留原始 URL。

5.3 保存后再打开,图片全没了

这个问题有两类原因。一类是真的文件丢了,检查资源目录里有没有生成对应的文件。另一类是资源目录路径变了,比如开发环境和打包后app.getPath('userData')指向的地方不一样,导致协议处理器找不到文件。

我的经验是:资源目录要做一次统一的初始化,把路径放在一个配置模块里,跑测试时显式指定临时目录,打包后用系统用户数据目录,并且每次启动时检查目录是否存在,不存在就创建。另外,绝对不要在初始化之前触发协议请求。我见过不少项目在 app ready 之前就加载编辑器,图片请求发出去了,协议还没注册,于是全部 404。

5.4 大图把编辑器卡死

插入一张 8MB 以上的高清大图,编辑器会明显变卡,甚至会崩溃。原因有两个:一是 UEditor 在插入时会计算图片宽高、加载原始像素到内存,二是内容变大后 DOM 重排成本飙升。

我的方案是插入前在渲染进程做一次压缩。预览或导入时,先用 canvas 把图片压缩到合理宽度,比如最长边 2000px,然后转成 JPEG 或 WebP 再落盘。实测下来,原来 8MB 的图压到 300KB 左右,视觉损失几乎看不出来,编辑器流畅度提升明显。

async function compressImage(file, maxWidth = 2000, quality = 0.85) { const dataUrl = await readFileAsDataURL(file) const img = await loadImage(dataUrl) const scale = Math.min(1, maxWidth / img.width) const canvas = document.createElement('canvas') canvas.width = Math.round(img.width * scale) canvas.height = Math.round(img.height * scale) const ctx = canvas.getContext('2d') ctx.drawImage(img, 0, 0, canvas.width, canvas.height) return canvas.toDataURL('image/jpeg', quality) }

压缩后就不再是原始文件,所以文件名也不需要保留原始名称,直接用随机串就行。做图文混排,图片体积控制是刚需。你前期省了这一步,后面每次编辑大文档都会相当煎熬。

折腾完这个适配层,我自己最大的体会是:UEditor 和 Electron 结合,难点不在于编辑器本身,而在于怎么把一个“网页时代的编辑器”的通行逻辑翻译成“桌面应用的文件系统逻辑”。自定义协议解决了图片可读性的问题,存储策略解决了图片持久化的问题,操作链路补全了编辑体验的问题。三者合在一起,图文混排才能从“能插个图”变成“真正能用”。这套思路不仅适用于 UEditor,换成别的富文本编辑器,比如老版本的 wangeditor 或者自己基于 contenteditable 实现一套,你也会遇到同样的三件事:图片怎么进来、图片怎么显示、图片怎么保存。把这三件事理顺,Electron 里的图文混排就算彻底搞定了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询