用有限状态机实现一个简版的html解析器

简介: 理解了状态机就如给你按上了一双翅膀,不管给你任何一段字符任容,都可以通过状态机来拆分成我们想要的结构,理解了上面这些再去看 vue 里的模板编译,你就能知道它到底是怎么加进去那些语法糖的了

FSM(Finite State Machines) 有限状态机,也叫有限状态自动机,是为研究有限内存的计算过程和某些语言类而抽象出的一种计算模型,它拥有有限个数量的状态,每个状态可以迁移到零个或多个状态,输入字串决定执行哪个状态的迁移。

有限状态机有什么用

代码编译器在工作时就需要通过词法分析、语法分析、语义分析来得到 AST(Abtract Syntaxt Tree) 抽象语法树。需要先词法分析拿到的所有 token 流,接着通过语法分析将 token 流进行文法校验生成语法解析树,这个过程一般有两种:

  • 边分词边生成 AST,像解析 HTML、CSS
  • 先分词生成所有 token,再来进行语法分析生成 AST,像 js

我们在前端工作中经常用到的:babel、typescript、eslint、postcss、prettier、uniapp、htmlparse、代码编辑器的语法高亮...这些其实都是基于 AST 抽象语法树来实现的,而为了得到 AST 我们需要先进行分词,而分词一个比较好的方式就是通过有限状态机来实现。

代码的本质就是字符串,分词就是把代码字符串变成一个个最小单元到不能再拆分的单词,也叫 token(注意不是咱们平时用到的登录态 token),分词器的英文 tokenizer。代码其实跟我们一篇英文文章、一首中文古诗、一个数学运算...都是一样的,我们一样可以用分词技术来拆分这些元素。

有限状态机是怎么工作的

为了理解有限状态机到底是怎么工作的,我们先来实现一个简单的减法运算分词。要求用状态机把 500-250=250 这个减法运算分词成一个数组,首先定义一共有2种状态:number-数字、operator-运算符,每一个最小的 token 只能是这两个当中的一个,代码如下

// 500-250=250
// [
//   { type: 'number', value: '500' },
//   { type: 'operator', value: '-' },
//   { type: 'number', value: '250' },
//   { type: 'operator', value: '=' },
//   { type: 'number', value: '250' }
// ]

function mathTokenizer(text) {
   
  // 匹配数字正则
  const numberReg = /[0-9]/
  // 匹配运算符正则
  const operatorReg = /[-=]/
  // 存储所有读取到的 token 数组
  let tokens = []
  // 当前正在读取的 token 信息
  let currentToken = {
   }

  // 初始状态
  function init(e) {
   
    if (numberReg.test(e)) {
   
      currentToken = {
    type: 'number', value: e }
      return onNumber
    } else if (operatorReg.test(e)) {
   
      currentToken = {
    type: 'operator', value: e }
      return onOperator
    }
  }

  // 读取到数字
  function onNumber(e) {
   
    if (numberReg.test(e)) {
   
      currentToken.value += e
      return onNumber
    }
    if (operatorReg.test(e)) {
   
      pushToken(currentToken)
      currentToken = {
    type: 'operator', value: e }
      return onOperator
    }
  }

  // 读取到运算符
  function onOperator(e) {
   
    if (numberReg.test(e)) {
   
      pushToken(currentToken)
      currentToken = {
    type: 'number', value: e }
      return onNumber
    }
    if (operatorReg.test(e)) {
   
      pushToken(currentToken)
      currentToken = {
    type: 'operator', value: e }
      return onOperator
    }
  }

  // 每次读取到完整的一个 token 后存入到数组中
  function pushToken(token) {
   
    tokens.push(token)
    currentToken = {
   }
  }

  // 遍历读取数组
  function parse(str) {
   
    const len = str.length
    let stateMachine = init
    for (let i = 0; i < len; i++) {
   
      const char = str[i]
      stateMachine = stateMachine(char)

      // 最后一个字符匹配完了要自己 pushToken
      if (i === len - 1) {
   
        pushToken(currentToken)
      }
    }

    return tokens
  }

  return parse(text)
}

const arr = mathTokenizer('500-250=250')
console.log(arr)

简版的 html 解析器

词法分析,生成 token 流

利用状态机来生成 token 流,为了方便理解以下示例不考虑标签属性节点、自闭合标签和一些异常情况。

我们先定义5个状态:标签开始、结束标签开始、标签名、标签结束、文本,每次读取到的内容会在这5个状态之间切换,每次读取时只要不是标签开始、结束标签开始、标签名、标签结束这4个状态的我们都当成文本来处理。

实际上我们只需要存储:开始标签、文本、结束标签这3个状态,所以定义的节点 type 分别为:startTag、text、endTag。你要按前面定义的5个状态来储存其实也是可以的,在下面生成 AST 直接忽略掉我们不需要的标签开始、标签结束这些状态信息就行了,只不过这里我们直接在分词这一步提前就给过滤了。

这里我们可以把状态机理解成一个函数,每遍历到一个字符我们都将这个字符传到函数中,而函数中可以根据这个字符来判断下一个状态是什么,再返回出去下一个状态函数就行了。

function htmlTokenizer(str){
   
  // 标签开始
  const tagStartReg = /</
  // 结束标签开始
  const closeTagReg = /\//
  // 标签结束
  const tagEndReg = />/
  // 标签名
  const tagNameReg = /[a-zA-Z]/

    let tokens = []
    let currentToken = {
   }

  // 初始状态
  function init(e) {
   
    if (tagStartReg.test(e)) {
   
      currentToken = {
    type: 'startTag', tagName: '' }
            return init
        }
    if (closeTagReg.test(e)) {
   
      currentToken = {
    type: 'endTag', tagName: '' }
            return onTagName
        }
    if (tagNameReg.test(e)) {
   
      currentToken.tagName += e
            return onTagName
        }

    // 不是上面3个状态的都当成文本节点处理
    currentToken = {
    type: 'text', text: e }
    return onText
  }

  // 读取到标签名
  function onTagName(e) {
   
    if (tagEndReg.test(e)) {
   
      pushToken(currentToken)
            return init
        } else {
   
      currentToken.tagName = (currentToken.tagName || '') + e
            return onTagName
        }
  }

  // 读取到文本
  function onText(e) {
   
    if (tagStartReg.test(e)) {
   
      pushToken(currentToken)
      currentToken = {
    type: 'startTag', tagName: '' }
            return init
        } else {
   
      currentToken.text = (currentToken.text || '') + e
            return onText
    }
  }

  // 每次读取到完整的一个 token 后存入到数组中
    function pushToken(e) {
   
        tokens.push(e)
        currentToken = {
   }
    }

  // 遍历读取数组
  function parse(chars){
   
    let stateMachine = init
        for (const char of chars) {
   
            stateMachine = stateMachine(char)
        }
        return tokens
    }

  return parse(str)
}

const tokenList = htmlTokenizer('<div>静夜思<p>锄禾日当午</p>周小黑<p>粒粒皆辛苦</p>公元一七八八年</div>')
console.log(tokenList)

语法分析,生成 AST 抽象语法树

这一步主要就怎么能把分词得到的数组转换成树形 tree 数据结构,日常开发中我们 array 转 tree 一般都是需要根据父亲 id 之类的来实现遍历生成,但是这里咱拿到的数组是没有这个父 id 的,那要怎么实现呢?

先观察数据结构,虽然是一个数组,但是这个数组其实是个类似中心对称结构的,我们暂时先忽略掉数组里的 type 为 text 的文本内容(因为这个其实我们是不能把它当成一个父节点的,它只能是某个标签的子节点),过滤掉文本后数组第1个元素和最后1个元素正好是1对,第2个元素和倒数第2个元素又是1对,我们要实现的就是把内层获取到的一对对标签不断挂载到它前面一对签的 children 属性上来实现 tree 结构。

那我们可以从数组第一项目开始遍历,然后用一个数组来模拟 stack 栈存每次遍历到的标签信息(栈的特点是先进后出,类似我们往一个桶里放东西,放在最上面的可以最先拿出来,规定数组只能使用 push 和 pop 就能模拟栈了)。

当遇到开始标签的时候就说明遇到一个新的标签了,这时就往栈里 push 进去,当遇到结束标签时就说明当前这个标签的所有信息都已经读取处理完了,那我们就可以将它从栈里弹出来,然后现在栈里最上面的一个元素其实就是当前弹出来的父标签了,直接挂载到 children 上就行了。整个过程其实主要就是理解下面2点:

  • 用栈来缓存节点:嵌套在内部的节点就可以先出栈,根节点最后出栈
  • 用引用类型对象的特点,来不断挂载节点
function htmlAst(tokenList) {
   
    let stack = []

  for (let i = 0; i < tokenList.length; i++) {
   
    const node = tokenList[i]

    // 开始标签:入栈
        if (node.type === 'startTag'){
   
            stack.push(node)
        }

    // 结束标签:出栈
        if (node.type === 'endTag') {
   
            const currentNode = stack.pop()
            const parent = stack[stack.length - 1]

            if (parent) {
   
                if (!parent.children) parent.children = []
        parent.children.push(currentNode)
            } else {
   
        const root = {
    type: 'document', children: [currentNode] }
                return root
            }
        }

    // 文本:加到父标签的 children 上
        if (node.type === 'text') {
   
            const parent = stack[stack.length - 1]
      if (!parent.children) parent.children = []
      parent.children.push(node)
        }
  }
}

然后就能拿到我们需要的 AST 语法树了,结构如下:

{
   
  "type": "document",
  "children": [
    {
   
      "type": "startTag",
      "tagName": "div",
      "children": [
        {
   
          "type": "text",
          "text": "静夜思"
        },
        {
   
          "type": "startTag",
          "tagName": "p",
          "children": [
            {
   
              "type": "text",
              "text": "锄禾日当午"
            }
          ]
        },
        {
   
          "type": "text",
          "text": "周小黑"
        },
        {
   
          "type": "startTag",
          "tagName": "p",
          "children": [
            {
   
              "type": "text",
              "text": "粒粒皆辛苦"
            }
          ]
        },
        {
   
          "type": "text",
          "text": "公元一七八八年"
        }
      ]
    }
  ]
}

理解了状态机就如给你按上了一双翅膀,不管给你任何一段字符任容,都可以通过状态机来拆分成我们想要的结构,理解了上面这些再去看 vue 里的模板编译,你就能知道它到底是怎么加进去那些语法糖的了。

还比如小程序中的富文本解析,特定平台的小程序实际上是不能识别浏览器里的 html 的,那我们就需要先将 html 通过状态机转成 AST,然后再按照小程序的语法来进行特定的转换。

目录
相关文章
|
22天前
|
XML 数据采集 数据格式
Python 爬虫必备杀器,xpath 解析 HTML
【11月更文挑战第17天】XPath 是一种用于在 XML 和 HTML 文档中定位节点的语言,通过路径表达式选取节点或节点集。它不仅适用于 XML,也广泛应用于 HTML 解析。基本语法包括标签名、属性、层级关系等的选择,如 `//p` 选择所有段落标签,`//a[@href=&#39;example.com&#39;]` 选择特定链接。在 Python 中,常用 lxml 库结合 XPath 进行网页数据抓取,支持高效解析与复杂信息提取。高级技巧涵盖轴的使用和函数应用,如 `contains()` 用于模糊匹配。
|
1月前
|
XML JavaScript 前端开发
如何解析一个 HTML 文本
【10月更文挑战第23天】在实际应用中,根据具体的需求和场景,我们可以灵活选择解析方法,并结合其他相关技术来实现高效、准确的 HTML 解析。随着网页技术的不断发展,解析 HTML 文本的方法也在不断更新和完善,
|
1月前
|
JavaScript API 开发工具
<大厂实战场景> ~ Flutter&鸿蒙next 解析后端返回的 HTML 数据详解
本文介绍了如何在 Flutter 中解析后端返回的 HTML 数据。首先解释了 HTML 解析的概念,然后详细介绍了使用 `http` 和 `html` 库的步骤,包括添加依赖、获取 HTML 数据、解析 HTML 内容和在 Flutter UI 中显示解析结果。通过具体的代码示例,展示了如何从 URL 获取 HTML 并提取特定信息,如链接列表。希望本文能帮助你在 Flutter 应用中更好地处理 HTML 数据。
115 1
|
2月前
|
XML 数据格式
HTML 实例解析
本文介绍了HTML中常见元素的使用方法,包括`&lt;p&gt;`、`&lt;body&gt;`和`&lt;html&gt;`等。详细解析了这些元素的结构和作用,并强调了正确使用结束标签的重要性。此外,还提到了空元素的使用及大小写标签的规范。
|
2月前
|
XML 前端开发 数据格式
Beautiful Soup 解析html | python小知识
在数据驱动的时代,网页数据是非常宝贵的资源。很多时候我们需要从网页上提取数据,进行分析和处理。Beautiful Soup 是一个非常流行的 Python 库,可以帮助我们轻松地解析和提取网页中的数据。本文将详细介绍 Beautiful Soup 的基础知识和常用操作,帮助初学者快速入门和精通这一强大的工具。【10月更文挑战第11天】
69 2
|
2月前
|
前端开发 JavaScript
pyquery:一个灵活方便的 HTML 解析库
pyquery:一个灵活方便的 HTML 解析库
29 1
|
3月前
|
数据采集 存储 JavaScript
如何使用Cheerio与jsdom解析复杂的HTML结构进行数据提取
在现代网页开发中,复杂的HTML结构给爬虫技术带来挑战。传统的解析库难以应对,而Cheerio和jsdom在Node.js环境下提供了强大工具。本文探讨如何在复杂HTML结构中精确提取数据,结合代理IP、cookie、user-agent设置及多线程技术,提升数据采集的效率和准确性。通过具体示例代码,展示如何使用Cheerio和jsdom解析HTML,并进行数据归类和统计。这种方法适用于处理大量分类数据的爬虫任务,帮助开发者轻松实现高效的数据提取。
如何使用Cheerio与jsdom解析复杂的HTML结构进行数据提取
|
3月前
|
存储 JavaScript Java
使用NekoHTML解析HTML并提取META标签内容
关于NekoHTML的代码样例,这里提供一个简单的示例,用于展示如何使用NekoHTML来解析HTML文档并提取其中的信息。请注意,由于NekoHTML的具体实现和API可能会随着版本更新而有所变化,以下代码仅供参考。 ### 示例:使用NekoHTML解析HTML并提取META标签内容 ```java import org.cyberneko.html.parsers.DOMParser; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NodeList; import org.xml
|
4月前
|
移动开发 前端开发 JavaScript
|
4月前
|
存储 移动开发 开发者

推荐镜像

更多
下一篇
DataWorks