「我们一起造轮子🛞」不看源码带你搞懂markdown it的原理实现🤓🤓(Versakit团队的自娱自乐)

464 阅读6分钟

Hi! 这里是JustHappy,青训营项目也快接近尾声了,来看看我们获得了什么吧!我们的项目主要参考了markdown-it,所以话不多说,我们开始吧!

源码链接在这 github.com/Versakit/Ve… 仅供参考哈

我们的 markdown 是啥?

画板

如图这是我们一开始的架构图算是,算是参考了很多项目,比如说 markdown-it,所以这篇文章我希望也能帮助您窥探markdown-it的原理

总的来说我们的解决方案有一下 3 部分组成

1. Parser(解析器) : 负责解析原始的 markdown 语法,构建 AST(抽象语法树)
2. Renderer(渲染器) :负责基于 Parser 所构建的 AST 生成渲染产物也就是 DOM
3. 编辑器组件 :一个带有语法工具按钮的编辑器

Parser

有关有限状态机

有限状态机(FSM)是软件工程中用于建模系统行为的工具,通过有限状态集合和状态转换规则,描述系统在不同输入下的行为变化,常用于设计协议、控制流程等。

你肯定用过的 Promise

Promise 是有限状态机的原因在于它具有有限的状态集合和明确的状态转换规则。Promise 有三种状态:Pending(进行中)、Fulfilled(已完成)和 Rejected(已拒绝),初始状态为 Pending。状态转换由 resolve()reject() 触发,且状态一旦改变(从 Pending 转为 Fulfilled 或 Rejected),就不可逆。这种状态的有限性和转换的规则性符合有限状态机的定义。

显式状态机 or 隐式状态机?

显式状态机和隐式状态机的区别主要在于状态的表示方式和转换逻辑的明确程度:

显式状态机

显式状态机是指在代码中明确地定义状态和状态转换逻辑。通常会有一个状态变量(如 state)来标识当前状态,并通过条件语句(如 ifswitch)来处理状态转换。它的优点是状态和转换逻辑清晰可见,易于理解和维护。

示例

let state = "INIT";

function handleEvent(event) {
    switch (state) {
        case "INIT":
            if (event === "start") {
                state = "RUNNING";
            }
            break;
        case "RUNNING":
            if (event === "stop") {
                state = "STOPPED";
            }
            break;
        case "STOPPED":
            if (event === "start") {
                state = "RUNNING";
            }
            break;
    }
}

隐式状态机

隐式状态机没有明确的状态变量,状态通过代码的结构或逻辑隐含地表示。状态转换逻辑通常嵌入在代码中,而不是通过显式的状态变量来控制。它的优点是代码可能更简洁,但缺点是状态转换逻辑不够直观,难以维护。

示例

function handleEvent(event) {
    if (event === "start") {
        start();
    } else if (event === "stop") {
        stop();
    }
}

function start() {
    // 运行逻辑
}

function stop() {
    // 停止逻辑
}

总结

  • 显式状态机:有明确的状态变量(如 state),状态转换逻辑清晰。
  • 隐式状态机:没有明确的状态变量,状态通过代码逻辑隐含,代码可能更简洁但逻辑不够直观。

我们的 Parser

将原始的 Markdown 代码切割为一行一行的存放到一个数组中,之后遍历这个数组,在遍历的时候使用状态机进行判断

为什么使用隐式状态机?

  • 如果使用显式状态机,需要在每次状态流转的时候去判断状态,会有一定的开支
  • Markdown 的语法状态是相对来说比较简单且固定的,并且只存在单向的状态流转,故我们清楚的知道什么样的情况对应什么样的状态
  • 我们清楚的知道下一个状态是什么

什么时候需要使用显式状态机?

当我们在编码的时候不清楚接下来的状态,就需要对状态进行判断,还是拿 Promise 举例子,当一个 Promise 请求发出的时候我们是不清楚其回调是成功还是失败,我们这时候就要有个东西去携带这个状态,于是我们就在 Promise 中集成 state 去存储“Pending” 、“rejected”、“fulfilled” 这些.....,同时这个 state 是会流转下去的(链式调用),在流转过程中我们也不知道它未来会变成什么样子的,所以我们更加需要使用显式的状态器 state 去存储它的状态

我们是怎么进行状态判断的?

你可能会疑惑,我们连状态器都没有,怎么去判断 Markdown 语法当前的状态呢?

其实很简单,我们在 Parser 中固定了一个状态流转的流程,并结合正则表达式等方式进行相对来说高效的 AST 构建

上面提到了,我们将原始的 Markdown 代码切割为一行一行的存放到一个数组中,我们需要遍历这个数组进行判断解析,那么这一行一行的代码就存在一个初始的状态,我们给他记做 block(块),这一个个块就是我们需要操作的对象,回顾一下我们 markdown 的语法,无非就是这么几种

  • 单行语法:标题这类
  • 多行语法:表格这类
  • 行内语法:加粗这类

那是不是对应着一些显而易见的状态呢?

画板

这个过程其实和我们作为人类看文字的顺序很像是吧?哈哈给这种业务一个高大上的名字吧——编译原理🤓

Renderer

为什么要 Diff?

尽量减少 DOM 操作,减少没必要的开支

话又说回来?DOM 操作为嘛开支大?

主要是因为浏览器在执行 DOM 操作时,需要进行一系列复杂的底层处理,这些处理不仅涉及内存和计算资源的消耗,还可能触发浏览器的重新渲染和布局计算(回流和重绘)

  • 回流/重排(Reflow):当 DOM 的几何属性(如宽度、高度、位置)发生变化时,浏览器需要重新计算页面布局。重排是一个非常耗时的操作,因为它可能影响多个元素。
  • 重绘(Repaint):当 DOM 的样式(如颜色、背景)发生变化时,浏览器需要重新绘制受影响的区域。虽然重绘的开销通常小于重排,但如果频繁触发,也会显著影响性能。

Diff 算法的实现

我们的 diff 算法实现主要参照的是 Vue2 的双端 Diff 算法,但是是有些许的精简和修改的

为什么使用类似 Vue2 的双端 Diff,而不是类似 Vue3 快速 Diff 呢?

简单易懂:双端 Diff 算法逻辑相对直观

性能在简单场景下表现良好:在节点数量较少且结构简单的情况下,双端 Diff 算法的性能表现与 Vue 3 相差不大,甚至在某些情况下可能更优

简化构建流程,直接基于 AST 进行 diff

与 Vue 不同的是我们的 Renderer 只需要实现对 markdown 这种单一业务的支持就 OK,所以我们不需要基于 AST 去构建 VDomTree,我们可以直接基于 AST 进行 diff,这简化了我们 Renderer 的实现,也进一步提升了渲染性能