[Vue源码学习]3-编译(中)

1,018 阅读8分钟

回顾

上次的内容整理我们梳理了Vue内的编译入口的一系列操作,最终终于找到了编译入口baseCompile

src/compiler.js

function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  const ast = parse(template.trim(), options)
  optimize(ast, options)
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
}

今天我们主要来分析下parse的内部逻辑,了解如何从template转换为ast (Abstract syntax tree)语法树的完整逻辑

parse

举个例子

我们先看看一个实际的例子,方便我们理解这个parse的过程

input:

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

经过parse后,会生成如下output:

ast = {
  'type': 1,
  'tag': 'ul',
  'attrsList': [],
  'attrsMap': {
    ':class': 'bindCls',
    'class': 'list',
    'v-if': 'isShow'
  },
  'if': 'isShow',
  'ifConditions': [{
    'exp': 'isShow',
    'block': // ul ast element
  }],
  'parent': undefined,
  'plain': false,
  'staticClass': 'list',
  'classBinding': 'bindCls',
  'children': [{
    'type': 1,
    'tag': 'li',
    'attrsList': [{
      'name': '@click',
      'value': 'clickItem(index)'
    }],
    'attrsMap': {
      '@click': 'clickItem(index)',
      'v-for': '(item,index) in data'
     },
    'parent': // ul ast element
    'plain': false,
    'events': {
      'click': {
        'value': 'clickItem(index)'
      }
    },
    'hasBindings': true,
    'for': 'data',
    'alias': 'item',
    'iterator1': 'index',
    'children': [
      'type': 2,
      'expression': '_s(item)+":"+_s(index)'
      'text': '{{item}}:{{index}}',
      'tokens': [
        {'@binding':'item'},
        ':',
        {'@binding':'index'}
      ]
    ]
  }]
}

实际流程

我们先来看看parse函数

src/compiler/parser/index.js

export function parse (
    template: string,
    options: CompilerOptions
): ASTElement | void {
    // 从options中获取参数
    // ...
    
    parseHTML(template, {
        // ...
        start (tag, attrs, unary) {
            // check namespace
            // ...
            // apply pre-transforms
            // ...
            // tree management
            // ...
        },
        end () {
            // remove trailing whitespace
            // ...
            // pop stack
            // ...
        },
        chars (text: string) {
            // handleText
        },
        comment (text: string) {
            // handleComment
        }
    })
    return root
}

这里主要是调用了parseHTML函数,处理完毕后返回root,即ast的根节点

我们先看看从options中获取的大部分参数是啥

options解析

函数上来就从options内读取了大部分的逻辑,我们贴一下我们省略的部分代码:

// 从options中获取参数
warn = options.warn || baseWarn

platformIsPreTag = options.isPreTag || no
platformMustUseProp = options.mustUseProp || no
platformGetTagNamespace = options.getTagNamespace || no

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

delimiters = options.delimiters

这里的options实际上是和平台相关的配置信息,我们看看代码

src/platforms/web/compiler/options

export const baseOptions: CompilerOptions = {
  expectHTML: true,
  modules,
  directives,
  isPreTag,
  isUnaryTag,
  mustUseProp,
  canBeLeftOpenTag,
  isReservedTag,
  getTagNamespace,
  staticKeys: genStaticKeys(modules)
}

这里不同平台会有不同的实现,所以放在了platforms目录内

这里获取的大部分内容都是后续会用到的,我们先继续往后看

parseHTML

我们在上述代码内看到,模版解析的核心工作都是在parseHTML内做的,parse又是相当于包了一层,那来详细看看parseHTML的代码

src/compiler/parser/html-parser.js

export function parseHTML (html, options) {
    // ...
    while (html) {
        // ...
        // matchComment
        // matchDoctype
        // matchEndTag
        // matchStartTag
        // ...
        // handleText / handlePlainTextElement
    }
    
    // Clean up any remaining tags
    parseEndTag()
    
    // ...
}

parseHTML内部的代码很长,有253行,这里我们先初步标明一下大概的内容

整体来说就是:

  • 循环解析template,直到解析完毕
    • 用正则做各种匹配
    • 针对不同情况进行不同的处理
    • 匹配的过程中会利用advance函数不断的步进字符串

advance的功能如图所示:

调用 advance 函数:

advance(4)

得到:

在执行过程中,匹配主要用到了html-parser.js开头申明的各类变量:

// Regular Expressions for parsing tags and attributes
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// could use https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-QName
// but for Vue templates we can enforce a simple charset
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
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 pased as HTML comment when inlined in page
const comment = /^<!\--/
const conditionalComment = /^<!\[/

我们下面来按优先级详细过一下 parseHTML 中匹配的模式

注释节点 / 文档类型节点

// Comment:
if (comment.test(html)) {
  const commentEnd = html.indexOf('-->')

  if (commentEnd >= 0) {
    if (options.shouldKeepComment) {
      options.comment(html.substring(4, commentEnd))
    }
    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(lastTag, html)) {
    advance(1)
  }
  continue
}

匹配的时候是先匹配End 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(attribute))) {
      advance(attr[0].length)
      match.attrs.push(attr)
    }
    if (end) {
      match.unarySlash = end[1]
      advance(end[0].length)
      match.end = index
      return match
    }
  }
}

这里面主要做了这几件事:

  • 通过startTagOpen匹配开始标签
  • 接着循环去匹配开始标签中的属性并添加到 match.attrs,直到匹配的开始标签的闭合符结束
  • 如果匹配到闭合符,则获取一元斜线符,前进到闭合符尾,并把当前索引赋值给 match.end

ok,到这我们就分析完了parseStartTag的逻辑,处理html,然后获取match对象,内部有 tag / attrs / start 信息,接下来看看handleStartTag的内容

function handleStartTag() {
  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]
    if (IS_REGEX_CAPTURING_BROKEN && args[0].indexOf('""') === -1) {
      if (args[3] === '') { delete args[3] }
      if (args[4] === '') { delete args[4] }
      if (args[5] === '') { delete args[5] }
    }
    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)
  }
}

这个函数内,主要做了这几件事:

  • 首先判断是否为一元标签(
  • 对 match.attrs 遍历并做了一些处理
  • 如果非一元标签,则往 stack 里 push 一个对象,并且把 tagName 赋值给 lastTag
  • 最后调用了 options.start 回调函数,并传入一些参数

这个回调函数的作用我们后面会分析,这里还可以关注下我们push的这个stack

闭合标签

闭合标签,首先和开始标签一样,也是先匹配,然后步进到结尾,再执行parseEndTag做解析

parseEndTag这里的作用也很简单,主要是对之前的开始标签stack做弹出匹配,匹配后把栈到 pos 位置的都弹出,并从 stack 尾部拿到 lastTag

最后调用了 options.end 回调函数,并传入一些参数,这个也在后面分析

文本标签

我们又回到了parseHTML内部,看看文本标签的处理逻辑

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)
  advance(textEnd)
}

if (textEnd < 0) {
  text = html
  html = ''
}

if (options.chars && text) {
  options.chars(text)
}

我们上面分析的四种情况,都是textEnd === 0,接下来判断 textEnd 是否大于等于 0 的,满足则说明到从当前位置到 textEnd 位置都是文本

如果 < 是纯文本中的字符,就继续找到真正的文本结束的位置,然后前进到结束的位置

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

最后调用了 options.chars 回调函数,并传 text 参数

因此,在循环解析整个 template 的过程中,会根据不同的情况,去执行不同的回调函数,下面我们来看看这些回调函数的作用

标签处理函数

我们在调用parseHTML的时候传入了几个函数,在做解析的过程中也被调用过,那我们在这具体的分析下这几个函数的作用把

start

在handleStartTag函数内,最后执行了这个 options.start(tagName, attrs, unary, match.start, match.end) 函数

这个函数其实就做了三件事:

  • 创建 AST 元素
  • 处理 AST 元素
  • AST 树管理
创建 AST 元素
// 调用parseHTML传入参数内部
let element: ASTElement = createASTElement(tag, attrs, currentParent)
if (ns) {
  element.ns = ns
}

// src/compiler/parser/index.js 外部定义
export function createASTElement (
  tag: string,
  attrs: Array<Attr>,
  parent: ASTElement | void
): ASTElement {
  return {
    type: 1,
    tag,
    attrsList: attrs,
    attrsMap: makeAttrsMap(attrs),
    parent,
    children: []
  }
}

这个地方逻辑主要是根据传入参数创建了个ASTElement

其中,type 表示 AST 元素类型,tag 表示标签名,attrsList 表示属性列表,attrsMap 表示属性映射表,parent 表示父的 AST 元素,children 表示子 AST 元素集合

处理 AST 元素
// apply pre-transforms
for (let i = 0; i < preTransforms.length; i++) {
  element = preTransforms[i](element, options) || element
}

if (!inVPre) {
  processPre(element)
  if (element.pre) {
    inVPre = true
  }
}
if (platformIsPreTag(element.tag)) {
  inPre = true
}
if (inVPre) {
  processRawAttrs(element)
} else if (!element.processed) {
  // structural directives
  processFor(element)
  processIf(element)
  processOnce(element)
  // element-scope stuff
  processElement(element, options)
}

首先是对模块 preTransforms 的调用,其实所有模块的 preTransforms、 transforms 和 postTransforms 的定义都在 src/platforms/web/compiler/modules 目录中

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

我们这里简要看看 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}`
      )
    }
  }
}

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, '')
    res.iterator1 = iteratorMatch[1].trim()
    if (iteratorMatch[2]) {
      res.iterator2 = iteratorMatch[2].trim()
    }
  } else {
    res.alias = alias
  }
  return res
}

processFor 就是从元素中拿到 v-for 指令的内容,然后分别解析出 for、alias、iterator1、iterator2 等属性的值添加到 AST 的元素上

就我们的示例 v-for="(item,index) in data" 而言,解析出的的 for 是 data,alias 是 item,iterator1 是 index,没有 iterator2

AST 树管理

我们在处理开始标签的时候为每一个标签创建了一个 AST 元素,在不断解析模板创建 AST 元素的时候,我们也要为它们建立父子关系,就像 DOM 元素的父子关系那样

当我们在处理开始标签的时候,判断如果有 currentParent,会把当前 AST 元素 push 到 currentParent.chilldren 中,同时把 AST 元素的 parent 指向 currentParent

接着就是更新 currentParent 和 stack ,判断当前如果不是一个一元标签,我们要把它生成的 AST 元素 push 到 stack 中,并且把当前的 AST 元素赋值给 currentParent

这里的代码比较长,我们简略的说下内容即可

end

当我们处理闭合标签的出后,最后会执行这个options.end函数,主要做了这几个事:

  • 首先处理了尾部空格的情况
  • 然后把 stack 的元素弹一个出栈,并把 stack最后一个元素赋值给 currentParent(这样就保证了当遇到闭合标签的时候,可以正确地更新 stack 的长度以及 currentParent 的值,这样就维护了整个 AST 树)
  • 最后执行closeElement(element))
function closeElement (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)
  }
}

closeElement 逻辑很简单,就是更新一下 inVPre 和 inPre 的状态

chars

文本构造的 AST 元素有 2 种类型,一种是有表达式的,type 为 2,一种是纯文本,type 为 3

通过执行 parseText(text, delimiters) 对文本解析

这段代码还挺经典,我们贴一下

src/compiler/parser/textr-parser.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?: [string, string]
): 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 首先根据分隔符(默认是 {{}})构造了文本匹配的正则表达式
  • 然后再循环匹配文本,遇到普通文本就 push 到 rawTokens 和 tokens 中
    • 如果是表达式就转换成 _s(${exp}) push 到 tokens 中
    • 以及转换成 {@binding:exp} push 到 rawTokens 中

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

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

总结

这里基本上就把parse的大部分流程分析完毕了,下次我们分析下AST语法树的优化工作