Vue源码阅读(12):解析器

 我的开源库:

今天聊聊解析器,解析器的作用是将程序员编写的模板字符串解析成抽象语法树,抽象语法树可以理解成模板字符串的对象表示形式,其本质并没有什么神奇的,只不过是 JS 中最为常见的对象字面量。

通过抽象语法树,Vue 可以以一种统一的格式来表示不同编码风格的模板字符串,这种统一是接下来进行优化器和代码生成器处理的基础。接下来,我们看一个简单模板字符串解析成的抽象语法树是什么样的。

new Vue({
  template: `
    <div class="container">
      <h1>我是静态文本</h1>
      <h1>名字:{{name}}</h1>
    </div>
  `
})

解析成的抽象语法树如下所示:

let ast = {
  attrsList: [],
  attrsMap: {class: "container"},
  children:[
    {
      attrsList: [],
      attrsMap: {},
      children: [{static: true, text: "我是静态文本", type: 3}],
      plain: true,
      static: true,
      staticInFor: false,
      staticRoot: false,
      tag: "h1",
      type: 1
    },
    {
      attrsList: [],
      attrsMap: {},
      children: [{type: 2, expression: ""名字:"+_s(name)", text: "名字:{{name}}", static: false}],
      plain: true,
      static: false,
      staticRoot: false,
      tag: "h1",
      type: 1
    }
  ],
  parent: undefined,
  plain: false,
  static: false,
  staticClass: ""container"",
  staticRoot: false,
  tag: "div",
  type: 1
}

可以看到,抽象语法树只是 JS 中普通的对象字面量,所以,大家要以平常心看待它。

接下来,开始看解析器的源码实现。

1,src/compiler/index.js ==> function baseCompile(){}

export const createCompiler = createCompilerCreator(
  // 真正执行编译功能的函数,分为三步走:(1)解析器 ==>(2)优化器 ==>(3)代码生成器
  function baseCompile (
    template: string,
    options: CompilerOptions
  ): CompiledResult {
    // 1,解析器。将模板字符串转换成抽象语法树
    const ast = parse(template.trim(), options)
    // 2,优化器。遍历抽象语法树,标记静态节点,
    // 因为静态节点是不会变化的,所以重新渲染视图的时候,能够直接跳过静态节点,提升效率。
    optimize(ast, options)
    // 3,代码生成器。使用抽象语法树生成渲染函数字符串
    const code = generate(ast, options)
    return {
      ast,
      render: code.render,
      staticRenderFns: code.staticRenderFns
    }
  }
)

实现模板编译功能的方法是 baseCompile,其内部调用了三个函数,分别对应:解析器、优化器、代码生成器。

2,src/compiler/parser/index.js ==> function parse(){}

/**
 * Convert HTML string to AST.
 */
export function parse (
  template: string,
  options: CompilerOptions
): ASTElement | void {
  // 解析 options 中的配置,并将配置项赋值给变量 //
  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

  // 解析过程中用到的变量 //
  // 节点栈,用于维护父子关系
  const stack = []
  // 保存抽象语法树的变量,也是抽象语法树的根节点
  let root
  // 当前处理节点的父节点
  let currentParent

  const preserveWhitespace = options.preserveWhitespace !== false
  let inVPre = false
  let inPre = false
  let warned = false

  // 辅助函数 //
  function warnOnce (msg) {}

  function endPre (element) {}

  // 调用 parseHTML 开始解析模板字符串
  parseHTML(template, {
    warn,
    expectHTML: options.expectHTML,
    isUnaryTag: options.isUnaryTag,
    canBeLeftOpenTag: options.canBeLeftOpenTag,
    shouldDecodeNewlines: options.shouldDecodeNewlines,
    shouldKeepComment: options.comments,
    // 下面的回调函数用于 AST 节点的生成和整个抽象语法树父子 AST 节点的维护
    // 针对开始标签
    start (tag, attrs, unary) {},
    // 针对结束标签
    end () {},
    // 针对文本内容
    chars (text: string) {},
    // 针对评论节点
    comment (text: string) {}
  })
  // AST type 解释
  // 1:元素节点
  // 2:含有表达式的文本节点
  // 3:纯文本节点

  return root
}

// 回调函数使用的工具函数 //
function processPre (el) {}
function processRawAttrs (el) {}
export function processElement (element: ASTElement, options: CompilerOptions) {}
function processKey (el) {}
function processRef (el) {}
export function processFor (el: ASTElement) {}
function processIf (el) {}
function processIfConditions (el, parent)  {}
function findPrevElement (children: Array<any>): ASTElement | void {}
export function addIfCondition (el: ASTElement, condition: ASTIfCondition) {}
function processOnce (el) {}
function processSlot (el) {}
function processComponent (el) {}
function processAttrs (el) {}
function checkInFor (el: ASTElement): boolean {}
function parseModifiers (name: string): Object | void {}
function makeAttrsMap (attrs: Array<Object>): Object {}
function isTextTag (el): boolean {}
function isForbiddenTag (el): boolean {}
function guardIESVGBug (attrs) {}
function checkForAliasModel (el, value) {}

我们在上文说过,解析器内部细分了很多小的解析器,各自处理对应的工作,其中作为主线的是 HTML 解析器(对应上面 parseHTML 函数调用),整个解析器的处理过程就是 HTML 解析器不断的用正则表达式处理模板字符串的过程,每处理完一小段模板字符串,就会将其从模板字符串中截取掉,直到模板字符串被解析成空字符串(""),解析器的工作也就完成了。

在 HTML 解析器解析到指定的节点时,会将解析的信息作为参数执行回调函数(上面代码中的 start、end、chars、comment),这些回调函数负责生成 AST 节点和维护 AST 节点父子关系。

接下来,开始看 parseHTML 函数的内容。

3,src/compiler/parser/html-parser.js ==> function parseHTML(){}

parseHTML 函数内容很复杂,但是思路却很清晰,就是使用 while(html) 不断的循环处理模板字符串,解析的方式是使用正则表达式处理模板字符串,每处理一小段模板字符串,就会调用对应的回调函数,在回调函数中进行 AST 节点的生成和 AST 树的维护,这一小段模板字符串处理完成后,就会将其从模板字符串中截取掉,直至截取成空字符串(""),接下来看看 parseHTML 的代码,先搞清除总体逻辑。

export function parseHTML (html, options) {
  const stack = []
  let index = 0
  // last 变量用于记录 html 字符串上一次解析之前的状态
  let last, lastTag

  // 解析 html 的过程,就是不断的截取和解析的过程,直至 html 字符串被解析完
  // 所以在这里,使用 while (html) 不断的遍历 html 字符串
  while (html) {
    last = html

    // !lastTag:针对首次进入解析的状态
    // !isPlainTextElement(lastTag):上一个处理的标签不是 script、style、textarea
    if (!lastTag || !isPlainTextElement(lastTag)) {
      // 获取当前的 html 中首个 '<' 的下标位置
      let textEnd = html.indexOf('<')
      // 如果 < 的下标是 0 的话,说明当前 html 字符串的开头是一个标签
      if (textEnd === 0) {
         接下来判断这个开头的标签是什么类型的标签

        // 判断是不是注释标签
        if (comment.test(html)) {}

        // 判断开头的标签是不是 <![if !IE]>,如果是的话,就什么都不用做,直接截取跳过即可 
        if (conditionalComment.test(html)) {}

        // 判断是不是 DOCTYPE 节点,如果是的话,也是直接截取掉并跳过
        const doctypeMatch = html.match(doctype)
        if (doctypeMatch) {
          advance(doctypeMatch[0].length)
          continue
        }

         接下来就是比较重点的开始标签和结束标签的判断和处理

        // 对结束标签进行匹配和处理
        const endTagMatch = html.match(endTag)
        if (endTagMatch) {
          // 截取掉匹配的结束标签
          advance(endTagMatch[0].length)
          // 调用 parseEndTag 辅助函数对该结束标签进行处理
          parseEndTag(endTagMatch[1], curIndex, index)
          continue
        }

        // 对开始标签进行匹配和处理
        // parseStartTag 函数能够返回解析后的开始标签的信息
        const startTagMatch = parseStartTag()
        if (startTagMatch) {
          // 如果当前 html 的开头的确是开始标签的话,则调用 handleStartTag 进行额外的处理
          handleStartTag(startTagMatch)
          continue
        }
      }

      // 这一部分逻辑是处理标签内文本内容的
      let text, rest, next
      if (textEnd >= 0) {
        // 获取当前的 html 字符串除最前面的文本内容剩下的部分
        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)
        // 从 html 中截取掉文本节点
        advance(textEnd)
      }

      // 处理找不到 '<' 的情况,说明已经没有待处理的标签了,
      // 将 html 置为 '',外面的 while(html) 下次循环就会结束
      if (textEnd < 0) {
        text = html
        html = ''
      }

      if (options.chars && text) {
        // 调用 options 中的 chars 回调函数,进行文本节点的处理
        options.chars(text)
      }
    } else {
      // 下面的代码针对 上一个处理的 tag 是 script、style、textarea 的情况
      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(/<!--([\s\S]*?)-->/g, '$1')
            .replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1')
        }
        if (shouldIgnoreFirstNewline(stackedTag, text)) {
          text = text.slice(1)
        }
        if (options.chars) {
          options.chars(text)
        }
        return ''
      })
      index += html.length - rest.length
      html = rest
      parseEndTag(stackedTag, index - endTagLength, index)
    }

    if (html === last) {
      options.chars && options.chars(html)
      if (process.env.NODE_ENV !== 'production' && !stack.length && options.warn) {
        options.warn(`Mal-formatted tag at end of template: "${html}"`)
      }
      break
    }
  }

  // 用于截取html字符串的工具函数
  function advance (n) {}

  // 解析开始标签的工具函数
  function parseStartTag () {}

  // 进一步处理开始标签,并会调用 options.start 回调函数
  function handleStartTag (match) {}

  // 解析结束标签的工具函数
  function parseEndTag (tagName, start, end) {}
}

3-1,while (html) {}

借助 while 不断地循环处理 html 字符串,每处理一小段,就会将其从 html 中截取掉,直至 html 被截成空字符串(""),解析也就完成了。

3-2,if (!lastTag || !isPlainTextElement(lastTag)) {} else {}

isPlainTextElement(lastTag) 用于判断当前处理节点的父节点是不是 script、style、textarea,script、style、textarea 类型节点的子节点需要特殊处理,在这里用 if else 将处理逻辑分开。

3-3,if (!lastTag || !isPlainTextElement(lastTag)) {}

如果当前处理节点的父节点不是 script、style、textarea 的话,代码流程逻辑如下:

// 获取当前的 html 中首个 '<' 的下标位置
let textEnd = html.indexOf('<')

// 如果 < 的下标是 0 的话,说明当前 html 字符串的开头是一个标签
if (textEnd === 0) {
    // 在这里,判断具体是什么类型的标签
    
    // 1,判断是不是注释标签
    if (comment.test(html)) {}
    
    // 2,判断标签是不是 <![if !IE]>
    if (conditionalComment.test(html)) {
      const conditionalEnd = html.indexOf(']>')
      if (conditionalEnd >= 0) {
        // 如果是 <![if !IE]> 标签的话,就什么都不用做,直接截取掉并跳过
        advance(conditionalEnd + 2)
        continue
      }
    }

    // 3,判断是不是 DOCTYPE 节点,如果是的话,也是直接截取掉并跳过
    const doctypeMatch = html.match(doctype)
    if (doctypeMatch) {}

    // 4,判断是不是结束标签,如果是的话,会进行解析和处理
    const endTagMatch = html.match(endTag)
    if (endTagMatch) {
        // 截取掉匹配的结束标签
        advance(endTagMatch[0].length)
        // 对该结束标签进行处理
        parseEndTag(endTagMatch[1], curIndex, index)
    }

    // 5,解析判断是不是开始标签,如果是的话,则会进行进一步的处理
    const startTagMatch = parseStartTag()
    if (startTagMatch) {
      // 如果当前 html 的开头的确是开始标签的话,则调用 handleStartTag 进行处理
      handleStartTag(startTagMatch)
      continue
    }
}

let text, rest, next
// 处理类似于这种模板字符串: "我是小明</h1></div>",textEnd 大于 0,textEnd 之前的内容都是当前应当处理的文本节点
if (textEnd >= 0) {
    rest = html.slice(textEnd)
    text = html.substring(0, textEnd)
    advance(textEnd)
}

// 文本节点的处理
if (options.chars && text) {
  // 调用 options 中的 chars 回调函数,创建该文本的 AST 节点
  options.chars(text)
}

3-4,栈是如何维护节点父子关系的

假设我们有如下的模板字符串。

<div class="container">
  <h1>我是文本1</h1>
  <h2>我是文本2</h2>
</div>

当解析 div 的开始标签的时候,我们向栈 push 这个 div 对应的 AST 节点。

  <h1>我是文本1</h1>
  <h2>我是文本2</h2>
</div>

当解析 h1 的开始标签的时候,我们向栈 push 这个 h1 对应的 AST 节点,当 push h1 对应 AST 节点的时候,程序能够发现栈的顶端有一个 div 的 AST 节点,这就说明,当前的 h1 是 div 的子节点。

      我是文本1</h1>
  <h2>我是文本2</h2>
</div>

 然后解析 "我是文本1" 这个文本节点,创建对应的 AST 节点,程序发现栈顶是一个 h1 AST 节点,所以这个文本节点是 h1 节点的子节点。

              </h1>
  <h2>我是文本2</h2>
</div>

接下来解析 h1 结束标签,程序发现栈顶是一个 h1 的 AST 节点,会进行出栈操作。

  <h2>我是文本2</h2>
</div>

接下来处理 h2 标签,处理流程和上面的 h1 标签是一样的,这里就不赘述了。

处理到最后,模板字符串所有的内容都处理完了,栈也成了空栈。

总结:

  1. 解析到开始标签,就会入栈;
  2. 解析到结束标签,就会出栈;
  3. 栈顶的 AST 节点是当前处理 AST 节点的父节点;

3-5,function advance (n) {}

该方法的作用是截取掉已经处理的模板字符串,参数是要截取字符串的长度。

function advance (n) {
  index += n
  html = html.substring(n)
}

例如:有如下的模板字符串:

let html = '<h1>我是文本</h1>'

执行 advance(4) 之后,html 变成了。

let html = '我是文本</h1>'

3-6,function parseStartTag () {}

用于解析开始标签,我们直接看例子,假如有如下的开始标签:

<div class="container" style="margin-top: 30px;">

其最终将会被解析成如下的对象。

{
  attrs: [
    [" class="container"", "class", "=", "container", undefined, undefined],
    [" style="margin-top: 30px;"", "style", "=", "margin-top: 30px;", undefined, undefined]
  ],
  end: 49,
  start: 0,
  tagName: "div",
  unarySlash: ""
}

3-7,function handleStartTag (match) {}

该函数的参数是 parseStartTag 函数的返回值,也就是上面被解析成的对象。

该函数的作用是:将上面的 attrs 转换成另外一种格式,判断标签是不是自闭和的标签,然后调用 options.start(tagName, attrs, unary, match.start, match.end) 回调函数。

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

   判断是不是自闭和的标签
  const unary = isUnaryTag(tagName) || !!unarySlash

  / 遍历处理标签的 attrs,转换成另外一种格式
  const l = match.attrs.length
  const attrs = new Array(l)
  // 遍历处理标签的 attrs
  for (let i = 0; i < l; i++) {
    const args = match.attrs[i]
    // hackish work around FF bug https://bugzilla.mozilla.org/show_bug.cgi?id=369778
    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] || ''
    attrs[i] = {
      name: args[1],
      value: decodeAttr(
        value,
        options.shouldDecodeNewlines
      )
    }
  }

  // 如果当前标签不是自闭和标签的话,需要将当前标签的信息对象 push 到栈数组中。栈数组用于处理 html 中标签的父子关系
  if (!unary) {
    stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs })
    lastTag = tagName
  }

  if (options.start) {
    // 调用 options 中的 start 回调函数,生成该开始标签的 AST
    options.start(tagName, attrs, unary, match.start, match.end)
  }
}

attrs 会被转换成如下的格式:

[
  {name: "class", value: "container"},
  {name: "style", value: "margin-top: 30px;"}
]

3-8,function parseEndTag (tagName, start, end) {}

parseEndTag 函数的作用是:

  1. 维护 stack 栈数据(我们上面说了,结束标签会进行退栈操作)
  2. 根据不同的情况,调用 options.start()、options.end() 回调函数

源码解释都是注释中,这里就不赘述了。

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

  if (tagName) {
    // 统一转换成小写
    lowerCasedTagName = tagName.toLowerCase()
  }

  // Find the closest opened tag of the same type
  if (tagName) {
    // stack 栈从上往下找,寻找与 lowerCasedTagName 相同的标签的下标
    // 一般情况下,相同的元素都是在栈顶,但这是DOM嵌套规范的情况下,
    // 有时候,不规范的嵌套,例如:<div><span></div>,在处理 </div> 的时候,与其对应的标签就不在栈顶
    for (pos = stack.length - 1; pos >= 0; pos--) {
      if (stack[pos].lowerCasedTag === lowerCasedTagName) {
        break
      }
    }
  } else {
    // If no tag name is provided, clean shop
    // 此处对应 tagName 是 undefined 的情况,在这里不做讨论
    pos = 0
  }

  // 如果 pos > 0,说明在栈中找到了与 lowerCasedTagName 相同的标签
  if (pos >= 0) {
    // 从栈顶往栈底遍历,直到当前处理标签对应开始标签的位置(pos)
    for (let i = stack.length - 1; i >= pos; i--) {
      // 用于处理类似于下面这种情况
      // <div><h1>Hello</h1>,h1 没有闭合标签,打印出警告。
      if (process.env.NODE_ENV !== 'production' &&
        (i > pos || !tagName) &&
        options.warn
      ) {
        // 打印警告
        options.warn(
          `tag <${stack[i].tag}> has no matching end tag.`
        )
      }
      // <div><h1>Hello</h1>,当处理 h1 闭合标签的时候,栈中有两个元素
      //     栈顶
      //   -------
      //      h1
      //     div
      //   -------
      //     栈底
      // 即使模板字符串中没有 h1 的闭合标签,在这里也会为其执行 end 回调函数,
      // 为 h1 执行 end 回调函数之后,也会为 div 执行 end 回调函数
      // 关于这一点,大家可以做个测试,在 Vue 的模板中写一个没有闭合标签的元素,
      // Vue 会发出警告,而且会为其添加闭合元素,添加闭合元素的源码级别实现就在这里
      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') {
    // 针对处理这种模板字符串:<div></br></div>
    if (options.start) {
      options.start(tagName, [], true, start, end)
    }
  } else if (lowerCasedTagName === 'p') {
    // 针对处理这种模板字符串:<div></p></div>,会为 p 结束标签增加对应的 <p> 开始标签
    // 真实的 DOM 会变成这样:<div><p></p></div>
    if (options.start) {
      options.start(tagName, [], false, start, end)
    }
    if (options.end) {
      options.end(tagName, start, end)
    }
  }
}

好了,parseHTML 的内容到这里就讲完了,接下来说说用于生成 AST 节点和维护 AST 层级关系的回调函数(start、end、chars、comment)。

4,讲解回调函数

回调函数定义在:src/compiler/parser/index.js ==> function parse(){}

export function parse (
  template: string,
  options: CompilerOptions
): ASTElement | void {
  let root

  // 调用 parseHTML 开始解析模板字符串
  parseHTML(template, {
    warn,
    expectHTML: options.expectHTML,
    isUnaryTag: options.isUnaryTag,
    canBeLeftOpenTag: options.canBeLeftOpenTag,
    shouldDecodeNewlines: options.shouldDecodeNewlines,
    shouldKeepComment: options.comments,
     下面的回调函数用于 AST 元素的生成和 AST 树结构的维护

    // 针对开始标签的回调函数
    start (tag, attrs, unary) {},
    // 针对结束标签的回调函数
    end () {},
    // 针对文本内容的回调函数
    chars (text: string) {},
    // 针对评论节点的回调函数
    comment (text: string) {}
  })

  return root
}

4-1,start (tag, attrs, unary) {}

start 回调函数的作用是:

  1. 创建标签 AST 节点;
  2. 进一步解析 AST 节点,增加更多的信息;
  3. 维护 AST 树结构;

首先说第一点:创建标签 AST 节点。

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

调用 createASTElement 方法生成 AST 节点,createASTElement 方法的源码如下。

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

创建 AST 节点的源码很简单,根据传递进来的参数,构建 AST 对象即可。

接下来说第二点:进一步解析 AST 节点,增加更多的信息。

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
  // 处理 v-for
  processFor(element)
  // 处理 v-if
  processIf(element)
  // 处理 v-once
  processOnce(element)
  // element-scope stuff
  processElement(element, options)
}

例如上面的 processIf(element),就是用来进一步处理 v-if 的。

假设有如下的模板字符串:

<div class="container">
  <h1 v-if="isShow">文本信息</h1>
</div>

h1 标签对应的 AST 节点刚创建时如下所示。

{
  attrsList: [{name: "v-if", value: "isShow"}],
  attrsMap: {v-if: "isShow"},
  children: [],
  tag: "h1",
  type: 1
}

我们可以看到其中的 v-if 是作为 attr 存在的,这需要进行进一步的解析。

processIf (el) 的源码如下所示。

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

经过 processIf 处理的 AST 节点如下所示。

{
  attrsList: [],
  attrsMap: {v-if: "isShow"},
  children: [],
  if: "isShow",
  ifConditions: [
    {exp: "isShow"}
  ],
  tag: "h1",
  type: 1
}

可以看到,多了 if 和 ifConditions 属性。

最后一点:维护 AST 树结构。

主要代码如下所示,解释都在注释中:

// tree management
if (!root) {
  // 如果 root 为 undefined 的话,说明当前处理的就是根节点
  // 所以将 element 直接赋值给 root
  root = element
} else if (!stack.length) {
  // 处理模板存在多个根节点的情况
  // 如果存在 root 节点,并且 stack 栈数组为空的话,说明模板存在多个根节点
  // 多个根节点的话,如果根节点上面有 v-if, v-else-if and v-else 来确保某一个特定时刻,只有一个根节点的话,
  // 也是可以被允许的。而如果没有 v-if, v-else-if and v-else 的话,则会打印出警告
  if (root.if && (element.elseif || element.else)) {
    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.`
    )
  }
}

if (currentParent && !element.forbidden) {
  // 维护 AST 树的父子关系
  currentParent.children.push(element)
  element.parent = currentParent
}

// 更新 currentParent 和 stack
currentParent = element
stack.push(element)

4-2,end () {}

end () {
  // remove trailing whitespace
  const element = stack[stack.length - 1]
  const lastNode = element.children[element.children.length - 1]
  if (lastNode && lastNode.type === 3 && lastNode.text === ' ' && !inPre) {
    element.children.pop()
  }
  // pop stack
  stack.length -= 1
  currentParent = stack[stack.length - 1]
},

第一段用于处理标签内全是全是空格的情况,例如如下的模板字符串。

<div class="container">
  <h1>      </h1>
</div>

当处理到 </h1> 结束标签的时候,就会进行第一段代码的优化处理,处理后的效果如下所示:

<div class="container">
  <h1></h1>
</div>

后面两行代码就很简单了,对 stack 做出栈操作以及更新 currentParent

4-3,chars (text: string) {}

chars (text: string) {
  if (!currentParent) {
    // 如果当前没有 currentParent 的话,说明有两种情况:
    // (1) 组件的 template 是一个纯文本
    // (2) 当前的文本写在标签的外面
    // 这两种情况都是不被允许的
    if (process.env.NODE_ENV !== 'production') {
      // 针对情况(1)
      if (text === template) {
        warnOnce(
          'Component template requires a root element, rather than just text.'
        )
      // 针对情况(2)
      } else if ((text = text.trim())) {
        warnOnce(
          `text "${text}" outside root element will be ignored.`
        )
      }
    }
    return
  }

  // 获取到父元素的 children 属性
  const children = currentParent.children
  text = inPre || text.trim()
    ? isTextTag(currentParent) ? text : decodeHTMLCached(text)
    // only preserve whitespace if its not right after a starting tag
    : preserveWhitespace && children.length ? ' ' : ''
  if (text) {
    let expression
    // 调用 parseText 对 text 进行解析。解析插值、过滤器等等特性 <span>{{name | nameFilter}}</span>
    if (!inVPre && text !== ' ' && (expression = parseText(text, delimiters))) {
      // 将当前文本的 AST 节点 push 到 children 数组中
      children.push({
        type: 2,
        expression,
        text
      })
    } else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
      // 处理 text 是纯文本的情况
      children.push({
        type: 3,
        text
      })
    }
  }
},

首先第一段判断文本的使用是否规范,不能出现模板字符串全是文本或者文本在根元素外面的情况,如果出现这两种错误的话,则会在开发环境下打印出警告。

然后尝试对文本进行解析,如果解析成功的话,说明文本不是纯文本,例如 "名字:{{name}}",此时会创建 type 为 2 的文本 AST 节点,并将该节点 push 到 currentParent.children 数组中。

如果解析失败的话,说明是纯文本节点,此时会创建 type 为 3 的文本 AST 节点,并将该节点 push 到 currentParent.children 数组中。

4-4,comment (text: string) {}

comment (text: string) {
  // 注释 AST 和纯文本 AST 很像,唯一的不同是有一个 isComment 属性,并且属性值为 true
  currentParent.children.push({
    type: 3,
    text,
    isComment: true
  })
}

comment 很简单,创建注释对应的 AST 节点,并 push 到 currentParent.children 数组中即可。

5,总结

解析器如果看具体细节的话,很复杂,因为解析器需要处理和考虑的东西很多。但是,如果我们抛开这些细节,先看整体流程的话,解析器的工作流程是很清晰的,并没有多难,无非就是在 HTML 解析器中不断地遍历解析模板字符串,解析的方法是利用正则表达式,解析完成之后,调用对应的回调函数,在回调函数中进行抽象语法树节点的构建和整个树结构的维护,一小段模板字符串处理完成后,就将其从模板字符串中截取出来。就这样,不断地循环,不断地解析,不断地触发回调函数,直到模板字符串变成空的字符串,解析器的工作也就完成了。

在这里,说一个小建议,大家可以先写一个简单的模板字符串,然后利用 debugger 调试解析器部分的源码,把相关的源码走一遍之后,就能够理解解析器整体的工作流程了。如果想了解 Vue 某个特性是如何解析的话,就在上面简单的模板字符串上添加上这个特性(例如 v-if),再 debugger 一遍。千万不要死读源码,也不要追求一遍就将所有特性的解析细节都搞清楚,一定要由简到难,一步一步来。

好了,解析器就讲到这里,接下来讲优化器的工作原理。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值