vue2从template到render:AST

310 阅读5分钟

vue中的ast是用来抽象描述模板(DOM结构)的对象结构,我们知道DOM也是一比较复杂的树,而ast是一个简单的树,用简单的树描述复杂的树有一种四两拨千斤的感觉,也是一种化繁为简的思路。

先看一个简单的DOM真实的节点属性:

<div id="app"></div>
<script>
    const dom = document.querySelector('#app');
    let count = 0;
    let keys = '';
    for (let key in dom) {
        keys += key + ',';
        count++;
    }
    console.log('count: ', count);
    console.log('key: ', keys);
</script>

执行结果:

image.png 可以看出有大量的属性,而且DOM节点的改变可能会引起复杂的重绘或重排,因此用对象描述DOM节点就显得顺势而生,不管是ast还是Virtual DOM都是一种通过少量属性描述复杂DOM结构的方式。本文主要介绍vueast产生的概况。

vue2从template到render:模板编译的入口中找到了获取render函数的真实入口是baseCompile,其中ast = parse(template.trim(), options)获取ast是第一步。

image.png 将以上代码生成ast如下:

image.png

1、parse

/**
 * Convert HTML string to AST.
 */
export function parse (
  template: string,
  options: CompilerOptions
): ASTElement | void {
    // 各种变量 ...
    const stack = []
    let root
    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 (tag, attrs, unary, start, end) {
            // ...
        },
        end (tag, start, end) {
            // ...
        },
        chars (text: string, start: number, end: number) { 
            // ...
        },
        comment (text: string, start, end) { 
            // ...
        }
    })
    // 各种方法 ...
  return root
}

这里定义了在处理模板时需要的变量和函数,将template作为第一个参数,startendcharscomment和其他变量拼接成参数对象optionsparseHTML中调用。

2、parseHTML

export function parseHTML (html, options) {
  const stack = []
  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
    if (!lastTag || !isPlainTextElement(lastTag)) {
      let textEnd = html.indexOf('<')
      if (textEnd === 0) {
        // 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
          }
        }

        // http://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
        if (conditionalComment.test(html)) {
          const conditionalEnd = html.indexOf(']>')

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

        // Doctype:
        const doctypeMatch = html.match(doctype)
        if (doctypeMatch) {
          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
        }
      }
  }
  // ...   
  function advance (n) {
    index += n
    html = html.substring(n)
  }
  function parseStartTag () { 
      // ...
  }
  function handleStartTag (match) { 
      // ...
  }
  function parseEndTag (tagName, start, end) { 
      // ...
  }
}

这里主要通过while循环对templateCommentDoctypeEnd tagStart tag进行处理,执行过程中会调用advance (n)将索引index进行移动。

image.png 执行advance(4)之后的结果

image.png 在移动的过程中,会对其中的CommentDoctypeEnd tagStart tag进行处理。

3、过程处理

(1)CommentDoctype

当遇到CommentconditionalCommentDoctype的时候,只会让索引index向前移动。

(2)Start tag

①通过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
      }
    }
  }

定义match对象,并把标签名称tagName、开始索引start、结束索引end、属性列表attrs和一元标签标示作为属性用来描述开始标签信息。
②通过handleStartTag进行标签信息处理

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 = 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 (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)
    }
  }

这里通过循环的方式重新对match中的属性attrs进行重构,循环中定义const args = match.attrs[i],然后以args[1]nameargs[3] || args[4] || args[5] || ''value。当unaryfalse(即是闭合标签)的时候进行栈的管理,当前例子管理结果如下:

image.png

③通过options.start进行ast树的管理

function start (tag, attrs, unary, start, end) {
      // check namespace.
      // inherit parent ns if there is one
      const ns = (currentParent && currentParent.ns) || platformGetTagNamespace(tag)

      // handle IE svg bug
      /* istanbul ignore if */
      if (isIE && ns === 'svg') {
        attrs = guardIESVGBug(attrs)
      }

      let element: ASTElement = createASTElement(tag, attrs, currentParent)
      if (ns) {
        element.ns = ns
      }

      if (process.env.NODE_ENV !== 'production') {
        if (options.outputSourceRange) {
          element.start = start
          element.end = end
          element.rawAttrsMap = element.attrsList.reduce((cumulated, attr) => {
            cumulated[attr.name] = attr
            return cumulated
          }, {})
        }
       //...

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

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

这里通过createASTElement(tag, attrs, currentParent)的方式创建astElement,再为其添加属性startendrawAttrsMap。如果root不存在,将element作为根节点。如果!unary(即是闭合标签),将element赋值为currentParent,并进行ast树的管理:

image.png

(3)End tag

①通过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)
      }
    }
  }

这里的例子会执行到options.end(stack[i].tag, start, end)
②通过options.end进行ASTElement的出栈

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

这里通过stack.length -= 1的方式进行出栈处理,第一次弹出SPAN-ASTElement,第二次弹出DIV-ASTElement,每一次弹出的过程中都会通过currentParent = stack[stack.length - 1]保留一个父级ASTElement

image.png

③通过closeElement管理ast

function closeElement (element) {
    trimEndingWhitespace(element)
    if (!inVPre && !element.processed) {
      element = processElement(element, options)
    }
    // 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) {
        processIfConditions(element, currentParent)
      } else {
        if (element.slotScope) {
          // scoped slot
          // keep it in the children list so that v-else(-if) conditions can
          // find it as the prev node.
          const name = element.slotTarget || '"default"'
          ;(currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element
        }
        currentParent.children.push(element)
        element.parent = currentParent
      }
    }

    // final children cleanup
    // filter out scoped slots
    element.children = element.children.filter(c => !(c: any).slotScope)
    // remove trailing whitespace node again
    trimEndingWhitespace(element)

    // check pre state
    if (element.pre) {
      inVPre = false
    }
    if (platformIsPreTag(element.tag)) {
      inPre = false
    }
    // apply post-transforms
    for (let i = 0; i < postTransforms.length; i++) {
      postTransforms[i](element, options)
    }
  }

这里通过currentParent.children.push(element)element.parent = currentParent的方式进行父子关系的构建,是ast树由栈变成树的核心步骤。
④通标签对象的出栈 执行完options.end(stack[i].tag, start, end)之后,会通过以下方式进行标签的出栈处理

// Remove the open elements from the stack
stack.length = pos
lastTag = pos && stack[pos - 1].tag

这里的pos是当通过lastTag确定的位置,第一次执行到时lastTagspan,栈中的位置索引为1,执行完stack.length=1后,栈中只剩描述div的对象。执行完lastTag = pos && stack[1 - 1].tag以后lastTag变成了div。第二次执行时,lastTagdiv使得pos0,进而清空栈。执行流程如下:

image.png

小结

整个parse的过程是将HTML模板处理得到ast树的过程,也是将栈变成树的过程。