适用读者:想在 Windows 上做「单文件桌面应用」的 Go 与前端开发者;正在评估 WebView2 壳方案、或打算手写 Markdown 解析器的人;关心体积、启动、保存语义与安全边界的工程师
结论先行:MicroMD 把约 3.17 MiB 的 exe、零依赖的 HTML 界面和一个手写 Markdown 解析器装进同一个进程。真正值得复盘的不是「小」,而是四个取舍:用系统 WebView2、把界面落到 file://、用三个绑定连接原生与 JS、用两遍式解析同时守住转义与占位符。它还有七个没有完全解决的问题,文章会逐个给出代价与遗留
阅读时间约 30 分钟 | 难度:进阶 | 收获:一套可复用的 WebView2 单文件壳实现 + 手写 Markdown 解析器的两遍式设计 + 七个真实问题的现象、原因与解决代价 + 一份不粉饰的限制清单
目录
- 为什么再造一个 Markdown 阅读器
- 一个 exe 与它的原生外壳
- 界面为什么先写到磁盘
- 三个绑定与保存语义
- 手写解析器:两遍式与块级状态机
- 问题一:WebView2 缺失与浏览器回退
- 问题二:图片相对路径的基址错位
- 问题三:全量重渲染与大文件节流
- 问题四:草稿配额与未使用的 MAX_DRAFT
- 问题五:体积、图标与启动时间
- 问题六:保存语义与用户预期错位
- 问题七:没有文件变更监听,也没有最近文件
- 已知限制与设计取舍
- 小结
- 相关阅读
为什么再造一个 Markdown 阅读器
把 Markdown 阅读器做成 3 MB 的单文件,听起来像在压缩一个本来就不大的东西。真正难的地方不在 Markdown:一个能解析标题、列表、表格的解析器可以很短,难的是 Windows 桌面壳——窗口、DPI、图标、WebView2 运行时、文件读写权限,以及「双击就能用」这四个字背后的分发问题。
MicroMD 的解法是尽量把成本外置:渲染引擎不打包,直接用系统里的 WebView2;界面不引入框架,写成一个 HTML 文件;Markdown 解析器不引第三方库,用两百多行 JavaScript 手写;原生层只保留窗口创建、三个绑定和启动参数解析。换来的结果是发布物只有一个 exe,解压即用,只写自己的应用数据目录,不装服务,也不依赖网络。
但要先把边界说清楚。它不是跨平台应用,代码里大量 Win32 调用和 WebView2 依赖决定了它只面向 Windows 10 与 11。它也不是功能完整的编辑器:没有代码高亮、没有侧栏目录、没有文件变更监听、没有最近文件列表。这些不是「以后会加」的路线图,而是当前版本的真实状态。本文按实现顺序拆开它:先看外壳和落盘,再看三个绑定与解析器,最后把七个已经暴露的问题逐个复盘。
体积数字也先摆正。release 构建在 strip 之后是 3,323,392 字节,即 3.17 MiB(十进制约 3.32 MB);未 strip 是 4,943,872 字节,
-s -w 省下 32.8%。标题里的「3 MB」说的是这个量级,不是精确的十进制 3.00 MB。
一个 exe 与它的原生外壳
先看这个 exe 里到底有什么。它没有把 Chromium 打进去,所以「3 MB」不是「3 MB 的浏览器」,而是「3 MB 的壳 + 系统里那份引擎」。壳的组成可以拆成五层:
原生入口很短。
main.go 先做 DPI 初始化,再算出应用数据目录,把内嵌的 HTML 写到磁盘,解析命令行参数,最后创建窗口并
Run()。窗口由
webview2.NewWithOptions 创建,关键参数只有几个:标题
MicroMD、宽高由
windowSize() 计算、
IconId: 1、
Center: true、
DataPath 指向
%LOCALAPPDATA%\MicroMD\webview。
Debug: false 同时关掉默认右键菜单和 DevTools,这是发布配置,不是开发配置。窗口样式来自
go-webview2 内部的默认值,注释写的是
WS_OVERLAPPEDWINDOW,也就是说它没有自定义标题栏,也没有原生菜单栏,外观完全交给系统。
这个选择很省事,也把限制写死了。没有自绘标题栏,就没有拖动区域、最小化动画、窗口阴影的全套适配问题;没有菜单栏,所有操作只能放进 HTML 里的按钮和快捷键。
DataPath 单独指向应用目录,意味着 WebView2 的缓存、Cookie、localStorage 都落在
%LOCALAPPDATA%\MicroMD\webview,不会污染用户的其他浏览器配置,卸载时删目录即可。代价是这个目录会随使用增长,而代码里没有清理逻辑。
还有一个容易被忽略的细节:
NewWithOptions 返回
nil 时,
main 会调用
openExternal(page) 用默认浏览器打开同一个页面,然后直接返回。这条路径是「没有 WebView2 运行时」时的兜底,后文会单独讲。需要提醒的是,并非所有初始化失败都会返回
nil;
go-webview2 的部分错误路径直接
log.Fatal,所以「兜底」覆盖的是窗口创建失败,而不是全部启动失败。
界面为什么先写到磁盘
//go:embed web/index.html 把 29,520 字节的界面编进二进制后,代码没有用
NavigateToString,而是先落盘再以
file:// 导航。启动时的顺序是:
appDataDir() 取 LOCALAPPDATA,拼出 %LOCALAPPDATA%\MicroMD;取不到就退到 os.UserCacheDir(),再退到相对路径 MicroMD。
- 拼接
app\index.html,os.MkdirAll(appDir, 0o755)。
- 读现有文件,和
indexHTML 全文比较;读失败或内容不同才 os.WriteFile(page, indexHTML, 0o644)。
view.Navigate(fileURL(page)),fileURL 用 net/url 处理盘符、斜杠和转义。
为什么不用字符串直接导航?从代码能看到的直接收益是:页面有了一个稳定的
file:// 源,localStorage 才能按这个源持久化;
<input type="file">、File System Access API、相对资源解析都按普通页面的规则工作;不需要自己注册自定义 scheme,也不需要起一个本地 HTTP 服务。最后一点尤其重要——起本地端口会引入端口冲突、防火墙提示和「到底有没有联网」的疑问,而这个项目的卖点之一就是渲染全在本地。
落盘策略是「字节不同才重写」,而不是每次启动都写。好处是升级时界面会更新,平时启动只多一次 29,520 字节的读取,开销可以忽略;坏处是用户如果手动改了
%LOCALAPPDATA%\MicroMD\app\index.html,下次启动会被覆盖回去。代码没有提供「使用外部界面」的开关,这是刻意的简化。
这条链路有两个静默失败点:
MkdirAll 失败直接
return,
WriteFile 失败也直接
return。两者都不会弹窗,用户看到的是「双击没反应」。在正常权限下这几乎不会发生,但如果目录被杀软锁定、磁盘满、或者
LOCALAPPDATA 指向不可写位置,就没有任何可诊断信息。这是「单文件、无安装器」带来的直接代价:没有安装程序替你检查目录权限。
三个绑定与保存语义
原生与 JS 之间只有三个绑定,名字都很直白:
micromdLoad 的设计很省:它不返回路径,只返回文件名和内容。前端拿到 JSON 后把
app.native 设为
true,于是
Ctrl+S 会调
micromdSave。
micromdSave 自己不接受路径参数,只认启动时解析出的
docPath;当前端在「没有原生文件」的状态下误调它,原生侧用
no file 拒绝,这是一层防御。
真正的语义分叉发生在前端。
native 只在一种情况下为
true:命令行传入了文件路径,或者把
.md 拖到了 exe 图标上——后者在 Windows 上同样表现为启动参数。其他打开方式都会走
setDocument(file, handle),而这个函数第一件事就是把
app.native = false。于是保存出现三条路径:
- 原生文件:
micromdSave 直接写回原路径,提示「已保存到 文件名」。
- 文件选择器:如果
showOpenFilePicker 可用,拿到 FileSystemHandle,用 createWritable() 写回;提示「已保存到 handle.name」。
- 拖进窗口或
<input type="file"> 回退:既没有原生路径,也没有可写句柄,退化成 Blob 下载,提示「已导出 文件名」。
降级逻辑在
saveFile() 里是显式的三级:
if (app.native && typeof window.micromdSave === 'function') {
await window.micromdSave(content);
return;
}
if (app.handle && app.handle.createWritable) {
const writable = await app.handle.createWritable();
await writable.write(content);
await writable.close();
return;
}
const link = document.createElement('a');
link.href = URL.createObjectURL(new Blob([content], { type: 'text/markdown;charset=utf-8' }));
link.download = app.name || 'untitled.md';
link.click();
这段代码有一个值得肯定的细节:
setDocument 每次打开新文件都会重置
native。假设用户先用命令行打开
A.md,原生
docPath 指向 A;随后又通过选择器打开
B.md,如果没有这个重置,
Ctrl+S 可能把 B 的内容写进 A。现在它明确地把「当前文档是否可原生写回」和「当前文档是谁」绑在一起,避免了最危险的那类错写。
代价是用户必须理解三套语义。同一个
Ctrl+S,在不同打开方式下分别意味着「覆盖原文件」「覆盖选择器文件」「下载一份副本」。README 写了规则,界面只用 toast 区分「已保存」和「已导出」,没有在打开文件时提前告诉用户「这个文件不会被写回」。这不是实现错误,而是安全模型与用户预期之间的缝,后文会展开。
手写解析器:两遍式与块级状态机
web/index.html 里用
/* md:start */ 和
/* md:end */ 圈出了解析器,约在 166 到 412 行。它没有引 marked、markdown-it 之类的库,而是自己实现了
inline() 和
blocks() 两层。最值得讲的是
inline() 的两遍式设计,因为它同时解决了两个看似矛盾的问题:既要让 Markdown 语法识别生效,又不能让原文里的 HTML 注入页面。
第一遍不是解析,而是「藏」。代码段、自动链接、反斜杠转义先被替换成占位符,形如
\u0001 + 数字 +
\u0001,对应的 HTML 存进
stash 数组:
const stash = [];
const put = (html) => '\u0001' + (stash.push(html) - 1) + '\u0001';
t = t.replace(/(`+)([\s\S]*?)\1/g, (m, ticks, code) =>
put('<code>' + esc(code.replace(/^ ([\s\S]*?) $/, '$1')) + '</code>'));
t = t.replace(/<((?:https?:\/\/|mailto:)[^\s<>]+)>/g, (m, url) =>
put('<a href="' + esc(url) + '">' + esc(url) + '</a>'));
t = t.replace(/\\([\\`*_{}\[\]()#+\-.!>~|])/g, (m, ch) => put(esc(ch)));
t = esc(t);
第二遍才是整体转义:
&、
<、
>、
" 全部变成实体。第三遍在转义后的文本上识别图片、链接、裸 URL 和强调,最后把占位符回填成真正的 HTML。这个顺序很关键:如果先整体转义,代码段里的反引号和链接里的括号会被实体打乱,语法识别失败;如果先识别再转义,拼接出来的
<a>、
<img> 又会被当成用户输入转义掉。占位符把「需要保留的 HTML」暂时移出文本,让
esc 只处理真正的用户文本,回填后再把结构放回去。
blocks() 是逐行状态机。它先统一换行、把 tab 展开成四个空格,然后在
while 循环里按优先级判断:围栏代码块、分隔线、标题、引用、表格、列表,最后才是普通段落。每种块都有自己的边界判断:
- 围栏代码块记录开头的反引号或波浪号数量,只有同字符且长度不小于开头的闭合行才结束,代码内容整体
esc,语言名写进 class="language-xxx"。
- 标题用
^ {0,3}(#{1,6}) 匹配,生成 id 时先去掉标签和标点,再按空白转连字符;标题重复时 id 也会重复。
- 引用把所有连续的
> 行剥掉前缀,递归调用 blocks(),所以引用里可以再放列表和代码块。
- 表格先拆表头,再读分隔行判断左中右对齐;正文行用带负向断言的
\| 分割,避免把转义的竖线当列边界。
- 列表记录第一项的缩进和有序/无序类型,遇到更深缩进的行就并入当前项,最后对每一项的文本递归
blocks(),嵌套列表因此成立;任务项用 ^\[([ xX])\][ \t]+ 单独识别,输出禁用的复选框。
普通段落会把连续的非空行用
<br> 连接,而不是按 CommonMark 的软换行合成空格。这是实现选择,不是 bug:对一个本地阅读器来说,作者在源码里换行通常就是希望看到换行。但它也意味着解析结果并不严格等价于 CommonMark,复制一段标准 Markdown 到别处渲染,段落形状可能不同。
解析器没有做代码高亮,
class="language-xxx" 只是留给未来或外部样式,页面里没有对应的着色规则。它也没有侧栏目录,标题的
id 生成了,但没有扫描标题生成导航的代码。块级状态机是逐行的,不做完整 AST,因此复杂嵌套、跨行强调、列表与表格的极端组合都只能靠规则覆盖,边界情况没有自动化测试兜底。整个仓库没有
*_test.go,解析质量靠人工样例验证。
安全边界:先转义再解析
解析顺序本身就是第一道安全边界。原文里的
<script>、
<iframe>、
onerror= 会先经过
esc,显示成文本,而不是进入 DOM。第二道是
safeUrl():图片和链接在拼接前检查是否以
javascript:、
vbscript:、
data:text/html 开头,命中就把整段原文转义后原样显示。第三道是点击拦截:预览区的普通链接与裸 URL 会带
target="_blank" 和
rel="noreferrer noopener",页面在捕获阶段只放行
http(s) 和
mailto,然后
preventDefault() 交给
micromdOpenExternal;原生侧再做一次前缀白名单,最后才
ShellExecuteW。
Debug: false 关掉 DevTools 与右键菜单,减少了「在应用内调试页面」的入口。
这几层能挡住常见的脚本注入,但有两个现状需要说清楚。第一,它没有禁用远程图片:如果 Markdown 里写了一个
https:// 图片地址,WebView2 仍然会发起网络请求。「渲染在本地、不联网」指的是应用自身不依赖网络,不是「文档里的远程资源一定不会加载」。第二,
safeUrl 只拦三类危险协议,其他协议或相对路径不会被拒绝;相对链接在
file:// 下会按当前页面的基址解析,这正好引出后面的图片路径问题。另外,普通链接和裸 URL 会补上
target 与
rel,尖括号自动链接这一支没有补,但它同样会被点击拦截,真正的边界仍在原生白名单。
问题一:WebView2 缺失与浏览器回退
现象:在一台没有 WebView2 运行时的 Windows 机器上双击 exe,没有应用窗口,默认浏览器被打开,页面看起来还是那个 MicroMD,但「保存」变成了下载,原生写回完全失效。
micromd.bat 则提供另一条路:依次找 Edge、Chrome、系统默认浏览器,用
--app 模式打开
web/index.html。
原因:整个渲染层依赖系统安装的 WebView2 Runtime。Windows 10 与 11 的大多数版本自带,但 LTSC、精简镜像、部分企业管控环境可能没有。
webview2.NewWithOptions 内部创建窗口失败时返回
nil,
main 只能用
openExternal(page) 兜底;这时 JS 里
typeof window.micromdLoad 不是函数,
micromdSave 也不存在,保存自然退化。另一个原因是
micromd.bat 依赖源码树里的
web/index.html,而发布物只有 exe,所以这个批处理只对仓库使用者有意义,不能作为终端用户的兜底。
解决:原生层检测
nil 后用
ShellExecuteW 打开已经落盘的页面;README 明确给出「没有运行时就直接用浏览器打开」的路径;批处理脚本用 Edge、Chrome、默认浏览器的三级顺序,尽量复用本机已有的 Chromium。前端不需要为这两种模式写分支,因为它本来就把原生绑定当成可选的。
代价与遗留:两种模式的能力不一致,而且用户很难在界面上看出自己处于哪一种。浏览器模式没有原生窗口、没有图标、没有直接写回,草稿的 localStorage 也取决于浏览器的
file:// 策略;代码没有提示「请安装 WebView2」,也没有提供引导安装的链接。兜底覆盖的只是窗口创建失败,部分初始化错误在库内直接
log.Fatal,进程会静默退出。这个问题的本质是「把引擎外置」换来的体积优势,必然要接受运行时不受自己控制。
问题二:图片相对路径的基址错位
现象:文档里写
,在别的编辑器里正常显示,在 MicroMD 里是破图。图片 URL 实际被解析到
%LOCALAPPDATA%\MicroMD\app\images\shot.png,而不是 Markdown 文件旁边的
images\shot.png。
原因:页面是通过
file:// 从应用目录导航的,浏览器的相对 URL 基址就是
app/index.html 所在目录。解析器把
src 原样放进
<img>,既没有注入
<base href="...">,也没有按文档路径重写相对地址。原生绑定只回传文件名和内容,不暴露目录,所以前端即使想重写也拿不到原始路径。命令行打开时原生知道
docPath,但这个信息没有传给页面。
解决:当前版本没有修复。能用的绕法是写绝对路径,例如
file:///C:/docs/images/shot.png 或
data: 内嵌图片;这两种都能被
safeUrl 放行,也能被 WebView2 加载。但绝对路径会破坏文档的可移植性,
data: 会让文件迅速膨胀,都不是通用方案。
代价与遗留:这是「界面先落盘」这个决定的直接副作用。要修就得在
micromdLoad 里多返回一个目录,前端在渲染后把相对
src 拼成绝对
file:// 地址,或者注入一个按文档目录计算的
<base>。前者要处理 URL 编码、盘符、
.. 和混合斜杠;后者会影响页面内所有相对 URL,包括链接和未来的资源。更麻烦的是拖进窗口和文件选择器打开时,浏览器出于安全不暴露完整路径,只有 File 对象,拿不到真实目录,所以同一份文档在不同打开方式下会出现不同的图片行为。这也是为什么它到现在仍然是一个「已知限制」,而不是一个已修复项。
问题三:全量重渲染与大文件节流
现象:在长文档里连续输入时,光标还在走,预览区却一顿一顿;文档越长,输入延迟越明显。滚动时也可能出现预览位置和编辑位置对不上的情况。
原因:
render() 的实现只有一句核心逻辑:
preview.innerHTML = MD.render(editor.value)。每次输入都从头解析整篇文档,再把整个预览树的 HTML 字符串替换掉。没有增量解析,没有 DOM diff,也没有把解析放进 Worker。
scheduleRender() 只用
requestAnimationFrame 合并同一帧内的多次输入,合并的是调用次数,不是工作量;解析和布局仍然在主线程上按整篇文档的规模跑。
解决:代码里有一个阈值
BIG_DOC = 200000。文档字符数超过 20 万时,
scheduleRender() 不再用
requestAnimationFrame,而是改成 150ms 的
setTimeout,把重渲染频率压到大约每秒六到七次;低于阈值时仍然每帧全量渲染。这是一个非常便宜的降级:不引入 Worker,不做增量,只把「多久渲染一次」调粗。
代价与遗留:阈值是按字符数拍的,和实际解析成本并不完全对应。19.9 万字符的文档仍然每帧全量渲染,20.1 万字符的文档突然降频,边界两侧体验不连续。150ms 是固定值,不随文档大小或机器性能自适应;在更长的文档上,单次解析本身就可能超过 150ms,节流只能减少次数,不能减少单次卡顿。
innerHTML 全量替换还会让预览区丢失选区、图片重新加载、滚动高度瞬间变化,滚动同步按比例计算
scrollTop,在高度跳变时就会失准。要真正解决,需要增量解析、虚拟列表或至少把解析搬到 Worker,这三者都会显著增加代码量和状态管理成本,不符合这个项目的体积目标。
问题四:草稿配额与未使用的 MAX_DRAFT
现象:README 说「超长草稿不写入本地存储」,但代码里声明了
MAX_DRAFT = 1000000 之后,全文没有任何地方引用它。实际行为是:每次输入都尝试写 localStorage,写不进去就被
try/catch 吞掉,界面没有任何提示,关掉窗口后草稿可能已经丢了。
原因:
saveDraft() 在输入停止 400ms 后调用
store.set('draft', editor.value) 和
store.set('name', app.name);
store.set 的实现是
try { localStorage.setItem(...) } catch (e) {},空 catch 意味着配额异常、隐私模式限制、序列化失败都不会冒泡。
MAX_DRAFT 很可能是早期打算用来做「超过上限就跳过」的分支,但最终没有接上。localStorage 的配额通常是每源几 MB,具体取决于浏览器和
file:// 的策略;把整篇文档作为一个字符串反复写,超过配额时
setItem 抛异常,草稿就停在旧版本。
解决:当前实现没有真正的超长保护,只有「异常不致命」的兜底。可行的修法很直接:在
saveDraft() 里判断
editor.value.length > MAX_DRAFT,超过就跳过并给一次 toast;或者把草稿改存 IndexedDB;或者原生模式下落一份草稿文件到应用目录。三种方案都比现在的空 catch 更诚实。
代价与遗留:用户看到的是「自动保存草稿」的承诺,实际得到的是「尽量保存」。连续输入时 400ms 的防抖意味着定时器不断重置,只有在停顿超过 400ms 后才会真正写一次;如果用户连续敲十分钟后直接关机,最后一次成功写入之前的全部内容都可能丢失。原生文件有未保存修改时,草稿是唯一的恢复手段,所以这个缺陷在「命令行打开、编辑、没按保存」的场景下风险最高。README 与实现的这处出入,应该按「现状缺陷」来读,而不是按「已实现的上限保护」来读。
问题五:体积、图标与启动时间
现象:release exe 是 3,323,392 字节;不 strip 是 4,943,872 字节;一个空的
windowsgui Go 程序也有 1,214,976 字节;内嵌的
WebView2Loader.dll 约 134 KB。启动时窗口句柄出现的时间,本机三次实测分别是 203ms、112ms、106ms。另一个现象是:本地直接
go build 出来的 exe 没有自定义图标。
原因:体积由四块构成:Go 运行时与链接产物的基线、
go-webview2 与
golang.org/x/sys 的代码、134 KB 的 WebView2Loader、29,520 字节的 HTML。
-H windowsgui 去掉控制台窗口,
-s -w 去掉符号表和调试信息,合起来把未 strip 的 4.94 MB 压到 3.17 MiB,省 32.8%。图标不是 Go 代码能直接带的资源,
IconId: 1 只是在窗口创建时按资源 ID 去取图标;真正的图标和版本信息由
go-winres 写进
rsrc_windows_amd64.syso,而这个
.syso 被
.gitignore 忽略。CI 的 release 流程会先安装
go-winres、生成资源再构建,本地构建如果跳过这一步,
IconId: 1 找不到资源,窗口就没有预期图标。
解决:构建命令固定为
-trimpath -buildvcs=false -ldflags "-H windowsgui -s -w";发布工作流把
go-winres 的安装和生成放在构建之前,并检查
rsrc_windows_amd64.syso 是否存在,不存在就报错;
windowSize() 按屏幕的 92% 和 88% 夹取 1280×860 的缩放结果,避免小屏上窗口超出边界;DPI 先尝试
SetProcessDpiAwarenessContext(-4),失败再退回
SetProcessDpiAwareness(2)。图标本身由
tools/icon/main.go 生成,用有向距离场和四倍超采样绘制,输出 16 到 256 的多种尺寸,不依赖外部设计文件。
代价与遗留:
-s -w 让 release 二进制失去符号表和调试信息,线上崩溃更难定位;本地构建多了一步工具链,忘记生成
.syso 就会得到「能跑但没图标」的版本,而且没有编译期报错。启动时间这组数字只是「窗口句柄出现」的时间,不是首帧可见时间,也不是内容渲染完成时间;窗口出现后还要创建 WebView2 环境、导航到
file://、执行 boot 脚本、读取草稿或原生文档,这些都没有计入。把 106ms 说成「启动完成」是不准确的,它最多说明原生窗口创建很快。体积也一样:3.17 MiB 是「不分发引擎」的结果,不是把 Chromium 压到了 3 MB。
问题六:保存语义与用户预期错位
现象:把
notes.md 拖进窗口,改几行,按
Ctrl+S,提示「已导出 notes.md」,原文件没有变化;下一次再拖进来,看到的还是旧内容。相反,如果把同一个文件拖到 exe 图标上打开,
Ctrl+S 会直接写回原文件。用户很难从界面看出这两种打开方式的区别。
原因:浏览器安全模型不允许网页凭一个
File 对象写回任意路径,
native 又只在启动参数存在时才为
true。拖进窗口得到的是 File API 的
File,没有可写句柄;文件选择器走
showOpenFilePicker 时能得到
FileSystemHandle,但
setDocument 仍然把
native 设为
false,保存走句柄而不是原生路径。代码选择了「默认安全」:没有明确的原生路径,就绝不猜测要覆盖哪个文件,宁可导出副本。
解决:三级降级已经覆盖了三种能力:原生路径、可写句柄、Blob 下载。
setDocument 重置
native 防止串文件;
micromdSave 在原生侧再次拒绝无路径调用;toast 用「已保存到」和「已导出」区分结果。这个实现没有错,它把「能不能写回」变成了能力探测,而不是靠用户记忆。
代价与遗留:能力探测的结果没有提前暴露。用户在拖入文件时不会收到「此文档将以导出方式保存」的提示,直到按下保存才看到差异。也没有原生「另存为」对话框:一旦进入下载分支,保存位置由 WebView2 的下载策略决定,通常是下载目录,文件名相同就多一份副本。文件选择器分支依赖 File System Access API,在 WebView2 里是否可用、权限提示如何呈现,都取决于运行时版本;不可用时就静默退回
<input type="file">,连句柄都没有。这个缝不是靠再加一个分支能补上的,它需要界面在打开文件时就明确展示当前保存模式,或者干脆提供「另存为」的原生入口。
问题七:没有文件变更监听,也没有最近文件
现象:用别的编辑器改了同一个
notes.md,MicroMD 里还是旧内容;此时按
Ctrl+S,如果当前是原生命中模式,旧内容会覆盖掉外部修改,而且没有任何冲突提示。关掉应用再打开,也没有「最近打开」列表,只能重新拖文件或重新走命令行。
原因:原生层只在启动时通过
micromdLoad 读一次文件,之后没有任何
ReadDirectoryChangesW、
fsnotify 或轮询 mtime 的逻辑。前端的状态只有
app.name、
app.saved、
app.handle、
app.native,localStorage 里只存
draft、
name、
theme、
view、
split,没有路径历史。整个应用也没有单实例锁或进程间通信,所以第二个实例不会把「文件已变化」传给第一个实例。
解决:当前版本没有实现自动重载,也没有冲突检测。可行的绕法是关掉再开、重新拖入,或者用文件选择器重新打开;但这些操作都不会提示「磁盘上的版本更新了」。要补这个能力,最小方案是原生侧定时检查
docPath 的修改时间,变化时通过绑定或
Eval 通知页面,页面弹一条「文件已在外部修改,重新加载 / 保留当前」的条;更完整的方案是接
ReadDirectoryChangesW,并处理重命名、删除和原子替换。
代价与遗留:没有监听,就没有「读者」和「作者」之间的边界。作为纯阅读器,这个问题还可以接受;但它同时提供了编辑和写回,旧内容覆盖新内容就是真实的数据风险。没有最近文件,则把「打开」的成本推给了文件系统,和「双击即用」的定位不太一致。加监听还会带来新的决策:收到变更时自动重载会丢掉未保存的编辑,弹窗询问又会打断阅读;保存时是覆盖还是另存,也需要一套策略。这些问题不解决,贸然加监听反而更危险,所以当前版本选择了不做。
已知限制与设计取舍
把前面散落的限制收拢成一张表,方便判断它适不适合你的场景。下表中的每一项都是当前代码的现状,不是「已经修好」的条目:
这些限制大多不是「没想到」,而是「在当前体积和复杂度预算下没做」。可以把它们归成四组取舍:
- 引擎外置换体积:不打包 Chromium,接受 WebView2 运行时依赖和两种能力模式。
- 单文件换工程简单:界面、解析器、样式全在一个 HTML 里,接受没有模块拆分、没有构建步骤、没有前端测试。
- 全量渲染换实现短:一句
innerHTML 就能工作,接受长文档的性能上限。
- 默认安全换可预期:没有原生路径就不写回,接受同一快捷键有三种结果。
如果继续做,优先级应该是「数据安全」高于「体验优化」:先把草稿上限真正接上、把外部修改冲突处理好,再考虑增量渲染和图片基址。代码高亮和侧栏目录属于阅读体验,不涉及数据丢失,可以放在后面。这个排序也解释了为什么文章把七个问题写得比功能更细:功能可以堆,数据丢了补不回来。
小结
MicroMD 的实现可以压缩成三句话:
- 体积不是单一优化,而是预算分配。3.17 MiB 的 exe 由约 1.21 MB 的空
windowsgui Go 基线、三个 Go 模块、134 KB 的 WebView2Loader、29,520 字节的 HTML 和链接产物组成;省掉的 Chromium 被系统 WebView2 接住了。
- 壳的复杂度集中在 Windows 细节。
file:// 落盘、三个绑定、保存语义、DPI 回退、图标资源、运行时缺失兜底,每一项都不大,但每一项都能让「双击即用」失效。
- 手写解析器的关键不是语法覆盖,而是顺序。先把代码段、自动链接、转义藏进占位符,再整体
esc,最后回填;这个两遍式设计同时解决了「语法要识别」和「原文不能注入」两个问题。
它也不是一个「完成品」。相对路径图片、草稿配额、全量重渲染、文件变更、最近文件、代码高亮、侧栏目录、解析器测试,这些都还没有做或者只做了一半。文章把它们写成现状,是因为一个 3 MB 的单文件应用真正的价值,不在于它多小,而在于它用多大的代价换来了这个体积,以及这些代价有没有被如实说清楚。
相关阅读