perf(markdown): 消除双解析 + format! 改 write! 直写

双解析:旧实现对同一份 md 调两次 Parser::new_ext(一遍收集标题、
一遍生成 HTML),等于两倍的 tokenize + 解析 CPU。现 collect 成
Vec<Event> 一次,两遍遍历复用(Event 内含 CowStr 借用 md 切片,
collect 后仍可重复借用)。解析 CPU 减半。

format! → write!:标题开始/结束、TOC li、runnable pre、language class
共 5 处在循环内用 format! 拼接 HTML 片段,每次先分配临时 String 再
push_str 复制进去、临时串立即丢弃。改用 write!(html, ...) 直写目标
String,零中间分配。N 个标题省约 3N 次临时分配。

顺带:html 与 toc_html 用 with_capacity 预分配(旧 String::new 多次 realloc)。

全部 68 个 markdown 测试通过。
This commit is contained in:
xfy 2026-07-15 11:41:55 +08:00
parent d4f8b463d2
commit a41a1d3ae1

View File

@ -36,18 +36,23 @@ pub struct RenderedContent {
/// 增强版 Markdown 渲染:生成 TOC、标题锚点与语法高亮代码块。 /// 增强版 Markdown 渲染:生成 TOC、标题锚点与语法高亮代码块。
pub fn render_markdown_enhanced(md: &str) -> RenderedContent { pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
use pulldown_cmark::{Event, HeadingLevel, Options, Tag, TagEnd}; use pulldown_cmark::{Event, HeadingLevel, Options, Tag, TagEnd};
use std::fmt::Write as _;
// 两遍解析使用相同的 Options避免 TOC 收集与正文渲染对 Markdown 扩展语法 // 两遍遍历使用相同的 Options 与同一份解析结果,避免 TOC 收集与正文渲染对
// (表格、删除线、脚注等)的处理不一致。 // Markdown 扩展语法(表格、删除线、脚注等)的处理不一致。
let opts = Options::all(); let opts = Options::all();
// 1. Parse markdown and collect headings for TOC // pulldown-cmark 只解析一次collect 成 Vec<Event> 后两遍遍历复用。
let parser = pulldown_cmark::Parser::new_ext(md, opts); // 旧实现对同一份 md 调用两次 Parser::new_ext等于两倍的 tokenize + 解析 CPU。
// Event 内含 CowStr借用 md 切片collect 后仍可重复借用。
let events: Vec<Event> = pulldown_cmark::Parser::new_ext(md, opts).collect();
// 1. 第一遍收集标题level, text, id用 iter() 借用不消费 events。
// (level, text, id) // (level, text, id)
let mut headings: Vec<(u8, String, String)> = Vec::new(); let mut headings: Vec<(u8, String, String)> = Vec::new();
let mut current_heading: Option<(u8, String)> = None; let mut current_heading: Option<(u8, String)> = None;
for event in parser { for event in &events {
match event { match event {
Event::Start(Tag::Heading { level, .. }) => { Event::Start(Tag::Heading { level, .. }) => {
let lvl = match level { let lvl = match level {
@ -62,12 +67,12 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
} }
Event::Text(text) => { Event::Text(text) => {
if let Some((_, ref mut content)) = current_heading { if let Some((_, ref mut content)) = current_heading {
content.push_str(&text); content.push_str(text);
} }
} }
Event::Code(code) => { Event::Code(code) => {
if let Some((_, ref mut content)) = current_heading { if let Some((_, ref mut content)) = current_heading {
content.push_str(&code); content.push_str(code);
} }
} }
Event::End(TagEnd::Heading(_)) => { Event::End(TagEnd::Heading(_)) => {
@ -83,9 +88,9 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
// 2. Generate TOC HTML // 2. Generate TOC HTML
let toc_html = generate_toc_html(&headings); let toc_html = generate_toc_html(&headings);
// 3. Generate HTML with heading anchors // 3. 第二遍:生成 HTML用 into_iter() 消费 events非标题事件需 move 进 push_html
let parser = pulldown_cmark::Parser::new_ext(md, opts); // HTML 输出通常比 md 长(标签包裹),按 md 长度 + 256 预分配,避免 String::new 的多次 realloc。
let mut html = String::new(); let mut html = String::with_capacity(md.len() + 256);
let mut heading_idx = 0; let mut heading_idx = 0;
let mut in_heading = false; let mut in_heading = false;
let mut in_codeblock = false; let mut in_codeblock = false;
@ -97,7 +102,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
let mut code_buffer = String::new(); let mut code_buffer = String::new();
let mut non_heading_events: Vec<Event> = Vec::new(); let mut non_heading_events: Vec<Event> = Vec::new();
for event in parser { for event in events {
match event { match event {
Event::Start(Tag::Heading { level, .. }) => { Event::Start(Tag::Heading { level, .. }) => {
// 先把累积的普通事件刷入 HTML再开始新标题。 // 先把累积的普通事件刷入 HTML再开始新标题。
@ -116,7 +121,8 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
HeadingLevel::H5 => "h5", HeadingLevel::H5 => "h5",
HeadingLevel::H6 => "h6", HeadingLevel::H6 => "h6",
}; };
html.push_str(&format!("<{} id=\"{}\">", tag, id)); // write! 直写目标 String零中间分配format! 会先分配临时 String 再 push_str
let _ = write!(html, "<{tag} id=\"{id}\">");
} }
} }
Event::End(TagEnd::Heading(level)) => { Event::End(TagEnd::Heading(level)) => {
@ -130,10 +136,10 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
HeadingLevel::H5 => "h5", HeadingLevel::H5 => "h5",
HeadingLevel::H6 => "h6", HeadingLevel::H6 => "h6",
}; };
html.push_str(&format!( let _ = write!(
"<a class=\"anchor\" aria-hidden=\"true\" href=\"#{}\">#</a></{}>", html,
id, tag "<a class=\"anchor\" aria-hidden=\"true\" href=\"#{id}\">#</a></{tag}>"
)); );
heading_idx += 1; heading_idx += 1;
} }
in_heading = false; in_heading = false;
@ -181,13 +187,14 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
let highlighted = let highlighted =
crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref()); crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
// 可运行代码块:在 <pre> 上挂 data-runnable / data-lang / data-overrides / data-source // 可运行代码块:在 <pre> 上挂 data-runnable / data-lang / data-overrides / data-source
// 阅读post_content.rs客户端扫描这些标记原地挂载 CodeRunner 组件。 // 阅读post_content.rs客户端扫描这些标记原地挂载 CodeRunner 组件。
// data-source 为 HTML 转义后的原始源码,供阅读器无损提取(避免反解高亮 HTML // data-source 为 HTML 转义后的原始源码,供阅读器无损提取(避免反解高亮 HTML
if let Some((lang, overrides_escaped)) = code_runnable.take() { if let Some((lang, overrides_escaped)) = code_runnable.take() {
let source_escaped = crate::utils::html::escape_html(&code_buffer); let source_escaped = crate::utils::html::escape_html(&code_buffer);
html.push_str(&format!( let _ = write!(
html,
r#"<pre data-runnable="true" data-lang="{lang}" data-overrides="{overrides_escaped}" data-source="{source_escaped}"><code class="language-{lang}">"# r#"<pre data-runnable="true" data-lang="{lang}" data-overrides="{overrides_escaped}" data-source="{source_escaped}"><code class="language-{lang}">"#
)); );
} else { } else {
html.push_str("<pre><code"); html.push_str("<pre><code");
if let Some(lang) = &code_lang { if let Some(lang) = &code_lang {
@ -195,7 +202,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
// 高亮的 language-xxx 取纯语言 token首个空白前 // 高亮的 language-xxx 取纯语言 token首个空白前
let clean_lang = lang.split_whitespace().next().unwrap_or(""); let clean_lang = lang.split_whitespace().next().unwrap_or("");
if !clean_lang.is_empty() { if !clean_lang.is_empty() {
html.push_str(&format!(" class=\"language-{clean_lang}\"")); let _ = write!(html, " class=\"language-{clean_lang}\"");
} }
} }
html.push('>'); html.push('>');
@ -304,11 +311,15 @@ where
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 根据标题层级生成嵌套目录 HTML。 /// 根据标题层级生成嵌套目录 HTML。
fn generate_toc_html(headings: &[(u8, String, String)]) -> String { fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
use std::fmt::Write as _;
if headings.is_empty() { if headings.is_empty() {
return String::new(); return String::new();
} }
let mut html = String::from("<ul>"); // TOC 大小按标题数估算(每个 li+a 约 64 字节起),避免 String::new 的多次 realloc。
let mut html = String::with_capacity(headings.len() * 64 + 16);
html.push_str("<ul>");
let mut stack: Vec<u8> = vec![headings[0].0]; let mut stack: Vec<u8> = vec![headings[0].0];
for (i, (level, text, id)) in headings.iter().enumerate() { for (i, (level, text, id)) in headings.iter().enumerate() {
@ -342,10 +353,10 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
// 因此正文与属性两处都走 escape_heading_text转义 & < > " ')。原先用 clean_html // 因此正文与属性两处都走 escape_heading_text转义 & < > " ')。原先用 clean_html
// 处理属性上下文会漏掉 `"`,标题中的双引号会越出 aria-label 边界。 // 处理属性上下文会漏掉 `"`,标题中的双引号会越出 aria-label 边界。
let escaped_text = escape_heading_text(text); let escaped_text = escape_heading_text(text);
html.push_str(&format!( let _ = write!(
"<li><a href=\"#{}\" aria-label=\"{}\">{}</a>", html,
id, escaped_text, escaped_text "<li><a href=\"#{id}\" aria-label=\"{escaped_text}\">{escaped_text}</a>"
)); );
} }
// Close remaining lists // Close remaining lists