上一节,我们讲了vue模版解析的大致流程,这一节,我们将细说一下模版编的具体内容是什么样子的?其实模版编译主要是html解析和文本解析。

// 代码位置:/src/complier/parser/index.js

/**
 * Convert HTML string to AST.
 * 将HTML模板字符串转化为AST
 */
export function parse(template, options) {
   // ...
  parseHTML(template, {
    warn,
    expectHTML: options.expectHTML,
    isUnaryTag: options.isUnaryTag,
    canBeLeftOpenTag: options.canBeLeftOpenTag,
    shouldDecodeNewlines: options.shouldDecodeNewlines,
    shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
    shouldKeepComment: options.comments,
    // 当解析到开始标签时,调用该函数
    start (tag, attrs, unary) {

    },
    // 当解析到结束标签时,调用该函数
    end () {

    },
    // 当解析到文本时,调用该函数
    chars (text) {

    },
    // 当解析到注释时,调用该函数
    comment (text) {

    }
  })
  return root
}

html的解析,主要是依赖于parse 函数,接收俩个参数,第一个参数无需多说,就是<tempalte></template>直接的内容。第二个参数是options,就是相关解析html的参数。同时,还定义了四个钩子函数:主要作用就是将模版字符串中的内容取出来,然后转化成AST树。把这四个钩子函数传给parseHTML,当解析出不同的内容的时候,就会调用钩子函数。
当解析到标签元素时,就会调用start函数生成元素类型到AST节点。

// 当解析到标签的开始位置时,触发start
start (tag, attrs, unary) {
	let element = createASTElement(tag, attrs, currentParent)
}

export function createASTElement (tag,attrs,parent) {
  return {
    type: 1,
    tag,
    attrsList: attrs,
    attrsMap: makeAttrsMap(attrs),
    parent,
    children: []
  }
}

通过三个参数,标签,属性,是否闭合,调用createASTElement()方法来创建标签到AST树。

当解析到结束标签时,调用end函数。

当解析到文本时,会调用chars函数:

chars (text) {
	if(text是带变量的动态文本){
    let element = {
      type: 2,
      expression: res.expression,
      tokens: res.tokens,
      text
    }
  } else {
    let element = {
      type: 3,
      text
    }
  }
}

所以,函数内部首先会判断是不是动态文本,是就创建动态AST节点,否则创建静态AST节点。

当解析到注释时,会调用comment:

// 当解析到标签的注释时,触发comment
comment (text: string) {
  let element = {
    type: 3,
    text,
    isComment: true
  }
}

一边解析,一边创建AST节点。这就是HTML解析器所要做的工作。

如何解析不同的内容?

总体来说vue解析的内容:

  • 文本,例如“hello world”
  • HTML注释,例如<!-- 我是注释 -->
  • 条件注释,例如<!-- [if !IE]> -->我是注释<!--< ![endif] -->
  • DOCTYPE,例如<!DOCTYPE html>
  • 开始标签,例如<div>
  • 结束标签,例如</div>

(1)html注释的解析:通过正则匹配以<!--开头,以-->结尾,那么其中的内容就是注释的内容。 

const comment = /^<!\--/
if (comment.test(html)) {
  // 若为注释,则继续查找是否存在'-->'
  const commentEnd = html.indexOf('-->')

  if (commentEnd >= 0) {
    // 若存在 '-->',继续判断options中是否保留注释
    if (options.shouldKeepComment) {
      // 若保留注释,则把注释截取出来传给options.comment,创建注释类型的AST节点
      options.comment(html.substring(4, commentEnd))
    }
    // 若不保留注释,则将游标移动到'-->'之后,继续向后解析
    advance(commentEnd + 3)
    continue
  }
}

当匹配到注释的内容的时候,就会调用comment函数,然后开始解析,如果我们在<template></template>上设置了comments为true,那么我们就会在解析的时候,保留注释,从而创建注释AST节点。advance函数是用来移动解析游标的,解析完一部分就把游标向后移动一部分,确保不会重复解析。

function advance (n) {
  index += n   // index为解析游标
  html = html.substring(n)
}

(2)解析条件注释

先用正则判断是否是以条件注释特有的开头标识开始,然后寻找其特有的结束标识,若找到,则说明是条件注释,将其截取出来即可,由于条件注释不存在于真正的DOM树中,所以不需要调用钩子函数创建AST节点。

// 解析是否是条件注释
const conditionalComment = /^<!\[/
if (conditionalComment.test(html)) {
  // 若为条件注释,则继续查找是否存在']>'
  const conditionalEnd = html.indexOf(']>')

  if (conditionalEnd >= 0) {
    // 若存在 ']>',则从原本的html字符串中把条件注释截掉,
    // 把剩下的内容重新赋给html,继续向后匹配
    advance(conditionalEnd + 2)
    continue
  }
}

(3)解析DOCTYPE(同注释)

const doctype = /^<!DOCTYPE [^>]+>/i
// 解析是否是DOCTYPE
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
  advance(doctypeMatch[0].length)
  continue
}

(4)标签的解析

标签的解析,相对比较复杂一点,首先会通过正则匹配到模版字符串,看模板字符串是否具有开始标签的特征:

/**
 * 匹配开始标签的正则
 */
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)

const start = html.match(startTagOpen)
if (start) {
  const match = {
    tagName: start[1],
    attrs: [],
    start: index
  }
}

// 以开始标签开始的模板:
'<div></div>'.match(startTagOpen)  => ['<div','div',index:0,input:'<div></div>']
// 以结束标签开始的模板:
'</div><div></div>'.match(startTagOpen) => null
// 以文本开始的模板:
'我是文本</p>'.match(startTagOpen) => null

上面的代码匹配到了一个div标签的数组,但是,标签解析的start函数是需要三个参数,标签,属性,是否闭合,所以还的接着解析。所以匹配属性的时候,就会先将开始标签的一部分截掉,剩下属性之后的部分

//    <div class="a" id="b"></div> ===> class="a" id="b">
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
let html = 'class="a" id="b"></div>'
let attr = html.match(attribute)
console.log(attr)
// ["class="a"", "class", "=", "a", undefined, undefined, index: 0, input: "class="a" id="b"></div>", groups: undefined]

如上面的div标签,有多个属性,那么就循环匹配,每匹配到一次,就将匹配到的部分截掉,然后继续匹配下一个,知道不满足位置。

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const startTagClose = /^\s*(\/?)>/
const match = {
 tagName: start[1],
 attrs: [],
 start: index
}
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
 advance(attr[0].length)
 match.attrs.push(attr)
}

还有标签,是自闭标签的匹配。当上一步的属性解析完毕之后,就是剩下的结束标签。通过正则接着匹配。

const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/


function parseStartTag () {
  const start = html.match(startTagOpen)
  // '<div></div>'.match(startTagOpen)  => ['<div','div',index:0,input:'<div></div>']
  if (start) {
    const match = {
      tagName: start[1],
      attrs: [],
      start: index
    }
    advance(start[0].length)
    let end, attr
    /**
     * <div a=1 b=2 c=3></div>
     * 从<div之后到开始标签的结束符号'>'之前,一直匹配属性attrs
     * 所有属性匹配完之后,html字符串还剩下
     * 自闭合标签剩下:'/>'
     * 非自闭合标签剩下:'></div>'
     */
    while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
      advance(attr[0].length)
      match.attrs.push(attr)
    }

    /**
     * 这里判断了该标签是否为自闭合标签
     * 自闭合标签如:<input type='text' />
     * 非自闭合标签如:<div></div>
     * '></div>'.match(startTagClose) => [">", "", index: 0, input: "></div>", groups: undefined]
     * '/><div></div>'.match(startTagClose) => ["/>", "/", index: 0, input: "/><div></div>", groups: undefined]
     * 因此,我们可以通过end[1]是否是"/"来判断该标签是否是自闭合标签
     */
    if (end) {
      match.unarySlash = end[1]
      advance(end[0].length)
      match.end = index
      return match
    }
  }
}

所有的解析完成后,就可以是调用start去解析,但是在vue源码中是通过先调用handleStartTag处理属性数组,再调用start函数生成AST树。

function handleStartTag (match) {
    const tagName = match.tagName
    const unarySlash = match.unarySlash

    if (expectHTML) {
      // ...
    }

    const unary = isUnaryTag(tagName) || !!unarySlash

    const l = match.attrs.length
    const attrs = new Array(l)
    for (let i = 0; i < l; i++) {
      const args = match.attrs[i]
      const value = args[3] || args[4] || args[5] || ''
      const shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'
        ? options.shouldDecodeNewlinesForHref
        : options.shouldDecodeNewlines
      attrs[i] = {
        name: args[1],
        value: decodeAttr(value, shouldDecodeNewlines)
      }
    }

    if (!unary) {
      stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs })
      lastTag = tagName
    }

    if (options.start) {
      options.start(tagName, attrs, unary, match.start, match.end)
    }
  }

以上是解析标签开始的过程。

(5)解析结束标签

解析结束标签比较简单,因为结束标签没有属性之类的东西,通过正则来匹配结束标签,然后调用parseEndTag,从而调用end函数做相关处理

const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const endTagMatch = html.match(endTag)

'</div>'.match(endTag)  // ["</div>", "div", index: 0, input: "</div>", groups: undefined]
'<div>'.match(endTag)  // null

if (endTagMatch) {
    const curIndex = index
    advance(endTagMatch[0].length)
    parseEndTag(endTagMatch[1], curIndex, index)
    continue
}

(6)文本解析

let textEnd = html.indexOf('<')
// '<' 在第一个位置,为其余5种类型
if (textEnd === 0) {
    // ...
}
// '<' 不在第一个位置,文本开头
if (textEnd >= 0) {
    // 如果html字符串不是以'<'开头,说明'<'前面的都是纯文本,无需处理
    // 那就把'<'以后的内容拿出来赋给rest
    rest = html.slice(textEnd)
    while (
        !endTag.test(rest) &&
        !startTagOpen.test(rest) &&
        !comment.test(rest) &&
        !conditionalComment.test(rest)
    ) {
        // < in plain text, be forgiving and treat it as text
        /**
           * 用'<'以后的内容rest去匹配endTag、startTagOpen、comment、conditionalComment
           * 如果都匹配不上,表示'<'是属于文本本身的内容
           */
        // 在'<'之后查找是否还有'<'
        next = rest.indexOf('<', 1)
        // 如果没有了,表示'<'后面也是文本
        if (next < 0) break
        // 如果还有,表示'<'是文本中的一个字符
        textEnd += next
        // 那就把next之后的内容截出来继续下一轮循环匹配
        rest = html.slice(textEnd)
    }
    // '<'是结束标签的开始 ,说明从开始到'<'都是文本,截取出来
    text = html.substring(0, textEnd)
    advance(textEnd)
}
// 整个模板字符串里没有找到`<`,说明整个模板字符串都是文本
if (textEnd < 0) {
    text = html
    html = ''
}
// 把截取出来的text转化成textAST
if (options.chars && text) {
    options.chars(text)
}

依旧是通过正则,去匹配,最终调用chars函数,然后创建文本AST树。

解析了这么多标签,注释等,尤其是标签,总是存在层级关系的,那么是如何保证这种层级关系的呢?

其实是在最开始解析的时候,vue创建了一个stack栈,主要的作用就是保证AST树的层级关系,我们知道,在开始的时候,就会调用start函数,那么此时就可以将标签压入到stack栈中,当解析到结束标签时候,会调用end函数,这时将栈中的标签弹出。这样就保证了AST树的层级关系。

接下来看一段源码:

function parseHTML(html, options) {
	var stack = [];
	var expectHTML = options.expectHTML;
	var isUnaryTag$$1 = options.isUnaryTag || no;
	var canBeLeftOpenTag$$1 = options.canBeLeftOpenTag || no;
	var index = 0;
	var last, lastTag;

	// 开启一个 while 循环,循环结束的条件是 html 为空,即 html 被 parse 完毕
	while (html) {
		last = html;
		// 确保即将 parse 的内容不是在纯文本标签里 (script,style,textarea)
		if (!lastTag || !isPlainTextElement(lastTag)) {
		   let textEnd = html.indexOf('<')
              /**
               * 如果html字符串是以'<'开头,则有以下几种可能
               * 开始标签:<div>
               * 结束标签:</div>
               * 注释:<!-- 我是注释 -->
               * 条件注释:<!-- [if !IE] --> <!-- [endif] -->
               * DOCTYPE:<!DOCTYPE html>
               * 需要一一去匹配尝试
               */
            if (textEnd === 0) {
                // 解析是否是注释
        		if (comment.test(html)) {

                }
                // 解析是否是条件注释
                if (conditionalComment.test(html)) {

                }
                // 解析是否是DOCTYPE
                const doctypeMatch = html.match(doctype)
                if (doctypeMatch) {

                }
                // 解析是否是结束标签
                const endTagMatch = html.match(endTag)
                if (endTagMatch) {

                }
                // 匹配是否是开始标签
                const startTagMatch = parseStartTag()
                if (startTagMatch) {

                }
            }
            // 如果html字符串不是以'<'开头,则解析文本类型
            let text, rest, next
            if (textEnd >= 0) {

            }
            // 如果在html字符串中没有找到'<',表示这一段html字符串都是纯文本
            if (textEnd < 0) {
                text = html
                html = ''
            }
            // 把截取出来的text转化成textAST
            if (options.chars && text) {
                options.chars(text)
            }
		} else {
			// 父元素为script、style、textarea时,其内部的内容全部当做纯文本处理
		}

		//将整个字符串作为文本对待
		if (html === last) {
			options.chars && options.chars(html);
			if (!stack.length && options.warn) {
				options.warn(("Mal-formatted tag at end of template: \"" + html + "\""));
			}
			break
		}
	}

	// Clean up any remaining tags
	parseEndTag();
	//parse 开始标签
	function parseStartTag() {

	}
	//处理 parseStartTag 的结果
	function handleStartTag(match) {

	}
	//parse 结束标签
	function parseEndTag(tagName, start, end) {

	}
}

html解析就是这样繁琐,但是又简单。。。未完待续。。。

Logo

前往低代码交流专区

更多推荐