资讯动态

Electron 富文本编辑器图文混排实战:UEditor 图片存储与自定义协议方案

发布时间:2026/10/24 9:10:16 来源:尧图企业网站定制
最近在折腾一个本地笔记工具技术栈选了 Electron 老牌富文本编辑器 UEditor。跑通基础编辑功能很顺真正卡住我的是图文混排图片插进去之后要么不显示要么保存完退出再打开就裂了要么复制粘贴网络图片直接变成一串乱码字符。这篇文章就把我踩过的坑、最后采用的方案、以及一套可以直接抄走的实现思路完整梳理一遍主要是给那些也想在 Electron 里用 UEditor 做图文混排的朋友一个参考。先说明UEditor 本身是很多年前开源出来的老牌富文本编辑器生态成熟、配置简单但已经很久没大版本更新了。Electron 又是一个非常特殊的运行环境——它没有传统意义上的“后端服务器”页面加载走的是本地资源协议。把这两者硬凑在一起图文混排的很多默认逻辑都会失效。你需要做的不是找现成插件而是自己写一层适配把编辑器的图片上传、图片引用、图片导出整个链路重新接一遍让它适应桌面应用的文件系统环境。1. 图文混排在Electron里的链路断点img标签为什么死活不显示1.1 浏览器里图文混排原本是这么工作的在纯 Web 场景下UEditor 处理图文混排的逻辑并不复杂。你在工具栏点“插入图片”编辑器会弹出一个上传对话框把文件通过 HTTP 表单提交给后端接口后端返回一个可访问的图片 URL。UEditor 拿到这个 URL 后在编辑区域插入一段 HTMLp正文文字段落/p pimg srchttps://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 标签比如srcfile:///C:/Users/xxx/AppData/Roaming/MyApp/assets/abc123.pngElectron 渲染进程在默认安全配置下也不会允许页面直接读取本地文件。出于安全考虑Electron 对file://协议有严格限制加载出的页面无法跨协议访问本地资源。你操作一遍就会发现控制台报Not allowed to load local resource图片始终是裂的。第三个断点图片存到哪个目录、保存文档时记的是相对路径还是绝对路径、下次打开时这个路径还有没有效这串问题在网页时代根本不存在但在桌面应用里直接决定你的图文混排能不能持久化。所以核心思路就出来了。不能让图片引用真实文件路径也不能依赖 HTTP URL而是需要在 Electron 和 UEditor 之间加一个适配层图片文件正常落盘给编辑器的是一个由自定义协议生成的虚拟 URL渲染时由 Electron 主进程拦下来返回真实文件内容。这套思路也就是我后面实现的完整插件方案。2. 图片存储策略选型内嵌、本地引用还是自定义协议动手之前我先把图片的存储方案过了一遍。这步非常关键因为存储策略决定了你整个图文混排的“底子”是什么。做桌面笔记工具图片有几个去向比较常见。2.1 方案Abase64内嵌把图片转成 base64 字符串直接写进 img 标签的src里img srcdata:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg alt配图好处是彻底摆脱文件路径问题文档自带图片迁移到任何机器打开都还在。坏处也很明显base64 会让图片体积膨胀约 33%一张 500KB 的图变成约 660KB 的字符串如果一篇文档插入三五张大图编辑器会变得非常卡文档文件本身也变得异常臃肿。这个方案只适合偶尔插小图的轻量场景或者作为编辑过程中的临时方案。做正经图文混排不建议把它当主力。2.2 方案B相对路径引用把图片存在文档同级的 assets 目录里img 标签的src写成相对路径比如img srcassets/abc123.png alt配图这个方案在纯网页部署环境很常见。但 Electron 里直接用有一个隐患你在应用内部打开一个 Markdown 或 HTML 文档时编辑器的 HTML 并不是以文件方式加载的而是先拉进内存再写进 DOM此时相对路径没有可靠的基准目录。如果文档路径变了、或者从不同入口打开图片就会裂。将来要导出成单个文件分享还得再处理一遍图片路径很麻烦。2.3 方案C本地落盘 自定义协议图片持久化到应用自己的资源目录里img 标签的src指向一个自定义协议 URL比如img srcnote-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, pimg 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 主进程弹系统文件选择框。为了让用户没那么容易插入超大文件我在主进程里预先做了过滤只允许图片扩展名同时加了一个大小上限比如 10MBipcMain.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 拖拽与粘贴图片工具栏按钮只是第一步图文混排体验要顺拖拽和粘贴这两个高频操作必须接上。粘贴图片这种场景很容易出现用户从网页截图后 CtrlV 粘贴到编辑器。默认情况下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, pimg 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 里的图文混排就算彻底搞定了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑