用 PyWebView 写了个桌面 Markdown 编辑器:从入门到踩坑实录
平时写博客、记笔记都用 Markdown,一直想要个干净的桌面编辑器。找了一圈,VS Code 太重,Typora 要收费,网上开源编辑器要么太简陋要么依赖太重。后来发现 PyWebView 这条路——界面用 HTML/CSS/JS 写,功能用 Python 做,前端后端一条桥连起来。这篇文章就讲讲这个 Markdown 编辑器是怎么做的,有哪些值得说的技术点,以及我踩过的坑。
为什么要做一个 Markdown 编辑器
先说说为什么费劲自己写一个。作为经常写 Markdown 的人,我的需求其实很简单:
- 能打开一个目录,看到里面的所有 .md 文件
- 左边编辑,右边实时预览,所见即所得
- 自动保存,不怕写一半丢内容
- 打包成 exe,能发给别人直接用
这些需求单个看都不难,但要组合成一个"好用的桌面工具",传统的做法各有各的问题:
| Tkinter | 原生控件,观感一般 | 低 | 小 | 布局复杂时写起来很痛苦 |
| PyQt / PySide | 原生控件,现代 | 高 | 较大 | 功能强,为个小工具不值当 |
| Electron | Web | 中 | 100MB 起 | 界面好看,体积劝退 |
| PyWebView | Web | 低 | 小 | 界面外包给浏览器,Python 只管功能 |
最后选了 PyWebView。理由很实在:界面是浏览器渲染的,好看、好调、主题好换;文件操作这种脏活累活全留在 Python。它本质是个壳,里面跑一个 WebView(Windows 上是 Edge/WebView2),页面就是普通 HTML/CSS/JS。
选这条路有个必须接受的前提:WebView 是沙箱,前端碰不到本地文件系统。所有文件操作都得通过 js_api 桥回 Python。这个限制贯穿了整个项目,后面那个大坑就是它挖的。
项目骨架:三个文件,各干一件事
项目结构小得惊人,就三层:
main.py 窗口入口:创建 WebView 窗口,加载 index.html
backend.py Api 类:文件读写 / 目录选择 / 图片转 base64
web/ 前端:index.html + app.js + style.css
Python 侧只暴露这几个接口,前端 pywebview.api.xxx() 直接调:
class Api:
def list_files(self): # 列目录下所有 .md 文件
...
def open_file(self, path): # 读文件内容
...
def save_file(self, path, content): # 写文件
...
def select_directory(self): # 弹目录选择框
...
def read_image(self, path): # 本地图片 → base64 data URI
...
整个工具跑起来就是「前端 <-> 桥接 <-> 后端」一条流水线:

图1:PyWebView 的整体架构。HTML 写界面,Python 做文件操作,js_api 一条双向桥,数据落到本地磁盘。
功能一:双栏实时预览,几分钟就跑通
Markdown 渲染没自己造轮子,用 marked(12.0.1)做解析、highlight.js(11.9)做代码高亮,都从 CDN 加载:
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
核心逻辑很简单:编辑区 input 事件 → 重新 marked.parse → 塞进预览区。highlight.js 通过 marked 的 highlight 钩子接入,能识别的语言高亮,识别不了的 highlightAuto 兜底:
marked.setOptions({
highlight: function(code, lang) {
if (lang && hljs && hljs.getLanguage(lang)) {
try { return hljs.highlight(code, { language: lang }).value; } catch (e) {}
}
if (hljs) {
try { return hljs.highlightAuto(code).value; } catch (e) {}
}
return esc(code);
},
breaks: true,
gfm: true,
});
editor.addEventListener('input', function() {
updatePreview(editor.value); // 每次输入都重新渲染预览
});
两个细节,都是踩完才明白要加的:
实际效果:

图2:工具主界面。左边文件列表,中间编辑区,右边实时预览(marked + highlight.js),预览里嵌了张本地图片。
功能二:自动保存,2 秒防抖不打扰
编辑器最怕的事之一:写一半崩溃,内容丢了。自动保存是刚需,但实现有个取舍——不能每次按键都写盘,否则输入快的时候磁盘 IO 和渲染互相拖。
方案是 2 秒防抖:输入事件触发时重置计时器,只有停顿 2 秒才真正写盘:
function scheduleAutoSave() {
if (state.saveTimer) clearTimeout(state.saveTimer);
state.saveTimer = setTimeout(function() {
if (state.modified) saveCurrentFile();
}, 2000);
}
防抖之外还有一层:写盘状态要可感知。modified 标志跟踪内容改了没存——改了没写盘置 true,写盘成功置 false。状态栏里写盘成功会闪一下绿色的「已保存」,2 秒后消失:
function showSaveIndicator(msg) {
var el = document.getElementById('status-save');
el.textContent = msg;
el.style.color = '#10b981';
setTimeout(function() { el.textContent = '–'; el.style.color = ''; }, 2000);
}
还有个容易漏的细节:切换文件前如果没保存,先自动保存一次,防止打开别的文件把未保存的改动丢掉。
整体流程:

图3:自动保存的流程。输入触发 2 秒防抖,停顿后写盘,状态栏给「已保存」反馈。
实际状态栏效果:

图4:状态栏左下角是字数/行数统计,右侧出现绿色的「已保存」提示。
功能三:同步滚动,按比例映射
编辑区和预览区内容长度不一样,滚动同步不能简单「滚动多少跟多少」,要按比例映射——各自算 scrollTop / maxScroll,一个的进度对应另一个的进度:
function syncScroll(source, target) {
var maxS = source.scrollHeight – source.clientHeight;
var maxT = target.scrollHeight – target.clientHeight;
if (maxS <= 0 || maxT <= 0) return;
target.scrollTop = (source.scrollTop / maxS) * maxT;
}
双向都绑:编辑区滚动 → 预览区跟;预览区滚动 → 编辑区跟。原理简单,但体验差别很大——没有这个,长文档对不上位置,看起来像两个孤立的框。
最大的坑:预览里引用本地图片,全裂了
这是整个项目踩得最深的一个坑,也是我最想讲的一段。
Markdown 里写 ,正常浏览器预览应该显示图片。但这个桌面应用里——裂图。原因就是前面那句「WebView 是沙箱」:它加载的是内存里的 HTML,前端没有权限读本地磁盘,<img src="相对路径"> 根本解析不到。
我一开始在前端硬拼绝对路径,想着把 images/demo.png 拼成 D:/xxx/images/demo.png 总行了吧——还是不行。WebView 的安全策略不允许直接加载本地文件。绕了一圈才找到正解:图片不留在前端,走后端转成 base64 data URI 再塞回 HTML。
后端 read_image 读文件转 base64:
def read_image(self, path):
"""读取本地图片,返回 base64 data URI(供 Markdown 预览内嵌图片)。"""
if not os.path.isfile(path):
return {"ok": False, "msg": "文件不存在: %s" % path}
ext = os.path.splitext(path)[1].lower()
mime = IMAGE_MIME.get(ext)
if not mime:
return {"ok": False, "msg": "不支持的图片格式: %s" % ext}
try:
with open(path, 'rb') as f:
b64 = base64.b64encode(f.read()).decode('ascii')
return {"ok": True, "uri": "data:%s;base64,%s" % (mime, b64)}
except Exception as e:
return {"ok": False, "msg": str(e)}
前端 replaceLocalImages:渲染完遍历所有 <img>,非本地图片(data:/http:/https: 开头)跳过,本地的统一替换:
function replaceLocalImages() {
if (!(window.pywebview && window.pywebview.api && window.pywebview.api.read_image)) return;
var imgs = document.getElementById('preview-content').querySelectorAll('img');
imgs.forEach(function(img) {
var src = img.getAttribute('src');
if (!src || /^(data:|http:|https:|blob:)/i.test(src)) return; // 非本地图片跳过
var abs = resolveLocalPath(src);
if (!abs) return;
img.dataset.origSrc = src;
img.style.minHeight = '24px'; // 加载前占位,避免布局跳动
api.read_image(abs).then(function(res) {
if (res.ok) {
img.src = res.uri;
img.removeAttribute('style');
} else {
img.setAttribute('data-error', res.msg || '');
}
});
});
}
这里有个容易被忽略的点:相对路径的解析。 里的 images/demo.png 是相对当前 .md 文件的目录,前端拿到的是纯字符串,得自己拼成绝对路径:
function resolveLocalPath(src) {
if (/^[A-Za-z]:[\\\\/]/.test(src)) return src; // 已经是绝对路径
var dir = state.currentFilePath ? state.currentFilePath.replace(/[\\\\/][^\\\\/]*$/, '') : '';
if (!dir) return null;
// 去掉可能的 ./ 前缀,拼接相对路径
return dir.replace(/[\\\\/]$/, '') + '\\\\' + src.replace(/^[\\\\/]+/, '').replace(/\\//g, '\\\\');
}
还有两个小细节,做的时候很容易漏:
这个坑的本质是:WebView 是沙箱,前端拿不到文件系统。「一切文件操作走后端」从此成了这个项目的铁律——不只是图片,之后要加文件重命名、删除、历史版本,都得走同一条桥。
Python 侧值得说的三个设计
1. 记住上次打开的目录
每次设置工作目录时,把路径写到一个 last_dir.txt;启动时读到就恢复。文件操作类工具的常见需求,实现就几行:
def load_last_dir():
path = get_data_path('last_dir.txt')
if os.path.exists(path):
with open(path, 'r', encoding='utf-8') as f:
d = f.read().strip()
if os.path.isdir(d):
return d
return ''
def save_last_dir(path):
p = get_data_path('last_dir.txt')
with open(p, 'w', encoding='utf-8') as f:
f.write(path)
2. 窗口最大化
PyWebView 的 create_window 直接传全屏尺寸,但得先拿到屏幕大小。用 tkinter 读屏幕尺寸,同时设 min_size=(900, 550) 防止太小:
def get_maximized_size():
"""返回 (width, height, x, y):铺满工作区但保留任务栏的最大化尺寸。"""
try:
import tkinter
root = tkinter.Tk()
root.withdraw()
w = root.winfo_screenwidth()
h = root.winfo_screenheight()
root.destroy()
return w, h, 0, 0
except Exception:
return 1280, 780, None, None
3. 优雅降级:浏览器里也能跑
app.js 里启动时 waitForApi(2000)——等 pywebview API 就绪,最多 2 秒。如果在纯浏览器里打开(没有 pywebview),就降级到内置的 DEMO_MD 预览模式,界面照常渲染。这让前端能脱离桌面壳单独调试:
function waitForApi(timeoutMs) {
return new Promise(function(resolve) {
var start = Date.now();
function check() {
if (window.pywebview && window.pywebview.api && window.pywebview.api.get_working_dir) {
resolve(true);
} else if (Date.now() – start > timeoutMs) {
resolve(false);
} else {
setTimeout(check, 100);
}
}
check();
});
}
这个设计是踩出来的:一开始前端依赖 pywebview 才能跑,浏览器里一打开就白屏,调试全靠打包 exe 看效果,慢得离谱。加了降级后,前端逻辑直接在浏览器里调,文件操作临时 mock 掉就行。
打包成 exe:_MEIPASS 和三态路径
打包成 exe 是这类工具的常见诉求。PyInstaller 会把 web/ 目录打进一个临时解压目录 sys._MEIPASS,运行时代码里直接写相对路径会找不到。
核心是区分资源路径(web/ 前端,走 _MEIPASS)和运行数据路径(last_dir.txt、导出文件,走 exe 同级):
def get_web_dir():
if getattr(sys, "frozen", False):
return os.path.join(sys._MEIPASS, 'web')
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web')
def get_data_path(filename):
if getattr(sys, "frozen", False):
return os.path.join(os.path.dirname(sys.executable), filename)
return os.path.join(os.path.dirname(os.path.abspath(__file__)), filename)
判断标准就一条:sys.frozen 为真说明跑在打包后的 exe 里,资源和数据分两个地方取。
坑位清单,拿去直接用
| 预览引用本地图片裂图 | 后端 read_image 转 base64 data URI,前端统一替换 |
| WebView 加载不了本地文件 | 一切文件操作走 js_api 桥回 Python |
| 前端依赖 pywebview 白屏 | waitForApi 超时降级 DEMO 预览模式 |
| 相对路径解析 | resolveLocalPath 拼绝对路径 + 统一转 \\ |
| 图片出现布局跳动 | minHeight: 24px 占位 |
| 打包后找不到 web 资源 | sys._MEIPASS + sys.frozen 三态路径 |
一点坦白
写完回头看,这个工具有几个小毛病,一直没改:
- 「编辑」按钮的文案不会变:点了折叠编辑区,按钮文字还是「编辑」,应该切换成「预览」的,写的时候漏了。下次有心情再说。
- last_dir.txt 的位置:它跟着 exe 同级放,符合「运行数据跟程序走」的约定,但不同用户共用会互相覆盖。真要多用户,得挪到用户目录。
这些小毛病不影响核心功能,但对一个想认真维护的项目来说,都是待办。
总结
聊到最后,把这次的经验浓缩成几句话:
- PyWebView 适合什么场景? 需要桌面壳、但界面复杂度超过 Tkinter 能力圈、又不想为 Qt 付出太重成本的小工具。它把「界面」这件事外包给了浏览器,代价是前端和 Python 之间多了一条需要维护的桥。
- 最大的教训是什么? WebView 是沙箱,前端碰不到文件系统。早想明白这一点,能少走很多弯路——所有涉及本地文件的逻辑,一开始就放 Python 侧。
- 如果你也想做类似的工具,建议先把「前端降级到浏览器能跑」做好,调试体验会好非常多。
如果你也在做类似的桌面小工具,或者好奇 PyWebView 的本地文件限制怎么处理,欢迎评论区聊聊——尤其是那个本地图片裂图的坑,有没有人跟我一样,绕了一圈才发现要转 base64?需要工具的,请私信我哦!!!!!!!!!!!!


