MarkView (https://markview.art,https://github.com/acheding/markview)的右侧预览区,看起来只是「把 Markdown 变成 HTML」。但真正跑起来的是一条跨线程、按需加载、带序号防乱序的管线:marked 解析、KaTeX 公式、highlight.js 高亮全部在 Web Worker 里跑完,Mermaid 因为需要真实 DOM 被单独留在主线程,最后串行提交到预览区。
本文把这条链路拆开讲。
一、链路全景
从一次击键到 DOM 落地,大致是这样:
content 变化 └─ previewRenderSession.render(source) [主线程] └─ markdownRenderer.render(source) → seq++ └─ worker.postMessage({ seq, source }) └─ renderMarkdown(source) [Worker 线程] ├─ extractFrontmatter 抽 YAML,换行占位保行号 ├─ lexer(body) marked 词法(含数学扩展) ├─ annotateSourceLines(tokens) 注入 token.sourceLine ├─ scanRenderNeeds → 按需 await 加载 hljs / KaTeX └─ parser(tokens, { extensions }) 同步渲染出 HTML └─ postMessage({ seq, html, toc }) └─ apply(seq, result) [主线程] 丢弃过期 seq └─ preparePreviewHtml(html, theme) Mermaid 落成 SVG └─ publishQueue 串行 → html.value = …线程边界很干脆:纯字符串、零 DOM 的部分全在 Worker;需要 DOM 的部分(Mermaid)留在主线程。
Worker 入口只有九行:
// src/core/markdown/renderMarkdown.worker.js// Web Worker:把 Markdown 渲染(marked + KaTeX + highlight.js,纯 JS、零 DOM)搬离主线程,// 大文档编辑时主线程不再卡顿。收 { seq, source },回传 { seq, html, toc }。self.onmessage=async(event)=>{const{seq,source}=event.dataconst{html,toc}=awaitrenderMarkdown(source)self.postMessage({seq,html,toc})}二、data-source-line:一切定位的地基
滚动同步、搜索定位、任务列表勾选回写——这些功能全都依赖一个东西:预览区的每个块级元素知道自己来自源码第几行。
MarkView 的做法是在 lex 之后、parse 之前遍历 token 树,用token.raw在源文里做游标式定位:
// src/core/markdown/renderMarkdown.js// 用 token.raw 在源文中定位块级 token 的起始行,输出>// 该属性是「编辑区行号 ↔ 预览 DOM」的唯一粘合契约,滚动同步与搜索定位都依赖它。constannotateSourceLines=(tokens,source)=>{letcursorIndex=0letcursorLine=1tokens.forEach((token)=>{if(!token.raw)returnconsttokenIndex=source.indexOf(token.raw,cursorIndex)if(tokenIndex<0)returncursorLine+=countLines(source.slice(cursorIndex,tokenIndex))if(SOURCE_LINE_TYPES.has(token.type)){token.sourceLine=cursorLine}if(token.type==='list')annotateListItemLines(token)cursorIndex=tokenIndex+token.raw.length cursorLine+=countLines(token.raw)})}游标cursorIndex单调推进,所以虽然写着indexOf,实际接近线性。
真正的麻烦在嵌套列表。marked 会把嵌套列表的raw逐行去掉父级缩进,导致indexOf直接失效。这里换了一种定位法:
// 列表项内的嵌套列表:其 raw 被 marked 逐行去除了父级缩进,无法直接 indexOf,// 改用「首行内容去缩进后精确相等」在 item.raw 的行中定位起始行——若某行去掉缩进后// 与嵌套列表首行完全一致,它本就会被解析成列表,故不会误中普通文字行。constchildFirstLine=child.raw.split('\n',1)[0].trim()constlineOffset=itemLines.findIndex((text,index)=>index>=searchFrom&&text.trim()===childFirstLine)还有个容易忽略的坑:frontmatter。如果直接把---\ntitle: x\n---从源码里切掉再解析,后面所有 token 的行号都会整体偏移。解法是用等量换行占位:
// 抽出 frontmatter,并用等量换行占位替换原区域——保留后续 token 的源码行号,// 否则 annotateSourceLines 里>constconsumed=match[0]constnewlineCount=countLines(consumed)constblanked='\n'.repeat(newlineCount)+source.slice(consumed.length)这个契约在消费端也有明确声明(PreviewController.js的文件头注释写着「data-source-line契约由 renderMarkdown 与这里闭环,Vue 组件不感知该属性」)——生产方和消费方各一处,中间层完全不用知道它存在。
三、绕开「renderer 不能 async」
marked 的 renderer 必须同步返回字符串。但代码高亮要 highlight.js,公式要 KaTeX,这两个都是几十上百 KB 的重依赖,不该无脑打进主包。
MarkView 的解法是把渲染拆成三拍:先 lex,再扫描 token 树看这次到底需要什么,异步加载完,最后同步 parse。
// marked 的 renderer 必须同步返回字符串,无法在其中 await;// 故在 parse 前先按 token 需求预加载重依赖,加载好后再走同步渲染。constneeds=scanRenderNeeds(tokens)awaitPromise.all([needs.hljs?loadHighlighter():null,needs.katex?loadKatex():null])scanRenderNeeds递归遍历 token 树(子 token 分布在tokens / items / header / rows四个字段,其中rows还是二维的),判断本次渲染是否真的出现了非 mermaid 的代码块、是否出现了公式。没有就不加载——一篇纯文字的文档首屏,既不会下载 hljs,也不会下载 KaTeX。
CSS 也是同样的按需策略,探测方式甚至不建 DOM,直接字符串匹配:
// src/preview/preparePreviewHtml.js// KaTeX 样式改为按需注入:仅当预览实际含公式时才加载 katex.min.css(缓存,仅注入一次)。// 与 mathExtension 的 JS 懒加载配套,让无公式文档的首屏彻底不碰这份较大的样式表。letkatexCssPromise=nullconstensureKatexCss=()=>{if(!katexCssPromise){katexCssPromise=import('katex/dist/katex.min.css').catch(()=>{})}returnkatexCssPromise}而且不 await——CSS 加载和 DOM 渲染并行,公式先无字体显示、样式到位后自动补上,比整体阻塞体验好。
四、行内公式与货币符号的战争
$…$作为行内公式定界符有个经典问题:「这件衣服 $100,那件 $200」会被识别成一个公式。
MarkView 采用了 Pandoc / GitHub 的定界规则,整条正则的推导过程被完整写在注释里:
// src/core/markdown/mathExtension.js// 行内公式 $...$:不匹配 $$,内部不跨行、不含裸 $。// 采用 Pandoc/GitHub 的定界规则规避货币误判(如「买 $100 和 $200」不应被当公式):// 1. 开 $ 右侧紧邻非空白 (?=\S) —— 排除「$ 100」这类// 2. 闭 $ 左侧紧邻非空白 [^$\n\s] 结尾 —— 「$100 和 $」的闭 $ 前是空格,不成立// 3. 闭 $ 右侧不接数字 (?![$\d]) —— 「$5 到 $10」的 $10 不误开公式// 内容首尾均须非空白,故用「(?=\S) + …以非空白结尾」表达,避免 lookbehind(旧版 Safari 不支持)。// 转义 `\$` 无需在此处理:marked 内置的 escape tokenizer 会先消费它,落为字面 $。constINLINE_MATH_RE=/^\$(?!\$)(?=\S)([^$\n]*[^$\n\s])\$(?![$\d])/四个决策叠在一条正则里:$$前瞻排除、货币三规则、刻意不用 lookbehind 兼容旧 Safari、转义交给 marked 内置的 escape tokenizer 先消费而不自己处理。
块级$$也有讲究——start函数只认行首:
// 只认行首的 $$,避免把段落中行内代码 `$$` 误当块级公式起点而截断段落。start(src){constmatch=/(^|\n)\$\$/.exec(src)returnmatch?match.index+match[1].length:undefined},这些规则在测试里都是独立用例:买 $100 和 $200 一共 $300全不成公式、区间 $ x $不成公式、\$5走 marked 的 escape 路径。
顺带一个体积细节:KaTeX 渲染时传的是output: 'html'而非默认的htmlAndMathml,DOM 直接少一半。
五、Mermaid:唯一留在主线程的一段
Mermaid 渲染需要真实 DOM(内部要往 document 插临时容器测量文本尺寸),Worker 里没有 DOM。所以它被单独切出去:Worker 只吐占位块,主线程再异步替换成 SVG。
// mermaid:不做语法高亮,输出占位块,源码作为文本子节点,由预览层异步渲染成图。if(language==='mermaid'){return`<div class="mermaid-block"${getSourceLineAttr(token)}>${escapeHtml(token.text)}</div>`}主线程侧有三个非做不可的处理。
其一,串行化。mermaid.initialize是全局单例配置,并发调用会互相污染主题:
// src/preview/mermaidRenderer.js// Mermaid SVG 生产器:按需加载、按主题和源码缓存,并串行保护全局 initialize + render。constcacheKey=(code,theme)=>`${theme==='dark'?'dark':'default'}\n${code}`// ...renderQueue=render.catch(()=>undefined)最后那行很关键:队列尾节点必须吞掉异常,否则一次渲染失败会把后续所有渲染串死。
其二,区分「语法错误」和「还没写完」。实时预览有个特有的状态:用户刚敲下 ```````mermaid ````这一行,围栏还没闭合,后面的正文被整段吞进代码块——这不是错误,是输入中间态:
// 围栏尚未闭合时,后续正文会被整段吞进源码,mermaid 识别不出图表类型(UnknownDiagramError)。// 这种「还不是图」的输入中间态按等待处理;只有已识别出类型的真实语法错误才展示错误块。constunknownType=error?.name==='UnknownDiagramError'||/no diagram type detected/i.test(error?.message||'')block.innerHTML=unknownType?'<p class="mermaid-empty">等待图表内容…</p>':`<pre class="mermaid-error">Mermaid 渲染失败:${escapeHtml(error?.message||String(error))}</pre>`其三,首屏不为它陪等。Mermaid 库很大,加载 + 渲染可达数百毫秒。如果整份 HTML 都等它,右侧会长时间停在骨架屏。于是有了「抢先绘制」:
// src/workspace/previewRenderSession.js// 含 Mermaid 的原始 HTML 需按需加载庞大的 mermaid 库并异步渲染成 SVG,耗时可达数百毫秒。// 首屏若整份等它,右侧会长时间停留在骨架屏。此处先把带占位块的原始 HTML 抢先交给调用方// 绘制一次(仅撤骨架屏、让文本立即可读)。抢先绘制不推进 published,定位类消费方走// whenPublished,仍等下方 SVG 就位后的最终发布,故定位精度不受影响。「抢先绘制不推进 published」是这段设计的精髓:视觉上提前一步,但依赖精确布局的消费方(滚动定位、搜索跳转)走的是另一条whenPublished通道,不受影响。
主题跟随也顺势解决了——theme 进了缓存 key,切主题时不重新走 Markdown 渲染,只复用latestRaw重跑一遍 prepare。首次渲染的图打上--fresh做淡入,缓存命中的重发布不打标,避免每次按键都让所有图表重放动画。
六、两层序号,各防各的乱序
快速输入时会有多个渲染请求同时在途,旧结果不能覆盖新结果。MarkView 用了两层调度器,各有一套计数。
第一层在 Worker 调度:
// src/workspace/markdownRenderer.jsconstapply=(resultSeq,result)=>{if(disposed||resultSeq<applied)returnapplied=resultSeqonResult(result,resultSeq)settleWaiters(true)}注意是<而非<=——允许同一 seq 被重复 apply(主题切换的 refresh 场景要用)。
降级路径也在这一层。Worker 创建失败(老浏览器)和运行期崩溃是两条不同的路:
try{worker=createWorker()worker.onmessage=(event)=>apply(event.data.seq,{html:event.data.html,toc:event.data.toc})worker.onerror=()=>{worker?.terminate()worker=nullif(latestRequest&&latestRequest.seq>applied)renderWithoutWorker(latestRequest)}}catch{worker=null}崩溃时只补渲染latestRequest(不重放整个队列),且只在它还没被应用时补。而renderWithoutWorker走的是动态import——降级代码在正常路径下完全不进主包。
第二层在发布会话,管的是四件不同的事:latestRequested(只有最新请求值得 prepare)、prepareRunId(主题切换会对同一 sequence 再跑一次 prepare,光靠 sequence 分不清新旧)、publishQueue(串行化 DOM 提交)、published(对外的完成信号)。
因为onResult内部有await nextTick(),await 期间状态可能变,所以发布体在 await 前后各查一次时序:
constpublish=publishQueue.then(async()=>{if(disposed||runId!==prepareRunId||raw.sequence!==latestRequested)returnfalseawaitonResult({...raw.result,html:preparedHtml})if(disposed||runId!==prepareRunId||raw.sequence!==latestRequested)returnfalsepublished=Math.max(published,raw.sequence)// ...})这些交错情形没法靠手工点击复现,但注入 deferred promise 之后全是毫秒级单测:「先回 seq2 再回 seq1,断言 onResult 只被调用一次且是 second,而两个 waiter 都 resolve」——这种用例跑一遍比开浏览器试十遍都靠谱。
七、XSS 防线在渲染层,不在净化层
值得单说一句:MarkView 没有引 DOMPurify。它的策略是渲染即净化——renderer 从不输出原始 HTML,所有文本走escapeHtml,URL 走白名单闸门:
constcompactUrl=url.replace(/[\u0000-\u001F\u007F\s]+/g,'')constprotocolMatch=compactUrl.match(/^([a-zA-Z][a-zA-Z\d+.-]*):/)if(!protocolMatch)returntrueconstprotocol=protocolMatch[1].toLowerCase()if(allowedProtocols.includes(protocol))returntrue先剥掉控制字符和空白再取协议——专门防java\0script:、java\tscript:这类绕过。链接白名单是http/https/mailto/tel,图片收紧为http/https加上受限的data:image/*;base64。
被拒绝时不是输出空 三条可以带走的经验:href,而是整体降级为纯文本 / alt 文本——测试里断言的是javascript:链接渲染成<p>结语data-source-line只是个字符串属性,但滚动同步、搜索定位、任务勾选全挂在它上面;这种跨模块契约值得在生产方和消费方各写一遍注释,中间层一概不知情。