起因

最近在写一个博客小网站,使用markdown作为编写语言。纯文本模式下,markdown预览效果实时渲染基本所有的流行markdown渲染库都能做到。但我打算在博客中加入类似LaTeX数学公式,甘特图,EChart图表等组件,这时候就发现传统的全局渲染延迟过大,特别是添加了图后,快速连续输入几个字符,整个预览界面就会出现卡顿,用户体验确实不好。于是花了几天魔改了一下markdown-it,重新实现了渲染逻辑。

相关代码可以在这里查看,也欢迎来我的博客网站rgan.work上体验体验(注册账号啥的随便填)。虽然小破站目前挺简陋的,但起码它能动…

具体流程

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
}

实现代码非常简单,一行写完

// 定义
parse (code) : Array<AstNode> {
  return this.mdRender.parse(code, {})
}
// 使用
let ast : Array<AstNode> = this.parse(code)
2、利用nesting字段,将解析得到的AST进行分块

这里实现比较简单,根据文章的大段落进行分段。nesting会根据解析得到的节点标签进行赋值,如果遇到开标签,如<p>,nesting会在前一个节点的nesting值的基础上+1,反之,遇到闭标签,如</p>,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上查看相应的代码。这个东西近期打算重新编写成一个独立的库,欢迎各位大佬围观,如果能顺手star一下那就更好了(雾)。也欢迎大家关注一下我的小破站摸鱼好地方,提个issue啥的,共建一个好用的技术资源分享社区