链接定义写在后面,分段渲染就断了
主线位置:块主线的第一遍。
Parser::new_ext在返回之前跑完整个第一遍,所有链接引用定义都在这时收进refdefs;第二遍遇到[文字][标签]才去查(表格与脚注)。查不到的引用留作文字。
sequenceDiagram participant App as 调用方 participant P as Parser participant FP as 第一遍 App->>P: new_ext 整份文本 P->>FP: run_first_pass FP-->>P: 树和 refdefs App->>P: next P-->>App: 第一个事件 Note over App,P: 只给前半段时定义还没收到
实验在 exp-pulldown-cmark/pita-lab,bin p07_refdef(release),输出存在 out-p07.txt。M2 Max、macOS 27、rustc 1.99.0,pulldown-cmark 0.13.5,Options::empty()。
现象
一份定义写在文末的文档:
按[规范][spec]的定义,先读[第二节]。
(中间还有很多段落。)
[spec]: https://spec.commonmark.org/0.31.2/
[第二节]: #part-2
边收边渲染。 像聊天界面显示模型输出那样,每收到一段就把已有的前缀整个重新解析、渲染一遍。第一段的 HTML 随着前缀变长而变化(左边是前缀的字节数,unresolved 是回调收到的未解析引用):
51 B <p>按[规范][spec]的定义,先读[第二节]。</p> unresolved=["spec", "第二节"]
86 B <p>按[规范][spec]的定义,先读[第二节]。</p> unresolved=["spec", "第二节"]
130 B <p>按<a href="https://spec.commonmark.org/0.31.2/">规范</a>的定义,先读[第二节]。</p> unresolved=["第二节"]
151 B <p>按<a href="https://spec.commonmark.org/0.31.2/">规范</a>的定义,先读<a href="#part-2">第二节</a>。</p> unresolved=[]
已经显示出去的方括号文字,在定义到达后变成了链接。
分块解析。 为了先显示第一屏,把文档在第二段开头切开,前半块单独交给解析器:
<p>按[规范][spec]的定义,先读[第二节]。</p> <- chunk 1 alone
这一块里的引用永远不会变成链接。
原因
CommonMark 允许定义写在文档任何位置、任何容器里,一个引用是不是链接,要看完整份文档才知道。pulldown-cmark 的做法是把「看完整份文档」放进构造函数。Parser::new_with_broken_link_callback(new 和 new_ext 都转到这里)的第一行:
let (mut tree, allocs) = run_first_pass(text, options);
第一遍收齐的定义在 allocs.refdefs 里,之后每个 next 只做当前块的行内解析(按需解析)。两个后果:
输入必须一次给全。 参数是
&'input str,不能喂Read,也没有「追加文本」的接口。给它一个前缀,它就把前缀当成完整的文档,前缀里没有的定义就是不存在。第一个事件要等第一遍。 spec.txt(204 815 字节)重复 1、10、100 次,各取 10 次中最快的一次(KB 按 1024 字节算,
uptime负载 4.25):200 KB first event 0.269 ms all events 0.449 ms 2000 KB first event 2.965 ms all events 4.751 ms 20001 KB first event 28.018 ms all events 46.326 ms拿到第一个事件的时间和文档长度成正比,约占全部事件的六成。这是设计,不是缺陷:两遍为什么这样分,见为什么是 pull parser。
找不到定义的引用会交给 BrokenLinkCallback(链接与图片),上面 unresolved 就是用它收集的。
修法
能拿到整份文档,就整份解析。 这是唯一保证和规范一致的做法;20 MB 的文档第一个事件也只要 28 ms。
必须分块(先渲染第一屏、按章节懒加载),就先用整份文档建一个解析器拿定义,再把它交给每一块的回调:
let whole = Parser::new(doc);
let defs = whole.reference_definitions();
let cb = |link: BrokenLink<'_>| {
defs
.get(&link.reference)
.map(|d| (d.dest.clone(), d.title.clone().unwrap_or_else(|| "".into())))
};
let mut out = String::new();
html::push_html(&mut out, Parser::new_with_broken_link_callback(head, Options::empty(), Some(cb)));
<p>按<a href="https://spec.commonmark.org/0.31.2/">规范</a>的定义,先读<a href="#part-2">第二节</a>。</p>
RefDefs::get 按 Unicode 大小写折叠查找,和解析器自己查的规则一样。两点代价:Parser::new(doc) 本身就是一次完整的第一遍,分块省下的只是行内解析和渲染;回调给出的链接类型是 ReferenceUnknown、ShortcutUnknown 这类,按 LinkType::Reference 匹配的代码要一并改。
边收边渲染,没有整份文档可用。每次重新解析整个前缀,结果和最终一致,但总代价随长度平方增长;上表的数字可以估算每次重新解析要多久。界面上要接受「方括号文字过一会儿变成链接」,或者用回调收集未解析的引用,在它们所在的段落标记为「未定稿」。
怎么检查
- 测试输入把定义放在文末、放在引用块和列表项里,断言引用都成了链接。
- 分块渲染的程序,把每一块的未解析引用(用回调收集)和整份解析时的比一比,多出来的就是被分块弄断的。
- 流式显示的界面,在最终结果到达后对比一次最后显示的 HTML 和整份渲染的结果,看是否需要整体刷新。
小结
- 链接引用定义可以写在使用之后、写在任意容器里;
Parser::new_ext在返回前跑完第一遍收齐定义,第一个事件的延迟和文档长度成正比。 - 只给前缀或分块解析时,定义不在这一块的引用就留作文字,回调会收到它们。
- 修法:整份解析;必须分块就用整份文档的
reference_definitions()喂每一块的BrokenLinkCallback;流式显示要接受早先的输出会变。