解析模板中的HTML标签——vue2源码探究(4)

206 阅读4分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第16天,点击查看活动详情

在响应性处理和虚拟DOM的概念都明确了之后,渲染出真正的页面就只差一个问题了——模板编译。也就是将我们使用Vue时填入到<template>标签中的真实DOM转化为虚拟DOM,之后利用虚拟DOM进行pathch以完成响应性的优化。

一套模板编译的完整流程主要包括:

  • 对模板进行解析生成AST抽象树
    • 解析HTML开始标签
    • 解析HTML结束标签
    • 解析注释
    • 解析文本
  • 标记静态节点
  • 生成render函数转化虚拟DOM

对模板解析生成AST抽象树

我曾经在这篇文章里提到过AST抽象语法树,它实质上是一段“描述代码的代码”,通过不断地将代码结构进行拆分和属性进行剥离而生成的树形结构,通过对这个属性结构的遍历和修改,可以生成新的代码,比如这样一段HTML代码:

<div>
  <span>子元素</span>
</div>

可以生成类似这样的抽象树(太长了不放全部了):

image.png

而这个抽象树可以进行编辑,增、删、移动节点的位置,或是修改属性,最后生成我们需要的代码。那么第一部,就是需要解析模板生成这个抽象树。

解析模板的代码入口在源码的src\compiler\parser\index.ts文件中:

// 源码文件 src\compiler\parser\index.ts
export function parse(template, options) {
   // ...
  parseHTML(template, {
    // ...
    start (tag, attrs, unary) {
    // ...
    },
    end () {
    // ...
    },
    chars (text: string) {
    // ...
    },
    comment (text: string) {
    // ...
    }
  })
  // ...
}

其中最主要的就是中间这四个方法来生成AST节点,分别对HTML开始标签、HTML结束标签、文本、注释进行了解析。 而在什么情况下分别使用这四个方法进行解析呢,是通过一大堆的正则来判断:

// 源码文件 src\compiler\parser\html-parser.ts
// Regular Expressions for parsing tags and attributes
const attribute =
  /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const dynamicArgAttribute =
  /^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+?\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const doctype = /^<!DOCTYPE [^>]+>/i
// #7298: escape - to avoid being passed as HTML comment when inlined in page
const comment = /^<!\--/
const conditionalComment = /^<!\[/

判断并处理完成后,调整游标继续判断后面的模板代码:

// 源码文件 src\compiler\parser\html-parser.ts
function advance(n) {
  index += n
  html = html.substring(n)
}

HTML开始标签

开始标签的解析代码如下:

// 源码文件 src\compiler\parser\html-parser.ts
function parseStartTag() {
  const start = html.match(startTagOpen)
  if (start) {
    const match: any = {
      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
    }
  }
}

当匹配到开始标签后,取第一个匹配元素start[0],这个是标签的名称,之后,持续移动游标,通过正则attribute获取属性(如class="danger")、通过正则dynamicArgAttribute获取动态属性(如:data="data")直到找到不符合属性和动态属性的文本。

而此时,自闭合标签和非自闭和标签的游标出呈现两种不同的状态:

/> // input之类的自闭合标签
></div> // div之类非自闭合的标签

而对他们使用/^\s*(/?)>/正则后(也就是最后获取的end值),能够得到不一样的结果:

["/>", "/", index: 0, input: "/><div></div>", groups: undefined]
[">", "", index: 0, input: "></div>", groups: undefined]

所以通过第二个元素是否存在可以判断是否为自闭合标签,由此设定unarySlash的值(之后非常有用)。

按理说,match直接交给外部的start方法就可以直接生成抽象树节点了,但是Vue这里在handleStartTag方法里进行了进一步处理:

// 源码文件 src\compiler\parser\html-parser.ts
function handleStartTag(match) {
  const tagName = match.tagName
  const unarySlash = match.unarySlash

  // 排除参数中传入不解析的标签
  if (expectHTML) {
    if (lastTag === 'p' && isNonPhrasingTag(tagName)) {
      parseEndTag(lastTag)
    }
    if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
      parseEndTag(tagName)
    }
  }

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

  // 处理节点属性
  const l = match.attrs.length
  const attrs: ASTAttr[] = 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 (__DEV__ && options.outputSourceRange) {
      attrs[i].start = args.start + args[0].match(/^\s*/).length
      attrs[i].end = args.end
    }
  }

  if (!unary) {
    // 将非自闭合标签推入stack
    stack.push({
      tag: tagName,
      lowerCasedTag: tagName.toLowerCase(),
      attrs: attrs,
      start: match.start,
      end: match.end
    })
    lastTag = tagName
  }

  if (options.start) {
    // 交给外部start方法生成抽象树节点
    options.start(tagName, attrs, unary, match.start, match.end)
  }
}

handleStartTag方法主要做了以下几件事:

  • 排除参数中传入不解析的标签
  • 处理节点属性
  • 将非自闭合标签推入stack(结束标签解析时会用到)
  • 交给外部start方法生成抽象树节点

HTML结束标签

结束标签的解析相对简单一些:

// 源码文件 src\compiler\parser\html-parser.ts
// ...
const endTagMatch = html.match(endTag)
if (endTagMatch) {
  const curIndex = index
  advance(endTagMatch[0].length)
  parseEndTag(endTagMatch[1], curIndex, index)
  continue
}
// ...
function parseEndTag(tagName?: any, start?: any, end?: any) {
  let pos, lowerCasedTagName
  if (start == null) start = index
  if (end == null) end = index

  // 查找上一个匹配的开始标签
  if (tagName) {
    lowerCasedTagName = tagName.toLowerCase()
    for (pos = stack.length - 1; pos >= 0; pos--) {
      if (stack[pos].lowerCasedTag === lowerCasedTagName) {
        break
      }
    }
  } else {
    pos = 0
  }

  if (pos >= 0) {
    // Close all the open elements, up the stack
    for (let i = stack.length - 1; i >= pos; i--) {
      // 如果找的的匹配标签不位于stack栈顶,说明标签未正常闭合,发出警告
      if (__DEV__ && (i > pos || !tagName) && options.warn) {
        options.warn(`tag <${stack[i].tag}> has no matching end tag.`, {
          start: stack[i].start,
          end: stack[i].end
        })
      }
      if (options.end) {
        options.end(stack[i].tag, start, end)
      }
    }

    // 开始标签出栈
    stack.length = pos
    lastTag = pos && stack[pos - 1].tag
  } else if (lowerCasedTagName === 'br') {
    if (options.start) {
      options.start(tagName, [], true, start, end)
    }
  } else if (lowerCasedTagName === 'p') {
    if (options.start) {
      options.start(tagName, [], false, start, end)
    }
    if (options.end) {
      options.end(tagName, start, end)
    }
  }
}

主要工作就是将结束标签名称提取出来交给外部的end方法进行处理,此外上面解析开始标签的stack在这里被用到来检查标签的闭合:非自闭合的开始标签逐个入栈,结束标签逐个出栈,符合先进后出的原则,否则就是未正常闭合,举个例子来说:

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

首先解析开始标签<div>,入栈stack=["div"];解析<input/>,自闭合标签不入栈;解析<span>标签,入栈stack=["div","span"];解析结束标签</span>,出栈stack=["div"];解析结束标签</div>,出栈stack=[]。 而如果是这样的模板:

<div>
    <span>
</div>

首先解析开始标签<div>,入栈stack=["div"];解析<span>标签,入栈stack=["div","span"];解析结束标签</span>,出栈时发现栈顶是"div"而并非"span",不匹配,说明标签未正常闭合。

解析注释

解析注释其实没什么可说的,正则判断到注释之后取出注释内容,交给外部的comment方法生成节点就好了(另外除了<!-- -->之外还要正则判断那种条件注释<![ ]>):

// 源码文件 src\compiler\parser\html-parser.ts
if (comment.test(html)) {
  const commentEnd = html.indexOf('-->')

  if (commentEnd >= 0) {
    if (options.shouldKeepComment && options.comment) {
      options.comment(
        html.substring(4, commentEnd),
        index,
        index + commentEnd + 3
      )
    }
    advance(commentEnd + 3)
    continue
  }
}

if (conditionalComment.test(html)) {
  const conditionalEnd = html.indexOf(']>')

  if (conditionalEnd >= 0) {
    advance(conditionalEnd + 2)
    continue
  }
}

解析文本

文本的解析被单独列在一边,一方面原因是它跟上述三种模板不同,不管是HTML的开始结束标签,还是注释,都是以左尖括号<开头的,而除此之外,都是文本:

let textEnd = html.indexOf('<')
if (textEnd === 0) {
    // ...处理上述三种情况
}
let text, rest, next
// 处理文本解析
if (textEnd >= 0) {
  rest = html.slice(textEnd)
  while (
    !endTag.test(rest) &&
    !startTagOpen.test(rest) &&
    !comment.test(rest) &&
    !conditionalComment.test(rest)
  ) {
    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)
}

if (options.chars && text) {
  //通过向后遍历直到匹配到前三种模板为止,取出所有文本,交给外部的char方法进行处理
  options.chars(text, index - text.length, index)
}

另一方面,外部的char方法较为复杂,篇幅所限,在下一篇文章中细说。