Milkup 技术内幕:一个 Typora 风格的即时渲染 Markdown 编辑器是怎样炼成的

0 阅读12分钟

导读

Hi 掘友们好,我是德莱厄斯,前段时间我的 milkup 编辑器经历了一次脱胎换骨的更新(已发布 v1 版本),从 milkdown 内核转为基于 prosemirror 的自研内核(milkup core),它目前有更优秀的编辑体验和更容易拓展的系统,接下来将为大家带来 milkup 内核的技术解读。

简介

milkup 是一款跨平台的、桌面端的、永久完全免费开源、即时渲染的 markdown 编辑器,体验堪比 Typora!

项目地址:github.com/Auto-Plugin…

一、问题与设计目标

用过 Typora 的人大多都有一种感受:这才是写 Markdown 应该有的体验。光标移到哪里,语法符号就在哪里"冒出来";光标一离开,内容立刻变成渲染后的样子。这种体验被称为即时渲染(Immediate Rendering)。

注:Immediate Rendering(RI 即时渲染) 概念由 Vditor 提出,是对 Typora 渲染模式的总结,区别于 WYSIWYG(所见即所得)

但实现这种体验的难点在于:Markdown 编辑器天生就是"两张皮"——一边是源码,一边是渲染结果。大多数解决方案选择简单粗暴的路线:把编辑区和预览区分开,或者干脆把输入转换成富文本节点、彻底丢弃源码信息。

Milkup 选了一条更难走的路:语法符号不消失,而是作为真实的文本内容保留在文档里,只是在合适的时机被"藏起来"

这个设计决策影响了整个系统的架构,本文就沿着这条主线,一层一层拆解 Milkup 的实现。


二、整体架构:五层协作

Milkup 构建于 ProseMirror 之上。ProseMirror 是一个高度可扩展的富文本编辑框架,提供了文档模型、事务系统、插件机制等基础能力。Milkup 在此之上搭建了五个核心层:

用户输入的 Markdown 文本
         ↓
    ┌─────────────┐
    │   Parser    │  解析 Markdown,生成带标记的文档树
    └──────┬──────┘
           ↓
    ┌─────────────┐
    │   Schema    │  定义文档结构:节点类型、Mark 类型
    └──────┬──────┘
           ↓
    ┌──────────────────────────────┐
    │  Decoration + Plugin 系统    │  控制显示逻辑(即时渲染的核心)
    └──────┬───────────────────────┘
           ↓
    ┌─────────────┐
    │  NodeView   │  特殊节点的自定义渲染(代码块、数学公式等)
    └──────┬──────┘
           ↓
    ┌─────────────┐
    │ Serializer  │  将文档树重新序列化为 Markdown 文本
    └─────────────┘

数据在这五层之间单向流动:源码经 Parser 变成文档树,文档树在 ProseMirror 的 View 层渲染,Decoration 系统在渲染前插手控制显示,NodeView 接管特殊节点的 DOM 渲染,用户导出时 Serializer 逆向还原。


三、核心秘诀:syntax_marker 设计

这是整个系统最关键的设计决策,理解它是理解后续所有内容的前提。

3.1 语法符号"不消失"

以粗体为例。传统富文本编辑器解析 **粗体** 时,会创建一个带 bold 样式的文本节点,** 符号就被"消化"掉了。Milkup 不这样做。

Parser 解析 **粗体** 后,生成的文档结构是:

paragraph
  ├─ text "**"   [mark: syntax_marker{syntaxType: "strong"}]
  ├─ text "粗体"  [mark: strong]
  └─ text "**"   [mark: syntax_marker{syntaxType: "strong"}]

** 作为独立的文本节点存在,但被打上了 syntax_marker 这个特殊 Mark 的标签。

3.2 syntax_marker Mark 的定义

schema/index.ts 中,syntax_marker 被定义为一个携带 syntaxType 属性的 Mark:

// src/core/schema/index.ts
syntax_marker: {
  attrs: { syntaxType: { default: "" } },
  toDOM: () => ["span", { class: "milkup-syntax-marker" }, 0],
  parseDOM: [{ tag: "span.milkup-syntax-marker" }],
}

syntaxType 记录这个标记属于哪种语法("strong""emphasis""link" 等),后续的 Decoration 系统需要根据它来判断该不该显示。

3.3 为什么这样设计

这个设计的优雅之处在于:

  1. 光标可以自由移动进入语法符号区域,这是实现 Typora 式交互的物理基础
  2. 文档结构完全保留语法信息,序列化时直接提取文本内容就能还原 Markdown
  3. 显示与存储解耦:显不显示 ** 是渲染层的事,文档层不关心

四、即时渲染的实现:Decoration 系统

这是 Milkup 最核心、也最复杂的部分。整个"光标靠近语法符号就显现,光标离开就隐藏"的效果,都由 decorations/index.ts 这一层实现。

4.1 什么是 ProseMirror Decoration

Decoration(装饰)是 ProseMirror 提供的一种机制:可以在不改变文档内容的前提下,修改特定位置的 DOM 表现。Milkup 利用 Decoration 给 syntax_marker 文本节点添加或移除隐藏样式,从而控制它的可见性。

4.2 核心数据结构

// src/core/decorations/index.ts
interface DecorationPluginState {
  decorations: DecorationSet;              // 当前的装饰集合
  activeRegions: SyntaxMarkerRegion[];     // 当前光标激活的语法区域
  sourceView: boolean;                     // 是否处于源码模式
  cachedSyntaxRegions: SyntaxMarkerRegion[];   // 缓存的语法区域(性能优化)
  cachedMathInlineRegions: MathInlineRegion[]; // 缓存的行内数学区域
}

interface SyntaxMarkerRegion {
  from: number;       // 语法标记区域的起始文档位置
  to: number;         // 语法标记区域的结束文档位置
  syntaxType: string; // 语法类型
}

4.3 工作流程:step by step

每次文档内容变化或光标移动时,Decoration 插件都会重新计算装饰集合。流程分为两步:

第一步:扫描文档,建立 SyntaxMarkerRegion 列表

函数 findSyntaxMarkerRegions() 遍历整个文档,找出所有带 syntax_marker mark 的文本节点,并把同一语法单元的开始标记和结束标记合并为一个"区域"。

// 伪代码(简化自 src/core/decorations/index.ts)
function findSyntaxMarkerRegions(doc: Node): SyntaxMarkerRegion[] {
  const regions: SyntaxMarkerRegion[] = [];

  doc.descendants((node, pos) => {
    if (!node.isText) return;
    const markerMark = node.marks.find(m => m.type.name === "syntax_marker");
    if (!markerMark) return;

    // 将连续的 syntax_marker 文本节点合并为一个区域
    const syntaxType = markerMark.attrs.syntaxType;
    mergeOrCreate(regions, pos, pos + node.nodeSize, syntaxType);
  });

  return regions;
}

这一步的结果就像给文档打了一张"语法地图":哪个位置到哪个位置是一对 **...**,哪个位置是 `...`,一目了然。

第二步:根据光标位置,计算哪些标记该显示

函数 computeDecorations() 接收光标位置(selection.head)和上一步建立的区域列表,按以下规则决定每个 syntax_marker 节点的 CSS 类:

光标位置 (cursorPos)
         │
         ▼
┌────────────────────────────────────────────────────┐
│ 是否处于源码模式?                                  │
│   是 → 所有 syntax_marker 显示(不加隐藏类)         │
│   否 → 继续判断                                     │
└───────────────────┬────────────────────────────────┘
                    │
                    ▼
┌────────────────────────────────────────────────────┐
│ 遍历每个 SyntaxMarkerRegion                         │
│                                                    │
│ 情形 A:光标直接在该 syntax_marker 文本节点内         │
│   → 显示(让用户能看到并编辑原始符号)                 │
│                                                    │
│ 情形 B:光标在该区域的"语义范围"内                     │
│   (如光标在粗体文字中,则其两侧的 ** 都显示)         │
│   → 显示                                            │
│                                                    │
│ 情形 C:光标在该区域之外                             │
│   → 隐藏(添加 CSS 类 "milkup-syntax-hidden")      │
└────────────────────────────────────────────────────┘

实际代码的核心判断:

// src/core/decorations/index.ts(节选)
function computeDecorations(
  doc: Node,
  selection: Selection,
  syntaxRegions: SyntaxMarkerRegion[],
  sourceView: boolean
): DecorationSet {
  if (sourceView) return DecorationSet.empty;

  const { head } = selection;
  const decorations: Decoration[] = [];

  for (const region of syntaxRegions) {
    const isActive = head >= region.from && head <= region.to;

    if (!isActive) {
      // 隐藏这个区域内的所有 syntax_marker 节点
      decorations.push(
        Decoration.inline(region.from, region.to, {
          class: "milkup-syntax-hidden",
        })
      );
    }
    // 激活时不加任何装饰,syntax_marker 自然显示
  }

  // 行内数学公式:额外渲染 KaTeX Widget
  for (const mathRegion of mathInlineRegions) {
    if (!isActive(mathRegion, head)) {
      decorations.push(
        Decoration.widget(mathRegion.from, renderKaTeXWidget(mathRegion.content))
      );
    }
  }

  return DecorationSet.create(doc, decorations);
}

4.4 性能优化:两级缓存

每次按键都要重新扫描整个文档是不可接受的。Milkup 引入了两级缓存:

  • 文档未变化时(只是光标移动):复用 cachedSyntaxRegions,只重新运行第二步
  • 文档发生变化时:重新运行第一步扫描,更新缓存,再运行第二步
// 伪代码
apply(tr: Transaction, state: DecorationPluginState) {
  let { cachedSyntaxRegions } = state;

  if (tr.docChanged) {
    // 文档变化,重新扫描
    cachedSyntaxRegions = findSyntaxMarkerRegions(tr.doc);
  }
  // 光标变化或文档变化,都需要重新计算装饰
  const decorations = computeDecorations(
    tr.doc, tr.selection, cachedSyntaxRegions, state.sourceView
  );

  return { decorations, cachedSyntaxRegions, ... };
}

4.5 CSS 的配合

milkup-syntax-hidden 的 CSS 实现用的是 font-size: 0 而非 display: none,这样光标仍然可以"走进去",保证用户点击渲染后的文字时能精确定位到对应的源码位置。

.milkup-syntax-hidden {
  font-size: 0;
  line-height: 0;
}

五、源码模式切换:块级节点的形变

Milkup 支持在渲染模式和源码模式之间切换。两种模式下,文档的结构是完全不同的

5.1 两种模式下的文档结构对比

渲染模式下,Markdown 的块级结构被忠实地表达为 ProseMirror 节点:

# 标题         → heading 节点 (level: 1)
> 引用内容      → blockquote 节点
```jscode_block 节点 (language: "js")
代码内容
```

源码模式下,这些块级节点全部被展平为 paragraph,每一行是一个段落,内容是原始的 Markdown 文本:

paragraph: "# 标题"
paragraph: "> 引用内容"
paragraph: "```js"
paragraph: "代码内容"
paragraph: "```"

5.2 切换函数

plugins/source-view-transform.ts 提供了两个核心函数:

convertBlocksToParagraphs():渲染 → 源码

遍历文档中的所有块级节点,把每个节点替换为若干个 paragraph,段落内容是该节点对应的 Markdown 源码。这里需要事先在节点 attrs 中存储源码信息(如 code_block 节点存储了 language 属性和原始代码内容)。

convertParagraphsToBlocks():源码 → 渲染

把当前文档的所有文本内容拼接成 Markdown,然后重新调用 Parser 解析,用解析结果替换整个文档。

// 伪代码(简化自 source-view-transform.ts)
function convertParagraphsToBlocks(state: EditorState, parser: MarkdownParser) {
  // 提取所有段落文本,重组为 Markdown
  const markdown = extractParagraphsAsMarkdown(state.doc);
  // 重新解析
  const newDoc = parser.parse(markdown);
  // 用新文档替换旧文档
  return state.tr.replaceWith(0, state.doc.content.size, newDoc.content);
}

5.3 状态总线:sourceViewManager

切换源码模式会影响多个子系统:Decoration 系统需要知道(决定是否隐藏标记),NodeView(代码块)需要知道(决定是否渲染 CodeMirror),行号插件需要知道(决定是否显示行号)。

Milkup 用一个简单的发布订阅对象 sourceViewManager 作为状态总线:

// src/core/decorations/index.ts
class SourceViewManager {
  private listeners: SourceViewListener[] = [];
  private state: boolean = false;

  subscribe(listener: SourceViewListener): () => void {
    this.listeners.push(listener);
    return () => {
      this.listeners = this.listeners.filter(l => l !== listener);
    };
  }

  setState(sourceView: boolean): void {
    this.state = sourceView;
    this.listeners.forEach(l => l(sourceView));
  }

  getState(): boolean {
    return this.state;
  }
}

export const sourceViewManager = new SourceViewManager();

各插件在初始化时订阅它,源码模式切换时统一触发更新。


六、NodeView:特殊节点的渲染代理

并不是所有节点都能靠 ProseMirror 的默认渲染搞定。代码块、数学公式等需要嵌入第三方库。NodeView 是 ProseMirror 提供的逃生口:允许开发者接管特定节点的 DOM 渲染。

6.1 代码块:嵌入 CodeMirror 6

nodeviews/code-block.ts 为每个代码块节点创建一个独立的 CodeMirror 6 编辑器实例,并根据 language 属性加载对应的语法高亮扩展。

特别值得一提的是 Mermaid 图表的处理:

// 伪代码
class CodeBlockView {
  update(node: Node) {
    if (node.attrs.language === "mermaid") {
      // 不显示 CodeMirror,改为渲染 Mermaid 图表
      this.renderMermaid(node.textContent);
    } else {
      // 显示 CodeMirror 编辑器,按语言高亮
      this.cm.dispatch({ changes: ... });
      this.updateLanguage(node.attrs.language);
    }
  }
}

CodeMirror 和 ProseMirror 之间的光标同步是这里最棘手的问题:用户在代码块里按 Tab 时,光标应该在 CodeMirror 内移动,而不是跳到 ProseMirror 的下一个节点;按 Escape 或上下键到代码块边界时,才应该把光标控制权归还给 ProseMirror。

6.2 数学公式:KaTeX 即时渲染

nodeviews/math-block.ts 为块级数学公式($$...$$)创建一个实时渲染区域。用户编辑 LaTeX 源码时,预览区域同步更新。

行内数学公式($...$)则走的是 Decoration Widget 路线(见第四章),不需要 NodeView,由 Decoration 系统在对应位置插入一个渲染好的 span 元素。


七、Parser 与 Serializer:文档的进出口

7.1 Parser:如何处理嵌套语法

parser/index.ts 是整个项目最长的文件(超过 1200 行),承担将 Markdown 文本转换为 ProseMirror 文档树的职责。

解析分两个层次进行:

块级解析先按行识别结构,切分出标题、代码块、列表、表格、引用块等。这一层相对直观,大量依赖行首特征字符(#```>-| 等)。

行内解析更为复杂,需要在一段文字中同时处理多种可能嵌套的语法。Milkup 的策略是:对每一段文字跑多个正则,收集所有匹配位置,然后按出现位置排序,相同起点时长匹配优先,再逐段递归处理。

// 伪代码(简化自 parser/index.ts)
function parseInlineWithSyntax(text: string, schema: Schema): Node[] {
  const matches: InlineMatch[] = [];

  // 对每种行内语法类型运行正则
  for (const syntax of INLINE_SYNTAXES) {
    for (const match of text.matchAll(syntax.pattern)) {
      matches.push({ start: match.index, end: match.index + match[0].length, syntax, match });
    }
  }

  // 按起始位置排序,相同起点长匹配优先
  matches.sort((a, b) => a.start - b.start || b.end - a.end);

  // 逐段构建节点列表
  return buildNodes(text, matches, schema);
}

7.2 Serializer:如何从文档树还原 Markdown

serializer/index.ts 的核心策略是:直接读取文本节点的内容

由于 syntax_marker 文本节点里存的就是 ***` 等原始符号,序列化时按顺序拼接所有文本节点即可还原出原始的 Markdown。对于代码块、表格等结构复杂的块级节点,节点的 attrs 中存有完整的源码字符串,直接输出。

这个设计保证了往返稳定性(roundtrip stability):一段 Markdown 文本经过解析和序列化之后,应该得到完全相同的字符串(或者语义等价的字符串)。这是编辑器数据正确性的基本保证。


八、插件系统:把一切粘合在一起

Milkup 的功能通过 ProseMirror 插件系统组织。editor.ts 在初始化时按优先级顺序加载所有插件:

// src/core/editor.ts(节选,伪代码展示加载顺序)
const plugins = [
  // 优先级最高:快捷键
  searchKeymap(),           // Mod-f / Mod-h
  dynamicKeymap(config),    // 用户可自定义的快捷键
  blockEnterKeymap(schema), // 块级元素的 Enter 处理
  baseKeymap,               // ProseMirror 基础快捷键

  // 核心功能
  instantRenderPlugin(config),   // 即时渲染(Decoration 系统)
  inputRules(schema),            // 输入规则(如 --- → 分隔线)
  syntaxDetector(schema),        // 语法检测
  syntaxFixer(schema),           // 语法修复

  // 辅助功能
  pastePlugin(config.pasteConfig),
  mathBlockSync(),
  lineNumbers(),
  placeholder(config.placeholder),
];

插件顺序决定事务处理的优先级,尤其是快捷键插件必须在最前面,避免被其他插件拦截。

其中 syntax-detectorsyntax-fixer 两个插件值得一提:用户直接在渲染模式下输入 ** 时,系统需要识别出这是一个新的语法标记并打上 syntax_marker,同时维护标记对的匹配关系(开始标记和结束标记必须配对)。这两个插件就是专门干这件事的。


九、总结

回顾整个系统,即时渲染和源码模式切换这两个核心特性,都建立在同一个基础设计之上:语法符号是真实内容,显示是渲染层的事

这个思路拉通了整条链路:

  • Parser**粗体** 拆成三个文本节点,**syntax_marker 标记
  • Decoration 系统根据光标位置,决定 ** 是否应该被 font-size: 0 藏起来
  • Serializer 拼接文本节点内容,自然得到原始 Markdown
  • 源码模式切换时,只需改变 Decoration 系统的行为(不再隐藏任何标记)以及把块级节点展平为段落

每一层的职责都很清晰,修改其中任何一层都不需要动其他层。这大概就是这个设计最值得学习的地方:把"存什么"和"显示什么"彻底分开,前者由 Schema 保证正确,后者由 Decoration 按需计算。

十、你有能力支持 milkup 创作

如果你正在寻找一个 Typora 的免费替代品,或者你正在使用 milkup,别忘了给 milkup 一个小星星🌟。

其次,开发 milkup 花费了大量的 tokens 费用,但它本身不盈利,你可以扫描 readme 里面的二维码赞助我们。

另外,milkup core 我预计也将作为独立项目开源出来,以便于大家可以在浏览器端使用,再贴一次地址:

项目地址:github.com/Auto-Plugin…

官网地址:milkup.dev