用 Rust 重写 MDX 编译

Rust
Home

@mdxor/compiler 是一个用 Rust 编写、高速并且零依赖的 MDX 解析器。 我刚刚完成它的绝大部分功能,这里分享一下它的实际效果和实现原理

Benchmark

为了测试其在前端领域的性能,我不仅将 @mdxor/compiler 通过 Napi-rs 封装成 Node.js native addon, 还把 @mdxor/compiler 编译成 wasm,以分别测试其在 Node.js 和浏览器上的速度

以下是测试结果(仅供参考,Benchmark 代码):

SSH
MacBook Pro (13-inch, 2017)8 GB
Mdxor Napi           x 15,963 ops/sec ±1.16% (91 runs sampled)
Official Mdx Node.js x 225 ops/sec ±5.25% (83 runs sampled)
Mdxor Wasm           x 8,181 ops/sec ±7.06% (46 runs sampled)
Official Mdx Browser x 125 ops/sec ±23.35% (44 runs sampled)

不论是从文字表述还是可视化图表,都可以看出在该项测试中 @mdxor/compiler 的速度大幅领先官方的 @mdx-js/mdx。 其中,Node.js 上的速度约为官方的 70 倍,浏览器上则约为 65 倍

实现原理

MDX 介绍

MDX 是一个结合了 Markdown 和 JSX 的文档格式。 它在 Markdown 简洁的标准文档格式基础上,结合 JSX 的灵活 & 组件化,让文档内容更加丰富

以下是一个简单的 MDX 文档:

JSX
import Chart from './chart'
 
# Title
This is a chart
<Chart />

可以看出,MDX 主要由头部的 importexport、GitHub Flavored Markdown 以及穿插在其中的 JSX 组成。 接下来的篇幅我会解释 @mdxor/compiler 如何解析一个 MDX 文档

JSX/JS 部分

对于头部的 import/export(JS) 和文档中的 JSX,我写了一套简单的 JS/JSX 解析器,用以验证语法的正确性。 如果不符合 JS/JSX 规范,会将它以文本的格式输出

官方 @mdx-js/mdx 使用 acornjs 解析 JSX/JS,所以支持所有 JS 语法。 但是 MDX 只有头部 import/export 和 JSX 标签中会存在 JSX/JS 语法,需要的特性有限。 针对 MDX 中 JS/JSX 的语法特性,@mdxor/compiler选择性地支持一些常用的 JS/JSX 语法,如下:

语法例子
基础数据类型true, null, undefined, 100, "str"
引用数据类型[], {}, {a:[true, {b:"str"}]}
变量成员a.b, a[b], a['b']
函数调用a(), a(1, true, "s")
三元运算a ? b : c
数学运算a + b, a += 1
逻辑运算a && b, !!a, a == b
exportexport const a = 10, export React from "react"
importimport React, {useState} from "react"
jsx<Comp1 props={["true"]}><Comp2 /></Comp1>
模版字符串`12`, `12${a}`

这些语法均可相互嵌套,比如以下:

JSX
export const a = { b: ['str'], c: <div test={true}></div> }

一开始,我准备直接调用 SWC 做这部分工作,但由于它的 Rust 库并没有暴露单独解析 import/export 的方法,最后我决定手写这部分代码。 虽然目前支持的语法不算全面,但经过测试,这部分不太影响解析速度,并且自有词法分析、语法分析,易拓展,后续可以再逐渐补全

词法分析

词法分析是将语句转为单词(Token)的过程,在 @mdxor/compiler 中定义了几种 Token 类型:

RUST
pub enum JSToken {
  Keyword(Span), // 关键字
  Punctuator(Span), // 符号
  String(Span), // 字符串
  Template(Span), // 模版字符串
  Identifier(Span), // 标识符
  Number(Span), // 数字
  Text(Span), // JSX 中的文本
}

export let a = { b: true } 这段语句最后会被转化为 export 关键字, let 关键字, a 标识符, = 符号, { 符号, b 标识符, : 符号, true 关键字, } 符号

语法分析

语法分析是在词法分析的基础上将单词序列组合成合法的语句

比如 export 关键字 后跟 let 关键字,程序会继续执行,其后又跟了一个 var 关键字,语法分析就会失败

Markdown 部分

Markdown 的解析主要遵从 GitHub Flavored Markdown 提到的解析策略

Markdown 由 Block 块(标题、段落、表格等) 和 Inline 内联(强调、链接等) 组成

Block

Block 又分为两种,一种是可以包含其他 Block 的,称之为 Container Block,另一种则不能包含其他 Block,称之为 Leaf Block

由于 Container Block 可以不停地嵌套,在解析时,需要重复扫描 Block,直至 Block 为 Leaf Blcok,再读取该 Leaf Block 的内联内容(这里不对内联解析)

rust-mdx1

这里还有一个难点,即是处理 Container Block 的连续性:

MD
- 123
  # title

在以上 Markdown 中,# title 是隶属于 List 的 Item 的。但 Markdown 是空格敏感的,如果删除了# title前面的空格,# title将直接隶属于文档的根元素,所以我们在解析新一行的时候,需要先判断出它的父元素

因为由于链接引用的存在,所以会存在 Inline 链接 引用的是之后出现的链接定义(可以类比为 JS 中的变量提升)。所以解析完整个文档的 Block 后,我们才解析各 Leaf Block 的内联元素

Inline

内联部分的解析比较简单,需要注意的是各内联元素的优先级

MD
*a`a*a`

在上面这段 Markdown,即使是 *(强调) 符号最先开始,并且也最先闭合,但仍是将 a*a 解析为内联代码,*a 解析为文本

最后

截止目前, @mdxor/compiler 仍需要补全一些功能并进行完备的测试,还无法在生产环境使用,所以暂未发布在 npm 上。 可以移步至 @mdxor/compiler 仓库 查看它的最新动态