Vue源码学习4.2:编译parse

295 阅读17分钟

建议PC端观看,移动端代码高亮错乱

编译过程首先就是对模板做解析,生成 AST,它是一种抽象语法树,是对源代码的抽象语法结构的树状表现形式。在很多编译技术中,如 babel 编译 ES6 的代码都会先生成 AST

首先来看一下 parse 的定义,在 src/compiler/parser/index.js 中:

export function parse (
  template: string,
  options: CompilerOptions
): ASTElement | void 
{
  // 从 options 中获取方法和配置...

  parseHTML(template, {
    // some options...
    
    start (tag, attrs, unary, start, end) {
      // ...
    },
    end (tag, start, end) {
      // ...
    },
    chars (text: string, start: number, end: number) {
      // ...
    },
    comment (text: string, start, end) {
      // ...
    }
  })
  return root
}

parse 函数的代码很长,主要执行了 parseHTML 函数,目的是解析 HTML 模板。

1. 解析 HTML 模板

parseHTML(template, options)

对于 template 模板的解析主要是通过 parseHTML 函数,这里先展示伪代码便于理解,下文在逐步分析源码。

// src/compiler/parser/html-parser
export function parseHTML (html, options{
  const stack = [] // 栈,用于保存开始标签
  const isUnaryTag = options.isUnaryTag || no // 用于判断是否是一元标签的函数,如<img>
  let index = 0
  let last // 用于保存上一次的 html 字符串
  let lastTag // 表示上一个标签,也就是当前栈顶的元素
  
  while (html) {
    last = html
    // Make sure we're not in a plaintext content element like script/style
    // 不存在上一个标签,或者上一个标签不是 script/style/textarea 其中之一
    // 因为像 style 标签中的内容是不用编译的
    if (!lastTag || !isPlainTextElement(lastTag)) {
      let textEnd = html.indexOf('<')
      if (textEnd === 0) {
        // 处理注释标签
        
        // 处理条件标签
        
        // 处理doctype标签
        
        // 处理结束标签
        
        // 处理开始标签        
      }
      
      // 处理文本
      
    } else {
      // 当上一个标签是 script/style/textarea 其中之一
      
    }

  }
}

parseHTML 的逻辑主要就是循环解析 template

  • 当不存在上一个标签,或者上一个标签不是 script/style/textarea 其中之一:
    • 第一个字符是 < 时,处理各种标签的情况。
    • 否则处理文本,比如 xxxx</div>
  • 否则处理上一个标签是 script/style/textarea 其中之一的情况

parseHTML 中需要提前知道的几个概念:

  1. 匹配过程中会有 stack 栈的概念,用来保存已解析的开始标签,栈的存在是用来维护开始标签和结束标签的一一对应关系。

  1. 在匹配的过程中会利用 advance 函数不断前进整个模板字符串,直到字符串末尾。
function advance (n{
  index += n
  html = html.substring(n)
}

为了更加直观地说明 advance 的作用,可以通过一副图表示:

调用 advance 函数:

advance(4)

  1. 匹配过程中用到的正则表达式如下:
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 = /^<!\[/

关于正则表达式阅读吃力的话可以借助这个网站,可以生成对应的图方便理解。

了解这三个概念以后下面继续分析 parseHTML 函数:

1.1 处理注释、条件、文档类型节点

源码如下:

// comment = /^<!\--/
if (comment.test(html)) {
  const commentEnd = html.indexOf('-->')

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

// conditionalComment = /^<!\[/
if (conditionalComment.test(html)) {
  const conditionalEnd = html.indexOf(']>')

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

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

这三种情况我们只需要调用 advance 前进到合适位置即可。

1.2 处理开始标签

源码如下:

// 处理开始标签
const startTagMatch = parseStartTag()
if (startTagMatch) {
  handleStartTag(startTagMatch)
  if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
    advance(1)
  }
  continue
}

1.2.1 parseStartTag

首先通过 parseStartTag 解析开始标签:

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
    }
  }
}

结合下面的例子来分析 parseTag 函数:

<img class="test"/>

函数先通过正则表达式 startTagOpen 匹配到开始标签,此时的 start["<img", "img"]

接着循环去匹配开始标签中的属性并添加到 match.attrs 中,直到匹配的开始标签的闭合符结束。这里属性的正则有两个分别是 dynamicArgAttributeattribute。借助这个网站,我们来看看 attribute 的正则图示:

执行完 while 循环后,match.attrs 如图:

最后如果匹配到闭合符,则获取一元斜线符,前进到闭合符尾,并把当前索引赋值给 match.end。函数执行结束后 match 如图:

1.2.2 handleStartTag

回到 parseHTML 函数中,当 parseStartTag 对开始标签解析拿到 match 后,紧接着会执行 handleStartTagmatch 做处理,handleStartTag 函数源码如下:

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

  // ...

  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) // decode value
    }
    if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
      attrs[i].start = args.start + args[0].match(/^\s*/).length // 跳过空格
      attrs[i].end = args.end
    }
  }

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

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

handleStartTag 的核心逻辑很简单:

  • 先判断开始标签是否是一元标签,类似 <img>、<br/> 这样。
  • 根据 match.attrs 构建新的 attrs 数组。

  • 判断如果非一元标签,则往 stackpush 一个对象,并且把 tagName 赋值给 lastTag

  • 最后调用了 options.start 回调函数,并传入一些参数,这个回调函数的作用稍后我会详细介绍。

1.3 处理闭合标签

源码如下:

// 处理闭合标签
const endTagMatch = html.match(endTag)
if (endTagMatch) {
  const curIndex = index
  advance(endTagMatch[0].length)
  parseEndTag(endTagMatch[1], curIndex, index)
  continue
}

先通过正则 endTag 匹配到闭合标签,然后前进到闭合标签末尾,最后执行 parseEndTag 方法对闭合标签做解析。

1.3.1 parseEndTag

parseEndTag 源码如下

function parseEndTag (tagName, start, end{
  let pos, lowerCasedTagName
  if (start == null) start = index
  if (end == null) end = index

  // Find the closest opened tag of the same type
  if (tagName) {
    lowerCasedTagName = tagName.toLowerCase()
    for (pos = stack.length - 1; pos >= 0; pos--) {
      if (stack[pos].lowerCasedTag === lowerCasedTagName) {
        break
      }
    }
  } else {
    // If no tag name is provided, clean shop
    pos = 0
  }

  if (pos >= 0) {
    // Close all the open elements, up the stack
    for (let i = stack.length - 1; i >= pos; i--) {
      if (process.env.NODE_ENV !== 'production' &&
        (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)
      }
    }

    // Remove the open elements from the stack
    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)
    }
  }
}

parseEndTag 的核心逻辑如下:

  • 找到和当前闭合标签相匹配的开始标签,记录下标 pos。(如果是正常的标签匹配,那么 stack 的最后一个元素应该和闭合标签匹配)
  • 处理 pos >= 0 时的情况:
    • 处理异常情况,比如 <div><span></div>,然后调用了 options.end 回调函数,这个函数稍后详细介绍。
    • 把栈顶到 pos 位置的都弹出,并从 stack 尾部拿到 lastTag
  • 否则就是 pos < 0 的几种情况,这里不做分析。

考虑以下错误情况:

<div><span></div>

这个时候当闭合标签为 </div> 的时候,从 stack 尾部找到的标签是 <span>,就不能匹配。

1.4 处理文本

看回 parseHTML 的代码

let textEnd = html.indexOf('<')
if (textEnd === 0) {
  // 处理注释标签

  // 处理条件标签

  // 处理doctype标签

  // 处理结束标签

  // 处理开始标签        
}

// 处理文本

textEnd === 0 的几种情况我们已经分析完毕了,如果 textEnd 不为 0 时,说明存在文本需要处理,比如 "xxx</div>" 这种情况。

下面看看这部分的源码:

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 < 0break
    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) {
  options.chars(text, index - text.length, index)
}
  • 接下来判断 textEnd 是否大于等于 0 的,满足则说明到从当前位置到 textEnd 位置都是文本,并且如果 < 是纯文本中的字符,就继续找到真正的文本结束的位置,然后前进到结束的位置。

  • 再继续判断 textEnd 小于 0 的情况,则说明整个 template 解析完毕了,把剩余的 html 都赋值给了 text

  • 最后调用了 options.chars 回调函数,并传 text 参数,这个回调函数的作用稍后我会详细介绍。

1.4 当上个标签是 script/style/textarea 其中之一

看回 parseHTML 的代码

while (html) {
  last = html
  // Make sure we're not in a plaintext content element like script/style
  // 不存在上一个标签,或者上一个标签不是 script/style/textarea 其中之一
  // 因为像 style 标签中的内容是不用编译的
  if (!lastTag || !isPlainTextElement(lastTag)) {
    let textEnd = html.indexOf('<')
    if (textEnd === 0) {
      // 处理注释标签

      // 处理条件标签

      // 处理doctype标签

      // 处理结束标签

      // 处理开始标签        
    }

    // 处理文本

  } else {
    // 当上一个标签是 script/style/textarea 其中之一

  }
}

if (!lastTag || !isPlainTextElement(lastTag)) 的情况我们已经分析完毕了。

下面看看 else 的逻辑:

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 (options.chars) {
      options.chars(text)
    }
    return ''
  })
  index += html.length - rest.length
  html = rest
  parseEndTag(stackedTag, index - endTagLength, index)
}

这部分逻辑也很简单,我们结合例子来分析:

<div><style>xxx</style></div>

当解析到 </style> 时:

  1. lastTagstackedTag 都是 style

  2. 构建正则表达式 reStackedTag/([\s\S]*?)(</style[^>]*>)/i

  3. 然后执行如下逻辑:

const rest = html.replace(reStackedTag, function (all, text, endTag) {
  // all: "xxx</style>"
  // text: "xxx"
  // endTag: "</style>"
  
  endTagLength = endTag.length // 8
  // ...
  if (options.chars) {
    options.chars(text)
  }
  return ''
})

主要作用就是将 "xxx</style></div>""xxx</style>" 替换为空串得到 "</div>",也就是 rest

  1. 继续执行如下逻辑:
index += html.length - rest.length // index += "xxx</style></div>".length - "</div>".length
html = rest // html = "</div>"
parseEndTag(stackedTag, index - endTagLength, index)

其实就是前进到相应的位置,然后执行 parseEndTag(stackedTag, index - endTagLength, index) 处理 </style>

到这里 while 循环中的主要逻辑也介绍完了,下面分别介绍上文提到过的几个回调函数

  • options.start
  • options.end
  • options.chars

这些回调函数都是在调用 parseHTML 时最为配置对象的属性传入的。

2. options.start

当解析到开始标签的时候,最后会执行 start 回调函数,省略部分代码后函数如下:

start (tag, attrs, unary, start, end) {
  // 创建 AST
  let element: ASTElement = createASTElement(tag, attrs, currentParent)

  // 处理 style/script 元素
  if (isForbiddenTag(element) && !isServerRendering()) {
    element.forbidden = true
    process.env.NODE_ENV !== 'production' && warn(
      'Templates should only be responsible for mapping the state to the ' +
      'UI. Avoid placing tags with side-effects in your templates, such as ' +
      `<${tag}>` + ', as they will not be parsed.',
      { start: element.start }
    )
  }

  if (!inVPre) {
    processPre(element) // v-pre
    if (element.pre) {
      inVPre = true
    }
  }
  
  if (inVPre) {
    // 如果处于 v-pre,则跳过编译
    processRawAttrs(element)
  } else if (!element.processed) {
    // 结构指令
    processFor(element) // v-for
    processIf(element) // v-if
    processOnce(element) // v-once
  }

  if (!root) {
    root = element
    if (process.env.NODE_ENV !== 'production') {
      checkRootConstraints(root)
    }
  }

  if (!unary) {
    currentParent = element
    stack.push(element)
  } else {
    closeElement(element)
  }
}

下面分步骤来介绍这个函数:

  • 创建 AST 元素
  • 处理 AST 元素
  • 压栈处理

2.1 创建 AST 元素

let element: ASTElement = createASTElement(tag, attrs, currentParent)

通过 createASTElement 函数创建 AST

export function createASTElement (
  tag: string,
  attrs: Array<ASTAttr>,
  parent: ASTElement | void
): ASTElement 
{
  return {
    type1,
    tag,
    attrsList: attrs,
    attrsMap: makeAttrsMap(attrs),
    rawAttrsMap: {},
    parent,
    children: []
  }
}

function makeAttrsMap (attrs: Array<Object>): Object {
  const map = {}
  for (let i = 0, l = attrs.length; i < l; i++) {
    if (
      process.env.NODE_ENV !== 'production' &&
      map[attrs[i].name] && !isIE && !isEdge
    ) {
      warn('duplicate attribute: ' + attrs[i].name, attrs[i])
    }
    map[attrs[i].name] = attrs[i].value
  }
  return map
}

每一个 AST 元素就是一个普通的 JavaScript 对象:

  • type 表示 AST 元素类型
  • tag 表示标签名
  • attrsList 表示属性列表
  • attrsMap 表示属性映射表,keyattrs[i].namevalueattrs[i].value
  • parent 表示父的 AST 元素
  • children 表示子 AST 元素集合

2.2 处理 AST 元素

// 判断元素是否是 style/script
if (isForbiddenTag(element) && !isServerRendering()) {
  element.forbidden = true
  process.env.NODE_ENV !== 'production' && warn(
    'Templates should only be responsible for mapping the state to the ' +
    'UI. Avoid placing tags with side-effects in your templates, such as ' +
    `<${tag}>` + ', as they will not be parsed.',
    { start: element.start }
  )
}

// 调用模块的 preTransforms 函数
for (let i = 0; i < preTransforms.length; i++) {
  element = preTransforms[i](element, options) || element
}

if (!inVPre) {
  processPre(element) // v-pre
  if (element.pre) {
    inVPre = true
  }
}
if (platformIsPreTag(element.tag)) {
  inPre = true
}
if (inVPre) {
  // 如果是 v-pre,则跳过编译
  processRawAttrs(element)
else if (!element.processed) {
  // 结构指令
  processFor(element) // v-for
  processIf(element) // v-if
  processOnce(element) // v-once
}

首先是判断元素是否是 script/style,如果是的话则抛出警告。

然后是对模块 preTransforms 的调用,其实所有模块的 preTransformstransformspostTransforms 的定义都在 src/platforms/web/compiler/modules 目录中,这部分我们暂时不会介绍。

接着判断 element 是否包含各种指令通过 processXXX 做相应的处理,处理的结果就是扩展 AST 元素的属性。

这里简单介绍下 processForprocessIf

2.2.1 介绍processFor

代码如下:

export function processFor (el: ASTElement{
  let exp
  if ((exp = getAndRemoveAttr(el, 'v-for'))) {
    const res = parseFor(exp)
    if (res) {
      extend(el, res)
    } else if (process.env.NODE_ENV !== 'production') {
      warn(
        `Invalid v-for expression: ${exp}`,
        el.rawAttrsMap['v-for']
      )
    }
  }
}

首先通过 getAndRemoveAttr 拿到 v-for 的值,来看下 getAndRemoveAttr 函数的源码:

// 只移除从数组 attrsList 中移除 attr,因此不会被 processAttrs 处理
// 默认情况下不会从attrsMap中移除,因为这个map在codegen过程中会被使用
export function getAndRemoveAttr (
  el: ASTElement,
  name: string,
  removeFromMap?: boolean
): ?string 
{
  let val
  if ((val = el.attrsMap[name]) != null) {
    const list = el.attrsList
    for (let i = 0, l = list.length; i < l; i++) {
      if (list[i].name === name) {
        list.splice(i, 1)
        break
      }
    }
  }
  if (removeFromMap) {
    delete el.attrsMap[name]
  }
  return val
}

这个函数的逻辑非常简单,就是得到 value 值并从 el.attrsList 移除 attr,这个函数下面还会用到,后面就不重复介绍了。

回到 processFor 函数,得到 v-for 的值 exp 后,作为参数传入 parseFor 函数并执行,目的是用来解析 exp 并得到 res 对象。下面来看看 parseFor 的源码:

export const forAliasRE = /(.*?)\s+(?:in|of)\s+(.*)/
export const forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/
const stripParensRE = /^\(|\)$/g

export function parseFor (exp: string): ?ForParseResult {
  const inMatch = exp.match(forAliasRE)
  if (!inMatch) return
  const res = {}
  res.for = inMatch[2].trim()
  const alias = inMatch[1].trim().replace(stripParensRE, '')
  const iteratorMatch = alias.match(forIteratorRE)
  if (iteratorMatch) {
    res.alias = alias.replace(forIteratorRE, '').trim()
    res.iterator1 = iteratorMatch[1].trim()
    if (iteratorMatch[2]) {
      res.iterator2 = iteratorMatch[2].trim()
    }
  } else {
    res.alias = alias
  }
  return res
}

其实就是分别解析出 foraliasiterator1iterator2 等属性的值添加到 res 的元素上。比如对于 v-for="(item,index) in data" 而言,解析出:

  • fordata
  • aliasitem
  • iterator1index
  • 没有 iterator2

再次回到 processFor 函数,parseFor 执行完毕后得到 res 对象,执行 extend(el, res),目的就是将 res 得属性拓展到 AST 元素上。

至此,processFor 函数就简单介绍完了。

2.2.2 介绍processIf

代码如下:

function processIf (el{
  const exp = getAndRemoveAttr(el, 'v-if')
  if (exp) {
    el.if = exp
    addIfCondition(el, {
      exp: exp,
      block: el
    })
  } else {
    if (getAndRemoveAttr(el, 'v-else') != null) {
      el.else = true
    }
    const elseif = getAndRemoveAttr(el, 'v-else-if')
    if (elseif) {
      el.elseif = elseif
    }
  }
}

export function addIfCondition (el: ASTElement, condition: ASTIfCondition{
  if (!el.ifConditions) {
    el.ifConditions = []
  }
  el.ifConditions.push(condition)
}

processIf 就是从元素中拿 v-if 指令的内容,如果拿到则给 AST 元素添加 if 属性和 ifConditions 属性;否则尝试拿 v-else 指令及 v-else-if 指令的内容,如果拿到则给 AST 元素分别添加 elseelseif 属性。

2.3 处理压栈

最后来看看 options.start 的剩余逻辑:

if (!root) {
  root = element
  if (process.env.NODE_ENV !== 'production') {
    checkRootConstraints(root)
  }
}

if (!unary) {
  currentParent = element
  stack.push(element)
else {
  closeElement(element)
}

如果不存在 root 根元素,则将当前 element 作为 root,并调用 checkRootConstraints

function checkRootConstraints (el{
  if (el.tag === 'slot' || el.tag === 'template') {
    warnOnce(
      `Cannot use <${el.tag}> as component root element because it may ` +
      'contain multiple nodes.',
      { start: el.start }
    )
  }
  if (el.attrsMap.hasOwnProperty('v-for')) {
    warnOnce(
      'Cannot use v-for on stateful component root element because ' +
      'it renders multiple elements.',
      el.rawAttrsMap['v-for']
    )
  }
}

这个函数就是用来检查 root 是不是 slot/template/v-for 的情况。

接着如果当前元素是非一元标签,将 element 保存为 currentParent,同时入栈。

否则如果是一元标签时,会执行 closeElement,这个函数稍后在 options.end 中再介绍。

需要注意的是这里的 stack 和前文的 stack 不是同一个栈,这里的 stack 保存的是 AST 元素。

3. options.end

当解析到闭合标签的时候,最后会执行 end 回调函数:

end (tag, start, end) {
  const element = stack[stack.length - 1]
  // pop stack
  stack.length -= 1
  currentParent = stack[stack.length - 1]
  if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
    element.end = end
  }
  closeElement(element)
},

end 回调函数主要执行了两个逻辑:

  • 栈顶元素出栈,更新 currentParent
  • 执行 closeElement 方法。

3.1 closeElement

function closeElement (element{
  
  // tree management
  if (!stack.length && element !== root) {
    // allow root elements with v-if, v-else-if and v-else
    if (root.if && (element.elseif || element.else)) {
      if (process.env.NODE_ENV !== 'production') {
        checkRootConstraints(element)
      }
      addIfCondition(root, {
        exp: element.elseif,
        block: element
      })
    } else if (process.env.NODE_ENV !== 'production') {
      warnOnce(
        `Component template should contain exactly one root element. ` +
        `If you are using v-if on multiple elements, ` +
        `use v-else-if to chain them instead.`,
        { start: element.start }
      )
    }
  }
  if (currentParent && !element.forbidden) {
    if (element.elseif || element.else) {
      // ...
    } else {
      // ...
      currentParent.children.push(element)
      element.parent = currentParent
    }
  }

  // ...
}

当满足 !stack.length && element !== root 时,说明模板不止一个根元素

  • 允许有多个根元素,但是必须是 v-if, v-else-if and v-else 的情况
  • 否则报错

currentParent 存在时,构建 AST 元素之间的父子关系。

4. options.chars

除了处理开始标签和闭合标签,我们还会在解析模板的过程中去处理一些文本内容:

chars (text: string, start: number, end: number) {
  if (!currentParent) {
    if (process.env.NODE_ENV !== 'production') {
      if (text === template) {
        warnOnce(
          'Component template requires a root element, rather than just text.',
          { start }
        )
      } else if ((text = text.trim())) {
        warnOnce(
          `text "${text}" outside root element will be ignored.`,
          { start }
        )
      }
    }
    return
  }
  
  const children = currentParent.children
  
  if (text) {
    let res
    let child: ?ASTNode
    if (!inVPre && text !== ' ' && (res = parseText(text, delimiters))) {
      child = {
        type2,
        expression: res.expression,
        tokens: res.tokens,
        text
      }
    } else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
      child = {
        type3,
        text
      }
    }
    if (child) {
      if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
        child.start = start
        child.end = end
      }
      children.push(child)
    }
  }
},

文本构造的 AST 元素有 2 种类型:

  • 有表达式,type2
  • 纯文本,type3

比如这个例子:

<ul :class="bindCls" class="list" v-if="isShow">
    <li v-for="(item,index) in data" @click="clickItem(index)">{{item}}:{{index}}</li>
</ul>

通过执行 parseText(text, delimiters) 对文本解析,它的定义在 src/compiler/parser/text-parsre.js 中:

const defaultTagRE = /\{\{((?:.|\n)+?)\}\}/g
const regexEscapeRE = /[-.*+?^${}()|[\]\/\\]/g

const buildRegex = cached(delimiters => {
  const open = delimiters[0].replace(regexEscapeRE, '\\$&')
  const close = delimiters[1].replace(regexEscapeRE, '\\$&')
  return new RegExp(open + '((?:.|\\n)+?)' + close, 'g')
})

export function parseText (
  text: string,
  delimiters?: [stringstring]
): TextParseResult | void 
{
  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) {
      rawTokens.push(tokenValue = text.slice(lastIndex, index))
      tokens.push(JSON.stringify(tokenValue))
    }
    // tag token
    const exp = parseFilters(match[1].trim())
    tokens.push(`_s(${exp})`)
    rawTokens.push({ '@binding': exp })
    lastIndex = index + match[0].length
  }
  if (lastIndex < text.length) {
    rawTokens.push(tokenValue = text.slice(lastIndex))
    tokens.push(JSON.stringify(tokenValue))
  }
  return {
    expression: tokens.join('+'),
    tokens: rawTokens
  }
}

parseText 首先根据分隔符(默认是 {{}}),构造了文本匹配的正则表达式,然后再循环匹配文本,遇到普通文本就 pushrawTokenstokens 中,如果是表达式就转换成 _s(${exp}) pushtokens 中,以及转换成 {@binding:exp} pushrawTokens 中。

对于我们的例子,tokens 就是 [_s(item),'":"',_s(index)]rawTokens 就是 [{'@binding':'item'},':',{'@binding':'index'}]。那么返回的对象如下:

return {
 expression: '_s(item)+":"+_s(index)',
 tokens: [{'@binding':'item'},':',{'@binding':'index'}]
}

5. 两个特殊情况

handleStartTag 函数中有这么一段逻辑:

function handleStartTag (match{
  // ...
  
  if (expectHTML) {
    if (lastTag === 'p' && isNonPhrasingTag(tagName)) {
      parseEndTag(lastTag)
    }
    if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
      parseEndTag(tagName)
    }
  }
  // ...
}

expectHTML 在 Web 环境中为 true,这时有两个特殊情况的处理,我们来分析一下:

5.1 情况一

首先来看这个条件 lastTag === 'p' && isNonPhrasingTag(tagName)isNonPhrasingTag 函数定义在 src/platforms/web/compiler/util.js

// HTML5 tags https://html.spec.whatwg.org/multipage/indices.html#elements-3
// Phrasing Content https://html.spec.whatwg.org/multipage/dom.html#phrasing-content
export const isNonPhrasingTag = makeMap(
  'address,article,aside,base,blockquote,body,caption,col,colgroup,dd,' +
  'details,dialog,div,dl,dt,fieldset,figcaption,figure,footer,form,' +
  'h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,legend,li,menuitem,meta,' +
  'optgroup,option,param,rp,rt,source,style,summary,tbody,td,tfoot,th,thead,' +
  'title,tr,track'
)

可以看到以上元素不属于 Phrasing 元素,关于这方面的知识可以参考这篇文章

简单来说就是:所有可以放在 p 标签内,构成段落内容的元素均属于 Phrasing 元素。那么如果 p 标签中放了非 Phrasing 元素的话会怎么样呢?比如有如下例子:

<p><div>111</div></p>

在浏览器中会被处理成这样:

所以在 Vue 的编译过程中为了与标准实现一致,会手动调用 parseEndTag 闭合标签,所以 <p><div>111</div></p> 就相当于 <p></p><div>111</div></p>

那么最后单独的 </p> 是又怎么变成 <p></p> 的呢?

实际上当解析到 </p> 并调用 parseEndTag 时有这么一段逻辑:

else if (lowerCasedTagName === 'p') {
  if (options.start) {
    options.start(tagName, [], false, start, end)
  }
  if (options.end) {
    options.end(tagName, start, end)
  }
}

pos 小于 0 时会走到这里的 else if 逻辑,手动调用了 options.startoptions.end 生成开始标签和结束标签的 AST,对于我们的例子来说也就是 <p></p>

5.2 情况二

接下来看这个这个条件 canBeLeftOpenTag(tagName) && lastTag === tagNamecanBeLeftOpenTag 函数也定义在 src/platforms/web/compiler/util.js

// Elements that you can, intentionally, leave open
// (and which close themselves)
export const canBeLeftOpenTag = makeMap(
  'colgroup,dd,dt,li,options,p,td,tfoot,th,thead,tr,source'
)

比如说这个嵌套 p 标签的例子:

<p>111<p>222</p></p>

在浏览器中会解析成这样:

所以在 Vue 的编译过程中为了与标准实现一致,会手动调用 parseEndTag 闭合标签,所以 <p>111<p>222</p></p> 就相当于 <p>111</p><p>222</p></p>

同理最后单独的 </p> 变成 <p></p> 和情况一相同。

总结