17.解析器

发布于:2025-08-01 ⋅ 阅读:(24) ⋅ 点赞:(0)

1.文本模式及其对解析器的影响

解析器遇到不同的标签,会切换不同的模式,从而影响对文本解析的行为:

  • title 标签、<textarea> 标签,当解析器遇到这两个标签时,会切换到 RCDATA 模式
  • style、<xmp><iframe><noembed><noframes><noscript> 等标签,当解析器遇到这些标签时,会切换到 RAWTEXT 模式;
  • 当解析器遇到 < ![CDATA[ 字符串时,会进入 CDATA 模式。

对于vue.js的模板来说,模板中允许出现script标签,因为vue.js遇到script标签时也会切换到RAWTEXT模式。

不同模式及其特性

1731642277410

定义状态:

const TextModes = {
   DATA: 'DATA',
   RCDATA: 'RCDATA',
   RAWTEXT: 'RAWTEXT',
   CDATA: 'CDATA'
 }

2.递归下降算法构造模板AST

解析器基本架构模型:

// 定义文本模式,作为一个状态表
 const TextModes = {
   DATA: 'DATA',   
   RCDATA: 'RCDATA',   
   RAWTEXT: 'RAWTEXT',  
   CDATA: 'CDATA' 
 }

 // 解析器函数,接收模板作为参数
 function parse(str) {
   // 定义上下文对象
   const context = {
     // source 是模板内容,用于在解析过程中进行消费
     source: str,
     // 解析器当前处于文本模式,初始模式为 DATA
     mode: TextModes.DATA
   }
   // 调用 parseChildren 函数开始进行解析,它返回解析后得到的子节点
   // parseChildren 函数接收两个参数:
   // 第一个参数是上下文对象 context
   // 第二个参数是由父代节点构成的节点栈,初始时栈为空
   const nodes = parseChildren(context, [])

   // 解析器返回 Root 根节点
   return {
     type: 'Root',
     // 使用 nodes 作为根节点的 children
     children: nodes
   }
 }

定义一个状态表,用来描述文本模式。parse函数是个解析器,内部定义了上下文对象context,用来维护解析程序执行中的状态。接着调用parseChildren函数进行解析,该函数计息后返回的子节点作为Root根节点的子节点。最后返回根节点,完成模板创建。parseChild人函数是解析器的核心功能

假设有这样的模板:

 <p>1</p>
 <p>2</p>

parseChildren函数设计:

参数: 1.上下文对象context; 2.由父代节点构成的栈,用于维护节点间的父子级关系。

功能: //通过parseChildren函数解析后,希望得出如下的数据,作为根节点的childre

 [
    { type: 'Element', tag: 'p', children: [/*...*/]  },
    { type: 'Element', tag: 'p', children: [/*...*/]  },
 ]

parseChildren函数本质也是一个状态机,该状态机有多少种状态取决于子节点的类型数量。在模板中,元素的子节点可以是以下几种:

  • 标签节点,例如 <div>
  • 文本插值节点,例如 {{ val }}。
  • 普通文本节点,例如:text。
  • 注释节点,例如 <!---->
  • CDATA 节点,例如 <![CDATA[ xxx ]]>。

在标准的HTML中,节点类型很多,为了降低复杂度,目前仅考虑上述节点类型。

解析模板过程中的状态迁移:

1731649418998

解析中的基本规则如下:

  • 当遇到字符 < 时,进入临时状态。

    如果下一个字符匹配正则 /a-z/i,则认为这是一个标签节点,于是调用 parseElement 函数完成标签的解析。注意正则表达式 /a-z/i 中的 i,意思是忽略大小写(case-insensitive)。

    如果字符串以 <!-- 开头,则认为这是一个注释节点,于是调用 parseComment 函数完成注释节点的解析。

    如果字符串以 <![CDATA[ 开头,则认为这是一个 CDATA 节点,于是调用 parseCDATA 函数完成CDATA 节点的解析。

  • 如果字符串以 {{ 开头,则认为这是一个插值节点,于是调用 parseInterpolation 函数完成插值节点的解析。

  • 其他情况,都作为普通文本,调用 parseText 函数完成文本节点的解析。

代码实现:

function parseChildren(context, ancestors) {
   // 定义 nodes 数组存储子节点,它将作为最终的返回值
   let nodes = []
   // 从上下文对象中取得当前状态,包括模式 mode 和模板内容 source
   const { mode, source } = context

   // 开启 while 循环,只要满足条件就会一直对字符串进行解析
   // 关于 isEnd() 后文会详细讲解
   while(!isEnd(context, ancestors)) {
     let node
     // 只有 DATA 模式和 RCDATA 模式才支持插值节点的解析
     if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
       // 只有 DATA 模式才支持标签节点的解析
       if (mode === TextModes.DATA && source[0] === '<') {
         if (source[1] === '!') {
           if (source.startsWith('<!--')) {
             // 注释
             node = parseComment(context)
           } else if (source.startsWith('<![CDATA[')) {
             // CDATA
             node = parseCDATA(context, ancestors)
           }
         } else if (source[1] === '/') {
           // 结束标签,这里需要抛出错误,后文会详细解释原因
         } else if (/[a-z]/i.test(source[1])) {
           // 标签
           node = parseElement(context, ancestors)
         }
       } else if (source.startsWith('{{')) {
         // 解析插值,例如<p>{{value}}</p>
         node = parseInterpolation(context)
       }
     }

     // node 不存在,说明处于其他模式,即非 DATA 模式且非 RCDATA 模式
     // 这时一切内容都作为文本处理
     if (!node) {
       // 解析文本节点
       node = parseText(context)
     }

     // 将节点添加到 nodes 数组中
     nodes.push(node)
   }

   // 当 while 循环停止后,说明子节点解析完毕,返回子节点
   return nodes
 }

需要注意如下:

● parseChildren 函数的返回值是由子节点组成的数组,每次 while 循环都会解析一个或多个节点,这些节点会被添加到 nodes 数组中,并作为 parseChildren 函数的返回值返回。

● 解析过程中需要判断当前的文本模式。根据前面结论可知,只有处于 DATA 模式或 RCDATA 模式时,解析器才支持插值节点的解析。并且,只有处于 DATA 模式时,解析器才支持标签节点、注释节点和 CDATA 节点的解析。

● 在 16.1 节中我们介绍过,当遇到特定标签时,解析器会切换模式。一旦解析器切换到 DATA 模式和RCDATA 模式之外的模式时,一切字符都将作为文本节点被解析。当然,即使在 DATA 模式或RCDATA 模式下,如果无法匹配标签节点、注释节点、CDATA 节点、插值节点,那么也会作为文本节点解析。

假定有一个模板:

 const template = `<div>
   <p>Text1</p>
   <p>Text2</p>
 </div>`

这里需要强调的是,在解析模板时,我们不能忽略空白字符。这些空白字符包括:换行符(\n)、回车符(\r)、空格(’ ')、制表符(\t)以及换页符(\f)。如果我们用加号(+)代表换行符,用减号(-)代表空格字符。那么上面的模板可以表示为:

template = `<div>+--<p>Text1</p>+--<p>Text2</p>+</div>`

解析过程:

解析器一开始处于 DATA 模式。开始执行解析后,解析器遇到的第一个字符为 <,并且第二个字符能够匹配正则表达式 /a-z/i,所以解析器会进入标签节点状态,并调用 parseElement 函数进行解析。

parseElement 函数会做三件事:解析开始标签,解析子节点,解析结束标签,代码实现:

function parseElement() {
   // 解析开始标签
   const element = parseTag()
   // 这里递归地调用 parseChildren 函数进行 <div> 标签子节点的解析
   element.children = parseChildren()
   // 解析结束标签
   parseEndTag()

   return element
 }

如果一个标签不是自闭合标签,可以分为开始标签,子节点,结束标签三部分构成。在parseElement函数内,可以分别调用三个解析函数来处理这三部分内容。

开始标签:

parseTag 解析开始标签。parseTag 函数用于解析开始标签,包括开始标签上的属性和指令。因此,在 parseTag 解析函数执行完毕后,会消费字符串中的内容 <div>,处理后的模板内容将变为:

template = `+--<p>Text1</p>+--<p>Text2</p>+</div>`

子节点:

递归地调用 parseChildren 函数解析子节点。parseElement 函数在解析开始标签时,会产生一个标签节点 element。在 parseElement 函数执行完毕后,剩下的模板内容应该作为 element 的子节点被解析,即 element.children。因此,我们要递归地调用 parseChildren 函数。在这个过程中,parseChildren 函数会消费字符串的内容:±-<p>Text1 </p>±-<p>Text2 </p>+。处理后的模板内容将变为:

template = `</div>`

结束标签:

parseEndTag 处理结束标签。可以看到,在经过 parseChildren 函数处理后,模板内容只剩下一个结束标签了。因此,只需要调用 parseEndTag 解析函数来消费它即可。

子节点的解析:

div这一层解析完了,里面的两个p标签就会进入递归的解析阶段。原理和过程同解析div的时候一样。

随着标签嵌套层次的增加,新的状态机会随着 parseChildren 函数被递归地调用而不断创建,这就是“递归下降”中“递归”二字的含义。而上级 parseChildren 函数的调用用于构造上级模板 AST 节点,被递归调用的下级parseChildren 函数则用于构造下级模板 AST 节点。最终,会构造出一棵树型结构的模板 AST,这就是“递归下降”中“下降”二字的含义。

3.状态机的开启与停止

parseChildren函数本质上是一个状态机,开启一个while循环使得状态机自动运行。状态机的停止条件依赖于isEnd()函数执行的结果。

状态机运行图示:

开启新的状态机

1731659793510

递归地开启新的状态机

1731659802939

状态机 2 停止运行

1731659823489

开启状态机 3

1731659843559

状态机 3 停止运行

1731659853500

状态机 1 停止

1731659858811

结论:

当解析器遇到开始标签时,会将该标签压入父级节点栈,同时开启新的状态机。当解析器遇到结束标签,并且父级节点栈中存在与该标签同名的开始标签节点时,会停止当前正在运行的状态机。

isEnd函数的实现

function isEnd(context, ancestors) {
   // 当模板内容解析完毕后,停止
   if (!context.source) return true
   // 获取父级标签节点
   const parent = ancestors[ancestors.length - 1]
   // 如果遇到结束标签,并且该标签与父级标签节点同名,则停止
   if (parent && context.source.startsWith(`</${parent.tag}`)) {
     return true
   }
 }

●第一个停止时机是当模板内容被解析完毕时;

●第二个停止时机则是在遇到结束标签时,这时解析器会取得父级节点栈栈顶的节点作为父节点,检查该结束标签是否与父节点的标签同名,如果相同,则状态机停止运行。

当遇到了不符合预期的模板,例如:

<div><span></div></span>

如果按照原有逻辑去解析,流程为:

●“状态机 1”遇到 <div> 开始标签,调用 parseElement 解析函数,这会开启“状态机 2”来完成子节点的解析。
●“状态机 2”遇到 <span> 开始标签,调用 parseElement 解析函数,这会开启“状态机 3”来完成子节点的解析。
●“状态机 3”遇到 </div> 结束标签。由于此时父级节点栈栈顶的节点名称是 span,并不是 div,所以“状态机 3”不会停止运行。这时,“状态机 3”遭遇了不符合预期的状态,因为结束标签 </div>缺少与之对应的开始标签,所以这时“状态机 3”会抛出错误:“无效的结束标签”。

如果换一种解析方式:

解析的结果:<div><span></div>   
多余的内容:</span>

1731663224409

修改isEnd函数

function isEnd(context, ancestors) {
   if (!context.source) return true

   // 与父级节点栈内所有节点做比较
   for (let i = ancestors.length - 1; i >= 0; --i) {
     // 只要栈中存在与当前结束标签同名的节点,就停止状态机
     if (context.source.startsWith(`</${ancestors[i].tag}`)) {
       return true
     }
   }
 }

流程:

●“状态机 1”遇到 <div> 开始标签,调用 parseElement 解析函数,并开启“状态机 2”解析子节点。

●“状态机 2”遇到 <span> 开始标签,调用 parseElement 解析函数,并开启“状态机 3”解析子节点。

●“状态机 3”遇到 </div> 结束标签,由于节点栈中存在名为 div 的标签节点,于是“状态机 3”停止了。

添加友好提示:

function parseElement(context, ancestors) {
   const element = parseTag(context)
   if (element.isSelfClosing) return element

   ancestors.push(element)
   element.children = parseChildren(context, ancestors)
   ancestors.pop()

   if (context.source.startsWith(`</${element.tag}`)) {
     parseTag(context, 'end')
   } else {
     // 缺少闭合标签
     console.error(`${element.tag} 标签缺少闭合标签`)
   }

   return element
 }

4.解析标签节点

无论是开始标签还是闭合标签,都调用了parseTag函数,并且使用parseChildren函数来解析开始标签与闭合标签中间的部分

function parseElement(context, ancestors) {
   // 调用 parseTag 函数解析开始标签
   const element = parseTag(context)
   if (element.isSelfClosing) return element

   ancestors.push(element)
   element.children = parseChildren(context, ancestors)
   ancestors.pop()

   if (context.source.startsWith(`</${element.tag}`)) {
     // 再次调用 parseTag 函数解析结束标签,传递了第二个参数:'end'
     parseTag(context, 'end')
   } else {
     console.error(`${element.tag} 标签缺少闭合标签`)
   }

   return element
 }

标签节点的解析过程

1731914714338

由于开始标签和结束标签的格式非常类似,可以统一使用parseTag函数处理,并且通过该函数的第二个参数来指定具体的处理类型。当第二个参数值为字符串’end’时,意味着解析的是结束标签。无论是处理开始还是结束标签,parseTag都会消费对应的内容。为了实现模板内容的消费,需要新增两个工具函数:

function parse(str) {
   // 上下文对象
   const context = {
     // 模板内容
     source: str,
     mode: TextModes.DATA,
     // advanceBy 函数用来消费指定数量的字符,它接收一个数字作为参数
     advanceBy(num) {
       // 根据给定字符数 num,截取位置 num 后的模板内容,并替换当前模板内容
       context.source = context.source.slice(num)
     },
     // 无论是开始标签还是结束标签,都可能存在无用的空白字符,例如 <div    >
     advanceSpaces() {
       // 匹配空白字符
       const match = /^[\t\r\n\f ]+/.exec(context.source)
       if (match) {
         // 调用 advanceBy 函数消费空白字符
         context.advanceBy(match[0].length)
       }
     }
   }

   const nodes = parseChildren(context, [])

   return {
     type: 'Root',
     children: nodes
   }
 }

为上下文对象增加了 advanceBy 函数和 advanceSpaces 函数。advanceBy 函数用来消费指定数量的字符。其实现原理很简单,即调用字符串的 slice 函数,根据指定位置截取剩余字符串,并使用截取后的结果作为新的模板内容。advanceSpaces 函数则用来消费无用的空白字符,因为标签中可能存在空白字符,例如在模板 <div----> 中减号(-)代表空白字符。

// 由于 parseTag 既用来处理开始标签,也用来处理结束标签,因此我们设计第二个参数 type,
 // 用来代表当前处理的是开始标签还是结束标签,type 的默认值为 'start',即默认作为开始标签处理
 function parseTag(context, type = 'start') {
   // 从上下文对象中拿到 advanceBy 函数
   const { advanceBy, advanceSpaces } = context

   // 处理开始标签和结束标签的正则表达式不同
   const match = type === 'start'
     // 匹配开始标签
     ? /^<([a-z][^\t\r\n\f />]*)/i.exec(context.source)
     // 匹配结束标签
     : /^<\/([a-z][^\t\r\n\f />]*)/i.exec(context.source)
   // 匹配成功后,正则表达式的第一个捕获组的值就是标签名称
   const tag = match[1]
   // 消费正则表达式匹配的全部内容,例如 '<div' 这段内容
   advanceBy(match[0].length)
   // 消费标签中无用的空白字符
   advanceSpaces()

   // 在消费匹配的内容后,如果字符串以 '/>' 开头,则说明这是一个自闭合标签
   const isSelfClosing = context.source.startsWith('/>')
   // 如果是自闭合标签,则消费 '/>', 否则消费 '>'
   advanceBy(isSelfClosing ? 2 : 1)

   // 返回标签节点
   return {
     type: 'Element',
     // 标签名称
     tag,
     // 标签的属性暂时留空
     props: [],
     // 子节点留空
     children: [],
     // 是否自闭合
     isSelfClosing
   }
 }

上面代码中的两个关键点:

●由于 parseTag 函数既用于解析开始标签,又用于解析结束标签,因此需要用一个参数来标识当前处理的标签类型,即 type。

●对于开始标签和结束标签,用于匹配它们的正则表达式只有一点不同:结束标签是以字符串 </ 开头的。图 16-16 给出了用于匹配开始标签的正则表达式的含义。

1731916794186

●对于字符串 ‘<div>’,会匹配出字符串 ‘<div’,剩余 ‘>’。

●对于字符串 ‘<div/>’,会匹配出字符串 ‘<div’,剩余 ‘/>’。

●对于字符串 ‘<div---->’,其中减号(-)代表空白符,会匹配出字符串 ‘<div’,剩余 ‘---->’。

除了正则表达式外,parseTag 函数的另外几个关键点如下。

●在完成正则匹配后,需要调用 advanceBy 函数消费由正则匹配的全部内容。

●根据上面给出的第三个正则匹配例子可知,由于标签中可能存在无用的空白字符,例如 <div---->,因此我们需要调用 advanceSpaces 函数消费空白字符。

●在消费由正则匹配的内容后,需要检查剩余模板内容是否以字符串 /> 开头。如果是,则说明当前解析的是一个自闭合标签,这时需要将标签节点的 isSelfClosing 属性设置为 true。

●最后,判断标签是否自闭合。如果是,则调用 advnaceBy 函数消费内容 />,否则只需要消费内容> 即可。

经过上面的流程,pareTag函数会返回一个标签节点。parseElement函数在得到由parseTag函数产生的标签节点后,需要根据节点的类型完成文本模式的切换

function parseElement(context, ancestors) {
   const element = parseTag(context)
   if (element.isSelfClosing) return element

   // 切换到正确的文本模式
   if (element.tag === 'textarea' || element.tag === 'title') {
     // 如果由 parseTag 解析得到的标签是 <textarea> 或 <title>,则切换到 RCDATA 模式
     context.mode = TextModes.RCDATA
   } else if (/style|xmp|iframe|noembed|noframes|noscript/.test(element.tag)) {
     // 如果由 parseTag 解析得到的标签是:
     // <style>、<xmp>、<iframe>、<noembed>、<noframes>、<noscript>
     // 则切换到 RAWTEXT 模式
     context.mode = TextModes.RAWTEXT
   } else {
     // 否则切换到 DATA 模式
     context.mode = TextModes.DATA
   }

   ancestors.push(element)
   element.children = parseChildren(context, ancestors)
   ancestors.pop()

   if (context.source.startsWith(`</${element.tag}`)) {
     parseTag(context, 'end')
   } else {
     console.error(`${element.tag} 标签缺少闭合标签`)
   }

   return element
 }

5.解析属性

模板

<div id="foo" v-show="display"/>

上面模板中存在一个id属性和一个v-show指令,为了处理指令,需要在parseTag中增加parseAttributes解析函数

function parseTag(context, type = 'start') {
   const { advanceBy, advanceSpaces } = context

   const match = type === 'start'
     ? /^<([a-z][^\t\r\n\f />]*)/i.exec(context.source)
     : /^<\/([a-z][^\t\r\n\f />]*)/i.exec(context.source)
   const tag = match[1]

   advanceBy(match[0].length)
   advanceSpaces()
   // 调用 parseAttributes 函数完成属性与指令的解析,并得到 props 数组,
   // props 数组是由指令节点与属性节点共同组成的数组
   const props = parseAttributes(context)

   const isSelfClosing = context.source.startsWith('/>')
   advanceBy(isSelfClosing ? 2 : 1)

   return {
     type: 'Element',
     tag,
     props, // 将 props 数组添加到标签节点上
     children: [],
     isSelfClosing
   }
 }

function parseAttributes(context) {
   // 用来存储解析过程中产生的属性节点和指令节点
   const props = []

   // 开启 while 循环,不断地消费模板内容,直至遇到标签的“结束部分”为止
   while (
     !context.source.startsWith('>') &&
     !context.source.startsWith('/>')
   ) {
     // 解析属性或指令
   }
   // 将解析结果返回
   return props
 }

到了处理属性值的环节。模板中的属性值存在三种情况:

○属性值被双引号包裹:id=“foo”。

○属性值被单引号包裹:id=‘foo’。

○属性值没有引号包裹:id=foo。

parseAttributes函数的具体实现:

function parseAttributes(context) {
   const { advanceBy, advanceSpaces } = context
   const props = []

   while (
     !context.source.startsWith('>') &&
     !context.source.startsWith('/>')
   ) {
     // 该正则用于匹配属性名称
     const match = /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec(context.source)
     // 得到属性名称
     const name = match[0]

     // 消费属性名称
     advanceBy(name.length)
     // 消费属性名称与等于号之间的空白字符
     advanceSpaces()
     // 消费等于号
     advanceBy(1)
     // 消费等于号与属性值之间的空白字符
     advanceSpaces()

     // 属性值
     let value = ''

     // 获取当前模板内容的第一个字符
     const quote = context.source[0]
     // 判断属性值是否被引号引用
     const isQuoted = quote === '"' || quote === "'"

     if (isQuoted) {
       // 属性值被引号引用,消费引号
       advanceBy(1)
       // 获取下一个引号的索引
       const endQuoteIndex = context.source.indexOf(quote)
       if (endQuoteIndex > -1) {
         // 获取下一个引号之前的内容作为属性值
         value = context.source.slice(0, endQuoteIndex)
         // 消费属性值
         advanceBy(value.length)
         // 消费引号
         advanceBy(1)
       } else {
         // 缺少引号错误
         console.error('缺少引号')
       }
     } else {
       // 代码运行到这里,说明属性值没有被引号引用
       // 下一个空白字符之前的内容全部作为属性值
       const match = /^[^\t\r\n\f >]+/.exec(context.source)
       // 获取属性值
       value = match[0]
       // 消费属性值
       advanceBy(value.length)
     }
     // 消费属性值后面的空白字符
     advanceSpaces()

     // 使用属性名称 + 属性值创建一个属性节点,添加到 props 数组中
     props.push({
       type: 'Attribute',
       name,
       value
     })

   }
   // 返回
   return props
 }

两个重要的正则表达式:

●/[\t\r\n\f />][^\t\r\n\f />=]*/,用来匹配属性名称;

●/[\t\r\n\f >]+/,用来匹配没有使用引号引用的属性值。

正则1:

1731988163748

●部分 A 用于匹配一个位置,这个位置不能是空白字符,也不能是字符 / 或字符 >,并且字符串要以该位置开头。

●部分 B 则用于匹配 0 个或多个位置,这些位置不能是空白字符,也不能是字符 /、>、=。注意,这些位置不允许出现等于号(=)字符,这就实现了只匹配等于号之前的内容,即属性名称。

正则2:

1731988180364

该正则表达式从字符串的开始位置进行匹配,并且会匹配一个或多个非空白字符、非字符 >。换句话说,该正则表达式会一直对字符串进行匹配,直到遇到空白字符或字符 > 为止,这就实现了属性值的提取。

模板1:

<div id="foo" v-show="display"></div>

解析结果:

const ast1 = {
   type: 'Root',
   children: [
     {
       type: 'Element',
       tag: 'div',
       props: [
         // 属性
         { type: 'Attribute', name: 'id', value: 'foo' },
         { type: 'Attribute', name: 'v-show', value: 'display' }
       ]
     }
   ]
 }

模板2:

<div :id="dynamicId" @click="handler" v-on:mousedown="onMouseDown" ></div>

解析结果2:

const ast2 = {
   type: 'Root',
   children: [
     {
       type: 'Element',
       tag: 'div',
       props: [
         // 属性
         { type: 'Attribute', name: ':id', value: 'dynamicId' },
         { type: 'Attribute', name: '@click', value: 'handler' },
         { type: 'Attribute', name: 'v-on:mousedown', value: 'onMouseDown' }
       ]
     }
   ]
 }

网站公告

今日签到

点亮在社区的每一天
去签到