实现一个简单的markdown双视图编辑器

目标与实现

目标

1.实现markdown语句的实时预览

2.实现markdown视图与预览视图的精准同步滚动

实现

1.支持markdown的编辑区使用了 CodeMirror

2.markdown的实时预览使用了unified (也简单写了markdown-it的实现方法)

3.语法树介绍

AST(Abstract Syntax Tree,抽象语法树)是源代码语法结构的抽象表示,它以树状形式展现代码的语法结构,每个节点代表代码中的一种语法结构(如关键字、表达式、语句等)。AST 忽略了源代码中的无关细节(如空格、注释),仅保留语法层面的核心结构,是编译器、解释器、代码分析工具等的重要中间表示形式。

HTML 语法树(HTML AST)

HTML 语法树是 AST 的一种具体形式,专门用于表示 HTML 文档的语法结构。当浏览器或解析器处理 HTML 字符串时,会先将其解析为语法树,再基于此构建 DOM(文档对象模型)。

核心特点:

  1. 层级结构:对应 HTML 的嵌套关系,根节点通常是 <html>,子节点为 <head><body>,再往下是各种元素(如 <div><p>)、文本、属性等,形成树状层级。
    例如,对于以下 HTML 片段:

    1
    2
    3
    <div class="container">
    <p>Hello, AST!</p>
    </div>

    其语法树结构大致为:

    • 根节点(虚拟)→ <div> 节点(属性:class="container")→ <p> 节点 → 文本节点(内容:Hello, AST!)。
  2. 节点类型:包含多种节点类型,常见的有:

    • 元素节点(Element Node):对应 HTML 标签(如 <div><a>),包含标签名、属性列表、子节点等信息。
    • 文本节点(Text Node):对应标签内的文本内容(如上述例子中的 Hello, AST!)。
    • 属性节点(Attribute Node):存储元素的属性(如 classid 及其值)。
    • 注释节点(Comment Node):对应 HTML 中的注释(<!-- 注释内容 -->)。

markdown语句的实时预览

方案一:markdown-it库实现

markdown-it用于将markdown语句转换为HTML格式

具体使用见

[markdown-it | markdown-it 中文文档]:

[【新人必备】Markdown神器——markdown-it-CSDN博客]:

由于这里并没有用markdown-it库实现 所以这里只贴出来预览代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<script setup>
import { ref, onMounted } from 'vue'
import MarkdownIt from 'markdown-it'

const articleContent = ref('')
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true
})

// 实时预览功能
function updatePreview() {
const previewRender = document.querySelector('#preview-render')
const content = articleContent.value
if (content.trim() === '') {
previewRender.innerHTML = '<p class="preview-placeholder text-gray-500 italic">预览内容将显示在这里...</p>'
} else {
// 使用 markdown-it 库转换 Markdown
try {
const html = md.render(content)
previewRender.innerHTML = html
} catch (error) {
previewRender.innerHTML = '<p class="text-red-500">Markdown 解析错误: ' + error.message + '</p>'
}
}
}

onMounted(() => {
// 初始化预览区域
updatePreview()
})
</script>

方案二: unified实现

unified简介

unified是一个通过使用语法树来进行解析、检查、转换和序列化文本内容的接口,可以处理MarkdownHTML和自然语言。它是一个库,作为一个独立的执行接口,负责执行器的角色,调用其生态上相关的插件完成具体任务。同时unified也代表一个生态,要完成前面说的文本处理任务需要配合其生态下的各种插件,截止到目前,它生态中的插件已经有三百多个!鉴于数量实在太多,很容易迷失在它庞大的生态里,可谓是劝退生态。

unified主要有四个生态:remarkrehyperetextredot,这四个生态下又有各自的生态,此外还包括处理语法树的一些工具、其他构建相关的工具。

unified的执行流程说出来我们应该都比较熟悉,分为三个阶段:

1.Parse

将输入解析成语法树,mdast负责定义规范,remarkrehype等处理器否则创建。

2.Transform

上一步生成的语法树会被传递给各种插件,进行修改、检查、转换等工作。

3.Stringify

这一步会将处理后的语法树再重新生成文本内容。

unified的独特之处在于允许一个处理流程中进行不同格式之间的转换,所以能满足我们本文的需求,也就是将Markdown语法转换成HTML语法,我们会用到其生态中的remark(解析Markdown)、rehype(解析HTml)。

具体来说就是使用remark生态下的remark-parse插件来将输入的Markdown文本转换成Markdown语法树,然后使用remark-rehype桥接插件来将Markdown语法树转换成HTML语法树,最后使用rehype-stringify插件来将HTML语法树生成HTML字符串。

使用unified

这里使用unified的主要原因 一是因为它基于AST,二是因为它是管道化,在不同插件之间流转的是AST树 管道化的特性有利于在后面实现精准滚动同步

使用unified的主要原理是 通过unified将markdown语句转换成语法树 再进一步转换成html语法树 最后最换成html字符串 插入预览区节点进行渲染

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 实时预览
function updatePreview(instance) {
unified()
.use(remarkParse) // 将markdown转换成语法树

.use(remarkRehype) // 将markdown语法树转换成html语法树,转换之后就可以使用rehype相关的插件

.use(rehypeStringify) // 将html语法树转换成html字符串

.process(instance.doc.getValue())// 输入编辑器的文本内容
.then(
(file) => {
// 将转换后得到的html插入到预览区节点内
htmlStr.value = String(file);
},
(error) => {
throw error;
}
);
}
1
2
3
4
5
6
7
//预览区
<div class="previewArea"
ref="previewArea"
v-html="htmlStr"
@scroll="onPreviewScroll"
@mouseenter="currentScrollArea = 'preview'">
</div>

编辑器和预览区的精准同步滚动

基本原理

实现同步滚动的主要难点在于markdown语句和最终语句上存在高度差 所以需要手动地去计算预览区的滚动高度

这里使用的方案是 通过定位节点的方式来保证滚动是同步的

实现精确同步滚动的核心在于我们要能把编辑区域和预览区域两边的“节点”对应上,比如当编辑区域滚动到了一个一级标题处,我们要能知道在预览区域这个一级标题节点所在的位置,反之亦然。

预览区域的节点我们很容易获取到,因为就是普通的DOM节点,关键在于编辑区域的节点,编辑区域的节点是CodeMirror生成的,显然无法和预览区域的节点对应上,此时,unified不同于其他MarkdownHTML开源库(比如markdown-itmarkedshowdown)的优点就显示出来了,一是因为它基于AST,二是因为它是管道化,在不同插件之间流转的是AST树,所以我们可以写个插件来获取到这个语法树数据,另外预览区域的HTML是基于remark-rehype插件输出的HTML语法树生成的,所以这个HTML语法树显然是可以和预览区域的实际节点对应上的,这样,只要我们把自定义的插件插入到remark-rehype之后即可获取到HTML语法树数据:

通过输出我们可以发现 节点树的节点和DOM的节点是一一对应的

当然仅仅对应还不够,我们还需要获得高度信息来控制滚动,DOM节点能通过DOM相关属性获取到它的高度信息,语法树的某个节点我们也需要能获取到它在编辑器中的高度信息,这个能实现依赖两点

一是语法树提供了某个节点的定位信息:

图片名称

二是CodeMirror提供了获取某一行高度的接口:

图片名称

所以我们能通过某个节点的起始行获取该节点在CodeMirror文档里的高度信息,

返回的高度是这一行的底部到文档顶部的距离,所以要获取某行顶部所在高度相当于获取上一行底部所在高度,所以将行数减1

1
let offsetTop = editor.heightAtLine(child.position.start.line - 1 , "local");

节点同步

编辑区和预览区都能获取到节点的所在高度之后,接下来我们就可以这样做,当在编辑区域触发滚动后,先计算出两个区域的所有元素的所在高度信息,然后再获取编辑区域当前的滚动距离,求出当前具体滚动到了哪个节点内,因为两边的节点是一一对应的,所以可以求出预览区域对应节点的所在高度,最后让预览区域滚动到这个高度即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 新增两个变量保存节点的位置信息
let editorElementList = [];
let previewElementList = [];

const computedPosition = () => {
// 获取预览区域容器节点下的所有子节点
let previewChildNodes = previewArea.value.childNodes;
// 清空数组
editorElementList = [];
previewElementList = [];
// 遍历所有子节点
treeData.children.forEach((child, index) => {
if (child.type !== "element") {
return;
}
let offsetTop = editor.heightAtLine(child.position.start.line - 1, "local");
// 保存两边节点的位置信息
editorElementList.push(offsetTop);
previewElementList.push(previewChildNodes[index].offsetTop); // 预览区域的容器节点previewArea需要设置定位
});
};

const onEditorScroll = () => {
computedPosition();
// 获取编辑器滚动信息
let editorScrollInfo = editor.getScrollInfo();
// 找出当前滚动到的节点的索引
let scrollElementIndex = null;
for (let i = 0; i < editorElementList.length; i++) {
if (editorScrollInfo.top < editorElementList[i]) {
// 当前节点的offsetTop大于滚动的距离,相当于当前滚动到了前一个节点内
scrollElementIndex = i - 1;
break;
}
}
if (scrollElementIndex >= 0) {
// 设置预览区域的滚动距离为对应节点的offsetTop
previewArea.value.scrollTop = previewElementList[scrollElementIndex];
}
};

节点内同步

由于刚刚我们是通过两边节点同步的 并没有实现节点内滚动的逻辑

这里我们可以这么计算:编辑区域当前的滚动距离是已知的,当前滚动到的节点的顶部距离文档顶部的距离也是已知的,那么它们的差值就可以计算出来,然后使用下一个节点的offsetTop值减去当前节点的offsetTop值可以计算出当前节点的高度,那么这个差值和节点高度的比值也就可以计算出来:

图片名称

对于预览区域的对应节点来说也是一样,它们的比值应该是相等的,所以等式如下:

1
2
3
4
5
6
(editorScrollInfo.top - editorElementList[scrollElementIndex]) /
(editorElementList[scrollElementIndex + 1] -
editorElementList[scrollElementIndex])
=
(previewArea.value.scrollTop - previewElementList[scrollElementIndex]) / (previewElementList[scrollElementIndex + 1] -
previewElementList[scrollElementIndex])

根据这个等式计算出previewArea.value.scrollTop的值即可,最终代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const onEditorScroll = () => {
computedPosition();
let editorScrollInfo = editor.getScrollInfo();
let scrollElementIndex = null;
for (let i = 0; i < editorElementList.length; i++) {
if (editorScrollInfo.top < editorElementList[i]) {
scrollElementIndex = i - 1;
break;
}
}
if (scrollElementIndex >= 0) {
// 编辑区域滚动距离和当前滚动到的节点的offsetTop的差值与当前节点高度的比值
let ratio =
(editorScrollInfo.top - editorElementList[scrollElementIndex]) /
(editorElementList[scrollElementIndex + 1] -
editorElementList[scrollElementIndex]);
// 根据比值相等计算出预览区域应该滚动到的位置
previewArea.value.scrollTop =
ratio *
(previewElementList[scrollElementIndex + 1] -
previewElementList[scrollElementIndex]) +
previewElementList[scrollElementIndex];
}
};

同时滚动到底部

同步滚动已经基本上很精确了,不过还有个小问题,就是当编辑区域已经滚动到底了,而预览区域没有:

需要让当一边滚动到底时我们让另一边也到底

这里只需要简单地对滚动到底部进行一个判断即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const onEditorScroll = () => {
computedPosition();
let editorScrollInfo = editor.getScrollInfo();
let scrollElementIndex = null;
// ...
// 编辑区域已经滚动到底部,那么预览区域也直接滚动到底部
if (
editorScrollInfo.top >=
editorScrollInfo.height - editorScrollInfo.clientHeight
) {
previewArea.value.scrollTop =
previewArea.value.scrollHeight - previewArea.value.clientHeight;
return;
}
if (scrollElementIndex >= 0) {
// ...
}
}

预览区滚动时编辑区同步滚动

最后让我们来完善一下在预览区域触发滚动,编辑区域跟随滚动的逻辑,监听一下预览区域的滚动事件:

1
<div class="previewArea" ref="previewArea" v-html="htmlStr" @scroll="onPreviewScroll"></div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const onPreviewScroll = () => {
computedPosition();
let previewScrollTop = previewArea.value.scrollTop;
// 找出当前滚动到元素索引
let scrollElementIndex = null;
for (let i = 0; i < previewElementList.length; i++) {
if (previewScrollTop < previewElementList[i]) {
scrollElementIndex = i - 1;
break;
}
}
// 已经滚动到底部
if (
previewScrollTop >=
previewArea.value.scrollHeight - previewArea.value.clientHeight
) {
let editorScrollInfo = editor.getScrollInfo();
editor.scrollTo(0, editorScrollInfo.height - editorScrollInfo.clientHeight);
return;
}
if (scrollElementIndex >= 0) {
let ratio =
(previewScrollTop - previewElementList[scrollElementIndex]) /
(previewElementList[scrollElementIndex + 1] -
previewElementList[scrollElementIndex]);
let editorScrollTop =
ratio *
(editorElementList[scrollElementIndex + 1] -
editorElementList[scrollElementIndex]) +
editorElementList[scrollElementIndex];
editor.scrollTo(0, editorScrollTop);
}
};

无限滚动问题

因为两边都绑定了滚动事件,所以互相触发跟随滚动,导致死循环,解决方式也很简单,我们设置一个变量来记录当前我们是在哪边触发滚动,另一边就不执行回调逻辑,进行一个简单的判断即可:

1
2
3
4
5
6
7
8
9
10
11
12
<div
class="editorArea"
ref="editorArea"
@mouseenter="currentScrollArea = 'editor'"
></div>
<div
class="previewArea"
ref="previewArea"
v-html="htmlStr"
@scroll="onPreviewScroll"
@mouseenter="currentScrollArea = 'preview'"
></div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

let currentScrollArea = ref("");

const onEditorScroll = () => {
if (currentScrollArea.value !== "editor") {
return;
}
// ...
}

// 预览区域的滚动事件
const onPreviewScroll = () => {
if (currentScrollArea.value !== "preview") {
return;
}
// ...
}

总结

学习到了

AST 与语法树应用
◦ 理解「语法树是文本与 DOM / 编辑器之间的桥梁」:
◦ Markdown 转 HTML 时,先解析为 Markdown AST(结构化表示语法),再转换为 HTML AST,最终渲染为 DOM;
◦ 利用 AST 的「节点层级、位置信息」,实现编辑器与预览区的节点映射(如标题、段落的一一对应),为同步滚动奠基。

对同步滚动的精度优化也是一个非常有趣的过程