Markdown实时分块渲染引擎
为啥要搞?
最近在写一个博客小网站,使用markdown作为编写语言。纯文本模式下,markdown预览效果实时渲染基本所有的流行markdown渲染库都能做到。但我打算在博客中加入类似LaTeX数学公式,甘特图,EChart图表等组件,这时候就发现传统的全局渲染延迟过大,特别是添加了图后,快速连续输入几个字符,整个预览界面就会出现卡顿,用户体验确实不好。于是花了几天魔改了一下markdown-it,重新实现了渲染逻辑。
相关代码可以在这里查看
具体流程
0. 准备工作
注册几个即将用到的渲染库,目前支持echart,mermaid,flowchart,katex库
// markdown-it
mdRender
// echart
echartRender
// mermaid
mermaidRender
// flowchart
flowchartRender
// katex
katexRender
1. 利用Markdown-it,分析生成抽象语法树(AST)
这一步利用Markdown-it的parse功能,分析markdown字符串。
AST上的每个节点都包含以下字段。很多字段我们都是用不到的,只需要关心attrs(生成的html表情的属性),children(该节点的子节点),content(节点内容),tag(html节点标签),nesting(层级),markup(标记符),type(节点类型)就好了
class AstNode {
attrs: any
block: any
children: any
content: any
hidden: any
info: any
level: any
map: any
markup: any
meta: any
nesting: any
tag: any
type: any
}
实现代码非常简单,一行写完
function parse (code) : Array {
return this.mdRender.parse(code, {})
}
// 使用
let ast : Array = this.parse(code)
2. 利用nesting字段,将解析得到的AST进行分块
这里实现比较简单,根据文章的大段落进行分段。nesting会根据解析得到的节点标签进行赋值,如果遇到开标签,如,nesting会在前一个节点的nesting值的基础上+1,反之,遇到闭标签,如,nesting会在前一个节点的nesting值的基础上-1。基于此,我们可以知道,当nesting的累和为0时,说明一个大段落结束,以此进行分块操作
let astBlockArray : Array<Array<AstNode>> = []
let nesting : number = 0
let blocks : Array<AstNode> = []
// ast分块
for (let i = 0; i < ast.length; i++) {
let astNode : AstNode = ast[i]
nesting += astNode.nesting
if (nesting > 0) {
blocks.push(astNode)
} else {
blocks.push(astNode)
astBlockArray.push(blocks)
blocks = []
}
}
3. 根据分块后的段落进行段落签名的计算
段落签名这块还没想好怎么生成比较好,目前是直接使用了渲染后的html字符串当做段落签名
// ast分块生成签名
// 签名
let signArray : Array<string> = []
// 渲染后的html
let codeArray : Array<string> = []
// 遍历段落
for (let i = 0; i < astBlockArray.length; i++) {
let block = astBlockArray[i]
let codeStrArr : Array<string> = []
let codeStr : string = ''
let parentTags : Array<Object> = []
// 根据节点属性渲染节点,得到渲染后的字符串
for (let node of block) {
// 渲染过程比较冗长,这里为了直观,使用renderNode进行替代
codeStrArr.push(renderNode(node))
}
codeStr = codeStrArr.join('')
// 生成的签名(直接使用html字符串)
signArray.push(codeStr)
// 生成的html
codeArray.push(codeStr)
}
4. 比较新旧节点的段落签名,确定发生变化的段落
let oriChangeNodes : Array<number> = []
let newChangeNodes : Array<number> = []
let newLen = signArray.length
let hisLen = this.historySignArray.length
if (hisLen === 0) {
oriChangeNodes = [0, -1]
newChangeNodes = newLen === 0? [0, 0] : [0, newLen - 1]
} else if (newLen === 0) {
oriChangeNodes = hisLen === 0? [0, 0] : [0, hisLen - 1]
newChangeNodes = [0, -1]
} else if (newLen !== hisLen) {
// 渲染块数量不一致,说明新建或移除了段落
let newFrontPtr : number = 0
let hisFrontPtr : number = 0
// 从前往后遍历新旧签名数组,找到第一个不同点的下标
while (newFrontPtr < newLen && hisFrontPtr < hisLen && signArray[newFrontPtr] === this.historySignArray[hisFrontPtr]) {
newFrontPtr++
hisFrontPtr++
}
// 移除前面找到的相同段落
let newArr = signArray.filter((item, index) => (index >= newFrontPtr))
let hisArr = this.historySignArray.filter((item, index) => (index >= hisFrontPtr))
// 从后往前遍历新旧签名数组,找到第一个不同点的下标
let newBackPtr : number = newArr.length - 1
let hisBackPtr : number = hisArr.length - 1
while (newBackPtr > 0 && hisBackPtr > 0 && newArr[newBackPtr] === hisArr[hisBackPtr]) {
newBackPtr--
hisBackPtr--
}
// 映射回原签名数组对应位置
newBackPtr += newFrontPtr
hisBackPtr += hisFrontPtr
// 将修改部分记录下来
oriChangeNodes = [hisFrontPtr, hisBackPtr]
newChangeNodes = [newFrontPtr, newBackPtr]
} else {
// 渲染块数量一致,说明段落内容出现变化
for (let i = 0; i < newLen; i++) {
if (signArray[i] !== this.historySignArray[i]) {
oriChangeNodes = [i, i]
newChangeNodes = [i, i]
break
}
}
}
// 没有找到变化位置,退出
if (!(oriChangeNodes.length > 0 && newChangeNodes.length > 0)) {
return
}
// changePos:第一个变化段落的下标,changeNum:后续发生变化的相邻段落的数量
let changePos = oriChangeNodes[0]
let changeNum = newChangeNodes[1] - oriChangeNodes[1]
5. 根据找到的变化段落,更新Dom节点
// 获取当前的渲染Dom节点列表
let blockDom = document.querySelectorAll(`.${this.CONTAINER_CLASS_NAME}`)
let container = document.getElementById(DomId)
// 将新的节点渲染在文档片段(DocumentFragment)中
let frag = document.createDocumentFragment()
for (let idx = 0; idx <= changeNum && (idx + changePos) < codeArray.length; idx ++) {
let b = document.createElement('div')
b.className = this.CONTAINER_CLASS_NAME
b.innerHTML = codeArray[idx + changePos]
this._renderNode(b)
frag.appendChild(b)
}
// 更新预览
if (blockDom.length === 0 || changePos >= blockDom.length) {
// 假如当前文档为空,或修改位置在文档末尾,直接添加
container.appendChild(frag)
} else {
if (changeNum < 0) {
// 假如段落数减少了
// 先将原有的节点删掉
for (let i = 0; i < -changeNum; i++) {
container.removeChild(blockDom[changePos + i])
}
blockDom = document.querySelectorAll(`.${this.CONTAINER_CLASS_NAME}`)
if (newChangeNodes[1] >= 0 && newChangeNodes[0] >= 0) {
// 直接修改旧节点的内容
for (let i = newChangeNodes[0]; i <= newChangeNodes[1]; i++) {
if (signArray[i] !== this.historySignArray[i]) {
blockDom[i].innerHTML = codeArray[i]
this._renderNode(blockDom[i])
}
}
} else {
// 假如原文档已被清空,直接插入新文档内容
container.insertBefore(frag, blockDom[newChangeNodes[1]])
}
} else {
// 段落数增加或无变化,直接将原节点覆盖
container.replaceChild(frag, blockDom[changePos])
}
this.historySignArray = signArray
}
以上就是整个渲染引擎的框架实现了。由于篇幅关系,某些具体的实现,例如节点的渲染,EChart等图表的渲染在这里就略过,有兴趣可以直接到GitHub上查看相应的代码