Vue2.x源码——解析器

解析器的作用就是将模板解析成AST。
那么什么是AST呢?下面我们先来了解一下AST到底是什么东西:
AST(Abstract Syntax Tree,抽象语法树),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。
例子:

<div>
	<p>{{value}}p>
div>

将上面的模板转换成AST后:

{
  type: 1,
  tag: "div",
  attrsList: [],
  attrsMap: {},
  rawAttrsMap: {},
  parent: undefined,
  plain: true,
  static: false,
  staticRoot: false,
  children: [{
    type: 1,
    tag: "p",
    attrsList: [],
    attrsMap: {},
    rawAttrsMap: {},
    parent: {type: 1, tag: "div",},
    plain: true,
    static: false,
    staticRoot: false,
    children: [{
      type: 2,
      expression: "_s(value)",
      text: "{{value}}",
      static: false
    }]
  }]
}

说白了,其实就是用js对象来描述一个节点,一个对象表示一个节点,对象中的属性保存了节点所需的各种数据。如type表示节点的类型,type值为1表示这是一个元素节点;tag是标签的名字;children是一个数组,里面保存了子节点的描述对象。

对AST有一定概念之后,下面来看一下解析器是如何将模板解析成AST的。

parse函数
export function parse (
  template: string,
  options: CompilerOptions
): ASTElement | void {
  warn = options.warn || baseWarn

  platformIsPreTag = options.isPreTag || no
  platformMustUseProp = options.mustUseProp || no
  platformGetTagNamespace = options.getTagNamespace || no
  const isReservedTag = options.isReservedTag || no
  maybeComponent = (el: ASTElement) => !!el.component || !isReservedTag(el.tag)

  transforms = pluckModuleFunction(options.modules, 'transformNode')
  preTransforms = pluckModuleFunction(options.modules, 'preTransformNode')
  postTransforms = pluckModuleFunction(options.modules, 'postTransformNode')

  delimiters = options.delimiters

  const stack = []
  const preserveWhitespace = options.preserveWhitespace !== false
  const whitespaceOption = options.whitespace
  let root
  let currentParent
  let inVPre = false
  let inPre = false
  let warned = false

  function warnOnce (msg, range) {...}
  function closeElement (element) {...}
  function trimEndingWhitespace (el) {...}
  function checkRootConstraints (el) {...}

  parseHTML(template, {
    warn,
    expectHTML: options.expectHTML,
    isUnaryTag: options.isUnaryTag,
    canBeLeftOpenTag: options.canBeLeftOpenTag,
    shouldDecodeNewlines: options.shouldDecodeNewlines,
    shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
    shouldKeepComment: options.comments,
    outputSourceRange: options.outputSourceRange,
    
    //解析到标签的开始位置时触发start函数
    start (tag, attrs, unary, start, end) {...},
    //解析到标签的结束位置时触发end函数
    end (tag, start, end) {...},
    //解析到文本时触发chars函数
    chars (text: string, start: number, end: number) {...},
    //解析到注释时触发comment函数
    comment (text: string, start, end) {...}
  })
  return root
}

在parse函数中,先是定义一些变量,如何调用parseHTML函数对模板进行解析。
调用parse HTML函数时传递的options参数中的start函数是构建一个元素类型的AST节点并将它压入栈中,chars函数是构建一个文本类型的AST节点,comment函数是构建一个注释类型的AST节点,end是从栈中取出一个节点以便完成AST树状结构的构建。

parseHTML函数
export function parseHTML (html, options) {
  const stack = []    //一个栈,用来记录DOM的层级关系
  const expectHTML = options.expectHTML
  const isUnaryTag = options.isUnaryTag || no
  const canBeLeftOpenTag = options.canBeLeftOpenTag || no
  let index = 0
  let last, lastTag
  while (html) {
    last = html
    // Make sure we're not in a plaintext content element like script/style
    //判断是不是script,style,textarea这三种标签(纯文本内容元素)
    if (!lastTag || !isPlainTextElement(lastTag)) {
		//正常元素处理
    } else {
      	//纯文本内容元素处理
    }
  }
}

在parseHTML函数中,会维护一个栈,这个栈用来记录DOM的层级关系。解析HTML模板时是一个循环的过程,模板解析时又分为纯文本内容元素与非纯文本内容元素来进行处理。

1、正常元素处理

if (!lastTag || !isPlainTextElement(lastTag)) {
      //正常元素处理
      //找到标签的开始位置"<"
      let textEnd = html.indexOf('<')
      //判断是否是以"<"开头
      if (textEnd === 0) {
        // Comment:
        //如果是注释标签
        //其中const comment = /^
        if (comment.test(html)) {
          //注释标签的结束标签
          const commentEnd = html.indexOf('-->')

          if (commentEnd >= 0) {
            if (options.shouldKeepComment) {
              //触发comment回调
              options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3)
            }
            //截掉已解析的注释标签,然后开始下一轮解析
            advance(commentEnd + 3)
            continue
          }
        }

        // http://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
        //如果是条件注释标签
        //其中const conditionalComment = /^
        if (conditionalComment.test(html)) {
          //找到注释标签的结束标签
          const conditionalEnd = html.indexOf(']>')

          if (conditionalEnd >= 0) {
            //截掉条件注释标签部分,开始下一轮循环
            advance(conditionalEnd + 2)
            continue
          }
        }

        // Doctype:
        //如果是DOCTYPE
        //其中const doctype = /^]+>/i;
        const doctypeMatch = html.match(doctype)
        if (doctypeMatch) {
          //截取掉DOCTYPE,然后开始下一轮循环
          advance(doctypeMatch[0].length)
          continue
        }

        // End tag:
        //如果是结束标签
        const endTagMatch = html.match(endTag)
        if (endTagMatch) {
          const curIndex = index
          //截取掉结束标签
          advance(endTagMatch[0].length)
          parseEndTag(endTagMatch[1], curIndex, index)
          continue
        }

        // Start tag:
        //如果是开始标签
        const startTagMatch = parseStartTag()
        if (startTagMatch) {
          handleStartTag(startTagMatch)
          if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
            advance(1)
          }
          continue
        }
      }

      let text, rest, next
      //如果不是以"<"开头,则当成文本
      if (textEnd >= 0) {
        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
          //如果有<但是又不符合开始标签、结束标签、注释、条件注释的话,就当成普通文本处理
          next = rest.indexOf('<', 1)
          if (next < 0) break
          textEnd += next
          rest = html.slice(textEnd)
        }
        text = html.substring(0, textEnd)
      }

      //如果模板中找不到<,则说明整个模板都是文本
      if (textEnd < 0) {
        text = html
      }

      //截取文本
      if (text) {
        advance(text.length)
      }

      //触发chars回调函数
      if (options.chars && text) {
        options.chars(text, index - text.length, index)
      }
    }

先判断剩余HTML模板第一个字符是不是"<",如果是则进一步进行处理判断到底是哪种类型,如果不是“<”则将它当做文本。

1)注释标签:
使用正则表达式判断剩余模板字符串是不是“”,若找到了“-->”就将注释文本截取出来。其中,如果配置options的shouldKeepComment为真的话,就会触发comment回调函数。
2)条件注释标签:
使用正则表达式判断模板字符串是不是符合条件注释标签的规则,如果符合则将条件注释部分的文本截取掉。它不需要触发options的回调函数,截取之后也没有对条件注释标签的内容进行其它的处理,只是简单的将条件注释内容给截掉而已。所以在vue.js中写条件注释其实是没有用的。
3)DOCTYPE:
DOCTYPE的截取是通过正则表达式来匹配DOCTYPE标签,然后根据匹配结果的length属性来决定截取的字符串长度。
4)结束标签:
判断剩余模板字符串是不是符合结束标签的规则,如果是结束标签,则将结束标签进行截取,然后调用parseEndTag函数。parseEndTag函数中会将结束标签名转换为小写,然后在stack中找到最近的相同标签名,然后触发end回调函数,并删除stack中已解析完成的标签。如果在stack中并没有找到相同的标签名的话,就会判断如果是"br"则触发start回调函数,如果是"p"则一次调用start和end回调函数。
5)开始标签:
开始标签的解析就要复杂一点了,因为开始标签中还有属性需要解析。开始标签的解析被分为了3部分:标签名、属性、结尾。

function parseStartTag () {
    //匹配开始标签的标签名
    const start = html.match(startTagOpen)
    if (start) {
      const match = {
        tagName: start[1],
        attrs: [],
        start: index
      }
      advance(start[0].length)
      let end, attr
      //解析标签属性
      while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) {
        attr.start = index
        advance(attr[0].length)
        attr.end = index
        match.attrs.push(attr)
      }

		//结尾
      if (end) {
        match.unarySlash = end[1]   //标记是否是自闭合标签
        advance(end[0].length)
        match.end = index
        return match
      }
    }
  }

parseStartTag 函数中先是匹配开始标签的标签名部分,如果匹配到了就创建一个match对象,在match对象中,tagName保存着标签的名字,attrs是一个数组保存着开始标签中的属性。将标签名截取掉之后就开始解析属性,将解析到的属性信息一项项添加到attrs中。属性解析完之后就到了结尾部分,如果结尾部分是"/>"的话则是自闭合标签,如果是自闭合标签则match.unarySlash保存着“/”,否则match.unarySlash是空字符串。

通过parseStartTag解析得到了开始标签的解析结果之后,就调用handleStartTag函数进一步进行处理。handleStartTag函数主要是讲tagName、attrs、unary等的数据取出来然后出发start回调函数。
6)文本截取:
如果模板不是以“<”开头,则当成文本进行处理。
1、不是以“<”开头但是模板中存在“<”,则截掉开头到“<”之间的字符串,然后接着有个循环进行判断剩下的字符串是否符合开始标签、结束标签、注释、条件注释的规则,如果不符合则当成普通文本处理,然后继续查找下一个"<“的位置重复上述循环的判断过程,直到符合标签的规则或者剩余字符串模板中不包含“<”。通过上述过程得到字符串模板中属于文本的部分。
2、如果模板字符串中不存在”<",则说明整个模板都是文本。
3、通过上述1、2得到本次的文本,然后将文本进行截取,触发chars回调函数。

chars回调函数中,会对文本进行进一步解析,因为文本中还可能带有变量。如果文本中没有带变量的话,就会构建一个type是3的普通文本类型的AST并添加到currentParent.children中,也就是添加到当前节点的父节点的children属性中;如果文本中带了变量,则构建一个type为2的带变量的文本类型的AST并添加到父节点的children属性中。而currentParent = stack[stack.length - 1],也就是用来记录DOM的层级关系的栈stack中的栈顶元素节点。

对文本进一步处理主要是通过parseText函数来进行:

export function parseText (
  text: string,
  delimiters?: [string, string]
): TextParseResult | void {
  //其中const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g
  const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
  //判断文本中是否带变量
  if (!tagRE.test(text)) {
    return
  }
  const tokens = []
  const rawTokens = []
  let lastIndex = tagRE.lastIndex = 0
  let match, index, tokenValue
  //匹配文本中的变量
  while ((match = tagRE.exec(text))) {
    index = match.index
    // push text token
    if (index > lastIndex) {
      //将{{前面的文本添加到tokens中
      rawTokens.push(tokenValue = text.slice(lastIndex, index))
      tokens.push(JSON.stringify(tokenValue))
    }
    // tag token
    //将变量转换成_s(name)的形式
    const exp = parseFilters(match[1].trim())
    tokens.push(`_s(${exp})`)
    rawTokens.push({ '@binding': exp })
    //将lastIndex的位置指向剩下还没解析的文本的开头,然后开始下一轮循环
    lastIndex = index + match[0].length
  }
  //变量解析完之后如果后面还有文本也将它添加到tokens中
  if (lastIndex < text.length) {
    rawTokens.push(tokenValue = text.slice(lastIndex))
    tokens.push(JSON.stringify(tokenValue))
  }
  //返回解析结果
  return {
    expression: tokens.join('+'),
    tokens: rawTokens
  }
}

在parseText函数中,先通过正则表达式来判断要解析的文本中有没有变量,如果没有则直接return;如果匹配到了变量,则将{{左边的文本添加到tokens中,然后将变量转化为_s(name)的形式,对后续的文本循环上述的变量解析过程,直到没有再匹配到变量,然后再将变量后边的文本也添加到tokens中。最后将解析结果返回。
Vue2.x源码——解析器_第1张图片
可以看到,文本“tctest{{name}}123”解析后的expression变量值为"tctest"+_s(name)+“123”。
_s是函数toString的别名:

/**
 * Convert a value to a string that is actually rendered.
 */
export function toString (val: any): string {
  return val == null
    ? ''
    : Array.isArray(val) || (isPlainObject(val) && val.toString === _toString)
      ? JSON.stringify(val, null, 2)
      : String(val)
}

2、纯文本内容元素处理

script,style,textarea这三种元素是纯文本内容元素。

if (!lastTag || !isPlainTextElement(lastTag)) {
      //正常元素处理
} else {
      //纯文本内容元素处理
      let endTagLength = 0
      const stackedTag = lastTag.toLowerCase()
      const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)( + stackedTag + '[^>]*>)', 'i'))
      //对文本进行处理
      const rest = html.replace(reStackedTag, function (all, text, endTag) {
        endTagLength = endTag.length
        if (!isPlainTextElement(stackedTag) && stackedTag !== 'noscript') {
          text = text
            .replace(//g, '$1') // #7298
            .replace(//g, '$1')
        }
        if (shouldIgnoreFirstNewline(stackedTag, text)) {
          //去掉开头的/n
          text = text.slice(1)
        }
        //触发chars回调
        if (options.chars) {
          options.chars(text)
        }
        //返回空字符串会将匹配到的内容截掉
        return ''
      })
      index += html.length - rest.length
      html = rest
      //解析结束标签
      parseEndTag(stackedTag, index - endTagLength, index)
    }

纯文本内容元素处理内容元素主要是将元素的内容截取出来,然后触发chars回调函数,接着解析结束标签并触发end回调。

解析器就是讲HTML模板解析成AST,解析时通过一个栈来记录DOM的层级关系。解析模板时,分为纯文本元素和正常元素两大类来解析,正常元素又分为开始标签、结束标签、HTML注释、条件注释、DOCTYPE以及文本着几类,文本中如果有变量也需要进一步对变量进行解析。

你可能感兴趣的:(vue)