流萤简历是我做的 AI 简历与求职资料工作台。这是专栏第 9 篇,从资料文档里的 Mermaid 和 HTML 预览切入,聊异步渲染、编辑状态与隔离边界。
产品特写:一份面试笔记,也可以把机制画出来
整理一道系统设计题时,只有长段落有时不够。流萤的 AI 练习文档支持把 Mermaid 放进内容块,边改源码边看图,也可以只展示结果;HTML 代码块则能在预览区呈现交互示例。AI 通过 CLI 写入源码,人继续在同一份文档里调整。

截图里,图表上方有视图切换、缩放、下载与全屏入口。它们看起来像几个普通按钮,实际上牵涉编辑器节点、渲染器全局配置、异步任务、预览 DOM 和本地观看状态。最难的部分往往出现在快速修改、语法暂时错误和页面切换的时候。
这个功能很适合人和 AI 协作:模型擅长生成结构化图表源码,用户能看图验证关系,再继续修订。要让这种协作自然,预览就不能反过来破坏源码和编辑生命周期。
源码属于文档,SVG 属于派生结果
Mermaid 根据文本描述生成图表,输出可以是 SVG。对编辑器而言,应该保存可继续修改的源码和必要节点属性,SVG 则按需要生成,不能把两者都当成用户可编辑的真值。
当前内容沿用 codeBlock,语言标记为 mermaid 或 html,保存源码、图表配色和显示模式等属性。其他语言继续作为普通代码展示。渲染器读取 node.textContent,不把生成结果写回原编辑节点。

图中的虚线说明观看状态没有默认写回正文。用户放大图表,是为了看清细节,不应该因此触发一次资料版本变化。否则两个人轮流缩放同一张图,就会产生没有内容意义的保存竞争。
反过来,作者选择图表配色与文档默认显示模式,是可以希望下次打开仍然保持的偏好,当前代码把这些写进节点属性。数据是否持久化,应该由它是否属于文档表达决定,而不是由它恰好用了 useState 还是某个按钮决定。
输入到一半的 Mermaid,本来就经常不合法
用户把 A --> B 改成 A --> C 时,中间可能短暂出现 A --。若每按一个键就清空预览、立即解析,图表会反复闪白,错误提示也会挤进用户注意力。
当前预览在源码停止变化 300ms 后绘制,并保留最后一次成功的 SVG。新源码还在更新,继续显示旧图并提示绘制中;新源码语法错误时,保留旧图同时展示错误入口,用户可以回到源码修正。
这里的旧图只能作为连续观看的辅助,不能让用户误以为它已经代表当前源码。状态提示与下载限制要一起工作:源码尚未成功绘制时,不应把旧 SVG 当成新结果交出去。

这些状态是对 UI 机制的说明,不是新增一张数据库状态表。源文档只有当前源码,最后成功图与错误状态在预览组件内维护。页面重新进入时可以重新渲染,不需要把错误堆栈当成文档字段存起来。
多张图为什么需要排队渲染
同一个资料页面可能有多张 Mermaid,每张还可以选择不同主题。Mermaid 的初始化配置与临时 DOM 不完全属于某个 React 组件,因此多个组件同时 initialize 和 render,可能互相影响。
当前模块维护一个 Promise 队列。每个 job 等待前一个结束,再加载 Mermaid、设置安全与主题配置、parse 和 render。每次使用独立递增图表 ID,并创建屏幕外的临时容器,finally 删除容器。
下面用伪代码表示排队关系,实际函数还包含配置与清理:
ts
const job = renderQueue
.catch(() => undefined)
.then(async () => {
const mermaid = await loadMermaid();
return renderWithCurrentSettings(mermaid, source, theme);
});
renderQueue = job;
return job;
这里的 catch 有明确恢复目的:上一张图失败,不应该让 Promise 链一直处于 rejected,使后面所有正常图也不再运行。当前 job 自己的错误仍交给对应组件显示,并没有被无声吞掉。
串行队列也有代价。一张复杂图可能阻塞后面的图,队列没有在这里展示任务优先级或移除所有过期工作的调度器。源码校验和容量限制有助于控制输入,但不能替代对大量复杂图的性能测量。
因此不能把"串行避免互相覆盖"宣传成"图越多仍然无限流畅"。它解决的是共享渲染状态的一致性问题,吞吐和取消是另外的工程维度。
取消等待,与取消计算,是两件事
React effect 在源码或主题变化时清理定时器,并把该次调用的 cancelled 置为 true。已经进入渲染队列的工作可能仍会完成,但结果回来以后不再更新当前组件。
设想源码 A 的绘制较慢,用户很快改成 B。没有过期结果保护,A 晚返回时会覆盖 B 的预览;有保护之后,A 的计算结果可以被丢弃,用户只看到当前有效结果。

当前 effect 对返回结果的处理可以摘成下面几行,省略计时器与长度检查:
ts
renderDiagram(source, theme).then(svg => {
if (!cancelled) {
lastSvg.current = svg;
setResult({ source, svg });
}
}).catch(error => {
if (!cancelled) setResult({ source, error: String(error).slice(0, 600) });
});
这段按原控制流做了局部缩写:真实错误分支会优先取 Error.message。cancelled 只负责这次 effect 的结果有效性;它没有向 Mermaid 传入可以中断计算的信号。
这个实现没有强制中断 Mermaid 内部计算,因此文章应该说"忽略过期结果",而不是"取消了所有旧渲染"。对于快速输入的大图,减少无用计算仍然是后续可以优化的方向。
下载入口把结果关联到源码,只有当前源码有成功 SVG 时才可用。主题变化也会触发绘制;如果未来要求导出对主题和其他配置也有同样严格的对应关系,应把完整渲染输入纳入结果身份验证。不能只因为界面看起来更新了,就假定所有导出条件已经覆盖。
初始缩放,为的是先看到完整关系
架构图经常横向很宽,直接按原尺寸展示会让读者只看到左上角。当前预览读取 SVG 的 viewBox,结合容器宽度和可用高度,等比计算适合整图的尺寸,再乘用户缩放比例。
ResizeObserver 监听预览区尺寸,窗口变化也会重新测量。用户放大以后,才通过预览容器内滚动与 pointer 拖动查看细节,不让整张文档跟着横向溢出。
这种分层在产品上很实用:正文仍保持阅读宽度,图表可以在自己的区域里探索。拖动开始、移动、结束和取消都要处理,pointer capture 让手指或鼠标拖出原位置时仍能完成动作。
清理也属于功能。观察器要 disconnect,窗口监听要移除,取消拖动要清空状态。否则切换多份资料以后,旧组件仍对新窗口变化作出反应,问题会表现成偶发错位和额外计算。
全屏不能重建一份"另一个编辑器"
把源码复制到全屏弹窗里的第二个编辑器,看起来很省事,但会立即产生两个撤销栈与两个选区。用户在全屏写了几行,退出后该怎么把它们合回原编辑器?中文输入尚未结束时又该怎么办?
当前实现使用浏览器 Fullscreen API,让现有编辑器区域进入全屏,而不是复制正文。块内工具通过现有节点工作,浮层容器随全屏区域调整,避免弹出的菜单被留在全屏外看不见。
源码继续使用 Tiptap 的原生内容节点,预览区域标为不可编辑。切换回源码时按块内相对位置恢复选区,防止每次回到代码开头。代码还检查实际节点位置与编辑状态,防止异步回调面对已经失效的文档坐标。
这些检查有很具体的生命周期依据:等待一帧之后,原节点可能已移动、删除或不再可编辑。它们与在稳定内部 API 的每个值上添加可选链不是一回事,负责的是真实竞态。
显示模式与节点属性同步也可能影响预览挂载。不能仅凭存在 previewMounted 就承诺任何切换都保持所有本地状态;验证要覆盖保存回显、source/preview 来回切换和刷新重开,分别观察源码、光标与缩放是否符合当前产品定义。
HTML 预览为什么要放进 iframe
HTML 交互示例需要脚本,但资料正文不能因此获得宿主页的权限。流萤将 HTML 预览放进 srcdoc iframe,只启用 sandbox="allow-scripts",不加入 allow-same-origin。这样预览运行在不共享本站来源身份的环境里。
CSP,也就是浏览器的内容安全策略,在预览文档里限制脚本、样式、图片和网络连接等资源。当前允许内联脚本与内联样式,以支持交互示例;禁止连接请求,并限制可加载的其他资源类型。

预览生成过程会去掉外部脚本、iframe、object、base、meta 和 link 等节点,也移除事件属性、链接和表单相关导航属性。净化只作用于预览副本,保存的源码仍然完整,用户不会因为看了一次预览而发现自己的代码被悄悄改写。
这几层各自承担责任。过滤器移除一批不需要的结构,sandbox 限制能力,CSP 约束资源;任何一层都不应被单独称为"已经证明所有攻击不可能"。尤其不应只看到 connect-src 'none',就把所有可能的导航通道一并推断成绝对无法发出请求。
产品需要的是有明确边界、可验证的交互示例环境。若后续增加网络请求、外部库或更宽的脚本能力,要在预览执行层重新设计,不能在某个页面临时放开一个属性。
同样是预览,作者与读者需要不同默认值
作者通常需要源码和效果并排,分享页的读者更希望先看到图。当前 code block 根据分享路径让 HTML 与 Mermaid 先展示预览,访客仍可临时查看或复制源码,但不能把临时查看动作保存成作者偏好。
作者选择的 mode 和 Mermaid 配色进入节点;缩放、换行、设备视口和全屏属于当前观看操作。这里的区分可以迁移到很多编辑器:数据所有者决定内容默认表达,阅读者保留本次观看控制。
HTML 设备预览也有桌面、平板和手机档位。它改变的是 iframe 展示视口,不是重新生成一份"手机内容"。预览刷新会重建 iframe,而正文源码仍然是同一个节点。测试要确认刷新能重跑交互,同时不触发正文保存。
怎样验证这类内容块,才算看到生命周期
先放入多张不同主题的图,快速修改其中一张,确认其他图没有被主题或临时 DOM 影响。再制造语法错误,观察旧图是否保留、错误是否可理解、当前源码的导出是否受控,修正后能否恢复。
切页、切显示模式、全屏进出和撤销需要组合测试。只检查"出现一张 SVG"看不到旧结果覆盖、焦点重置或监听泄露。缩放和拖动时还要观察保存请求,确认本地观看动作没有无端产生新版本。
HTML 用一个不含个人资料的交互示例验证脚本在框内工作,再检查它不能读取宿主页数据,受限资源按策略失败。只看到按钮能点,并不能证明隔离;只看到某个请求失败,也不能推导所有能力都已隔离。
可以在流萤 AI 练习把一道系统设计题画成 Mermaid,再让自己的 AI 帮忙调整结构,回到文档里继续推敲。源码、预览与编辑状态各有归属,内容块才能在复杂交互下保持自然。这个专栏从 CLI 自动化出发,落到的仍然是同一件具体的事:让 AI 的成果成为人能继续掌握和修改的产品内容。