欢迎光临
我们一直在努力

别用 Tkinter 折磨自己了:PyWebView 让网页技术做桌面界面

用 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); // 每次输入都重新渲染预览
});

两个细节,都是踩完才明白要加的:

  • breaks: true 让单换行也渲染成换行。中文写作习惯里「段落内换行」很常见,不开这个会连成一片,很别扭。
  • XSS 兜底:所有动态内容进 DOM 前过 esc()。marked 默认允许原始 HTML,用户写个 <script> 就真执行了——预览区渲染的是自己的文档,但你要打开的 .md 可能是别人写的,这层必须有。
  • 实际效果:

    在这里插入图片描述

    图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 里写 ![](images/demo.png),正常浏览器预览应该显示图片。但这个桌面应用里——裂图。原因就是前面那句「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) 里的 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, '\\\\');
    }

    还有两个小细节,做的时候很容易漏:

  • minHeight: '24px' 占位:base64 加载是异步的,不占位的话图片出现时整个预览区往下跳,很晃眼。先给个最小高度顶着,加载完再移除。
  • 路径统一转 \\:Markdown 里写的是 /,Windows 路径是 \\,这里做了转换,否则 os.path.isfile 校验会失败。
  • 这个坑的本质是: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?需要工具的,请私信我哦!!!!!!!!!!!!

    赞(0)
    未经允许不得转载:171主机测评 » 别用 Tkinter 折磨自己了:PyWebView 让网页技术做桌面界面
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址